How to Solve Friendly Captcha in Node.js Without a Browser

solve friendly captcha in node.js - How to Solve Friendly Captcha in Node.js Without a Browser

To solve Friendly Captcha in Node.js, fetch the page and keep the cookies it sets, read the frc-captcha widget and its script tag with cheerio, decide v1 or v2 from that script, and pass the sitekey, the page URL, the version and the script address to CapSkip’s friendlyCaptcha method. Post the token it returns together with the form’s own hidden fields, in frc-captcha-response on v2 or frc-captcha-solution on v1. Two things cause most failures here. The built-in fetch keeps no cookies, so a CSRF-protected form rejects a perfect token. And solving the wrong version returns a token that looks valid and fails without a word. CapSkip added Friendly Captcha in 1.4.0, and this guide does the whole run with no browser on your side.

What you need

  • CapSkip 1.4.0 or later running on a Windows machine. That release added Friendly Captcha, alongside CaptchaFox and Capy Puzzle.
  • Node.js 22 or later and version 1.3.0 or later of the capskip package, the first release with friendlyCaptcha. The samples also use cheerio to read the HTML.
  • The URL of the page that shows the widget. The sitekey, the script address and the field name all come out of that page’s HTML.
  • An address for the solver. Local mode answers on 127.0.0.1 for that device only; Server mode listens on your network address or public IP so a script on another box can call it over the API. Both are under connection settings, and Step 4 covers when to switch.
# npm install capskip cheerio
npm install capskip cheerio

The samples are ES modules with top-level await. Save them with an .mjs extension, or change the type field in your package.json to module, since npm init now writes commonjs there, and import works as shown. The capskip package also loads with require if your project is CommonJS.

Step 1: fetch the page and keep its cookies

Node’s fetch is a good HTTP client with one gap that matters here: it has no cookie jar. Every call starts clean, so the session cookie the page sets is gone by the time you post the form. A site that ties its CSRF token to that session then rejects the post, often with a 403, however good the CAPTCHA token is. Python’s requests.Session hides this problem, but in Node you carry the cookies yourself.

The tool for it is Headers.getSetCookie(), which returns each Set-Cookie line on its own. A plain headers.get call cannot do that job, because it joins the lines with commas and cookie expiry dates contain commas too.

// npm install capskip cheerio
import * as cheerio from "cheerio";

const PAGE_URL = "https://example.com/signup";

// fetch() keeps no cookies between calls, so carry them by hand.
const jar = new Map();
function remember(res) {
  for (const line of res.headers.getSetCookie()) {
    const pair = line.split(";")[0];
    const eq = pair.indexOf("=");
    jar.set(pair.slice(0, eq).trim(), pair.slice(eq + 1).trim());
  }
}
const cookieHeader = () => [...jar].map(([k, v]) => `${k}=${v}`).join("; ");

const page = await fetch(PAGE_URL);
remember(page);
const $ = cheerio.load(await page.text());

That jar is deliberately small. It keeps names and values and ignores paths and expiry, which is all one site and one form need.

Step 2: read the widget, pick v1 or v2, and call friendlyCaptcha

Both versions render the same element, a div with the class frc-captcha and a data-sitekey attribute, so a plain widget says nothing about the protocol. The script tag does. A v2 site loads the @friendlycaptcha/sdk package, whose file is site.min.js, and a v1 site loads friendly-challenge, whose file is widget.module.min.js. Cheerio pulls both out in a few lines:

const widget = $(".frc-captcha").first();
const form = widget.closest("form");

// Every script except nomodule fallbacks, as full addresses.
const scripts = $("script[src]").not("[nomodule]")
  .map((_, el) => new URL($(el).attr("src"), PAGE_URL).href)
  .get();

Passing the page URL as the base matters for self-hosted widgets. It turns a relative src such as /vendor/v2/site.min.js into a full address, and on v2 CapSkip loads that exact script in its own browser to run the solve. A bare path never loads.

CapSkip picks the version in a fixed order and stops at the first answer: the version you pass, then the script address you pass as moduleScript, then a default of v1. The default is the trap. A bundled script such as /assets/app.4f2a.js tells CapSkip nothing, so it solves v1 and a v2 site rejects every token. Decide in your own code instead, where you can refuse to guess:

const V2_FILES = ["site.min.js", "site.compat.min.js"];
const V1_FILES = ["widget.module.min.js", "widget.min.js", "widget.polyfilled.min.js"];

function friendlyVersion(scripts, widget) {
  // Package names are unambiguous, so check them first.
  for (const src of scripts) {
    if (src.includes("@friendlycaptcha/sdk")) return { version: "v2", script: src };
    if (src.includes("friendly-challenge")) return { version: "v1", script: src };
  }
  // Self-hosted builds usually keep the file name. Themes ship their own
  // site.min.js too, so only trust a path that says friendly.
  for (const src of scripts.filter((s) => s.toLowerCase().includes("friendly"))) {
    const file = new URL(src).pathname.split("/").pop();
    if (V2_FILES.includes(file)) return { version: "v2", script: src };
    if (V1_FILES.includes(file)) return { version: "v1", script: src };
  }
  // Last resort: v2 and v1 name their widget options differently.
  if (widget.is("[data-api-endpoint], [data-form-field-name]")) return { version: "v2" };
  if (widget.is("[data-puzzle-endpoint], [data-solution-field-name]")) return { version: "v1" };
  throw new Error("v1 or v2? Read the page and set it by hand.");
}

The file-name check only trusts paths that mention friendly, because a theme can load a site.min.js of its own that has nothing to do with the widget.

Then make the call. friendlyCaptcha takes the sitekey, the page URL and an options object. The SDK drops undefined values before it sends anything, so an attribute the page does not have simply stays out of the request:

import { CapSkip } from "capskip";

const solver = new CapSkip({ host: "127.0.0.1", port: 8080 });
const { version, script } = friendlyVersion(scripts, widget);

const result = await solver.friendlyCaptcha(widget.attr("data-sitekey"), PAGE_URL, {
  version,
  moduleScript: script,
  // EU sites: data-api-endpoint="eu" (v2) or data-puzzle-endpoint (v1)
  apiServer: widget.attr("data-api-endpoint") ?? widget.attr("data-puzzle-endpoint"),
});

console.log(result.token.slice(0, 40));   // v2 tokens start with AQQA.

The apiServer line is for sites on Friendly Captcha’s EU endpoint. The global endpoint still mints a token for an EU sitekey, so solving there fails only at the site’s own check, the same silent failure as the wrong version. result.token is the string to submit, result.code holds the same string, and result.captchaId is CapSkip’s id for the job. A version other than v1, v2, 1 or 2, an empty sitekey, or an option the method does not know throws ValidationException before any request leaves your machine.

Step 3: post the token with the form’s own fields

The token field is not in the HTML you fetched, because the widget script creates it in the browser. So you add it yourself, under the name the version and the widget use. Everything else the form carries, a hidden CSRF token included, comes from the form itself:

// A renamed field wins; otherwise the default for the version.
const field = widget.attr("data-form-field-name")
  ?? widget.attr("data-solution-field-name")
  ?? (version === "v2" ? "frc-captcha-response" : "frc-captcha-solution");

// The form's own fields, hidden CSRF token included.
const body = new URLSearchParams(
  form.serializeArray().map((f) => [f.name, f.value]),
);
body.set("email", "YOUR_EMAIL");
body.set(field, result.token);

const res = await fetch(new URL(form.attr("action") || PAGE_URL, PAGE_URL), {
  method: "POST",
  // Browsers send the page as Referer; some servers refuse a post without it.
  headers: { cookie: cookieHeader(), referer: PAGE_URL },
  body,
});
console.log(res.status);

serializeArray collects the fields a browser would send, which is how the CSRF token gets back to the server without you naming it. It leaves out the submit button, so if the site checks for the button’s name, add it with body.set. Send the page URL as Referer too: fetch sends no Referer or Origin header, and some frameworks, Django among them, refuse an HTTPS form post that has neither, however right the cookie and token are. A URLSearchParams body makes fetch send an ordinary form post and set the content type for you. A v2 token is about six kilobytes, so it belongs in that body and never in a query string, while a v1 token is four dot-separated parts and a few hundred characters. Pass it through untouched.

Two cases need a look in DevTools. On v1, a data-solution-field-name set to a single hyphen means the widget writes no field at all and the site’s own script sends the token another way. Some sites also post JSON from JavaScript rather than submitting the form. For either, submit once by hand and copy the request the page actually makes.

Each token is good for one submit. Friendly Captcha’s verification refuses a response that was already used or has expired, so solve again for every form.

Step 4: many forms at once, and where the solver runs

Every CapSkip method in Node already returns a Promise, and AsyncCapSkip is just another name for the same class, so concurrency is plain JavaScript. What you need is a cap. Push a hundred calls through Promise.all and CapSkip queues everything past its Friendly Captcha Max. Threads setting, 10 by default, where a task waits up to 250 seconds for a free thread before CapSkip fails it. A small worker pool keeps at most ten in flight:

// Run fn over items with at most `limit` in flight. A failed item
// becomes its Error instead of rejecting the whole batch.
async function mapLimited(items, limit, fn) {
  const results = new Array(items.length);
  let next = 0;
  async function worker() {
    while (next < items.length) {
      const i = next++;
      results[i] = await fn(items[i]).catch((err) => err);
    }
  }
  await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
  return results;
}

// jobs: { sitekey, url, version, script, apiServer } objects from Step 2
const tokens = await mapLimited(jobs, 10, (job) =>
  solver.friendlyCaptcha(job.sitekey, job.url, {
    version: job.version,
    moduleScript: job.script,
    apiServer: job.apiServer,
  }).then((r) => r.token));

Expect solve times to vary. Friendly Captcha sets the work per request and raises it for addresses it has seen a lot of, and CapSkip solves every v2 widget in a real browser. That is why the method polls for up to recaptchaTimeout, 300 seconds by default, rather than the 120 second defaultTimeout. CapSkip keeps a clock of its own too: in its Friendly Captcha settings a solve gets 120 seconds of Row Timeout before it is failed. You can pass timeout in the options to change the SDK’s wait for one call. The SDK has to outlast the wait for a thread plus the solve, and at CapSkip’s defaults that can reach 370 seconds. So keep the pool at Max. Threads or pass a longer timeout, and raise it again whenever you raise Row Timeout, or the call ends in TimeoutException while CapSkip is still working. The poll interval can also be set per call, but only as polling_interval in snake case. The camelCase pollingInterval belongs to the constructor, and as a per-call option it throws ValidationException. If solves slow down over a long run, rising difficulty on one address is the usual cause, so pass a proxy object with type and uri keys, or set up a proxy pool in CapSkip.

The samples use 127.0.0.1 because that is right when Node and the solver share a machine. Run the script anywhere else, such as a VPS, a container or a CI runner, and loopback points at that box, so the first call throws NetworkException with ECONNREFUSED. Switch CapSkip to Server mode and it listens on your network address or public IP, so any of those can reach it over the same API. Use a static public IP when the route crosses the internet, with a firewall rule for the addresses you expect. It is still your own hardware and still unmetered. The client does not read environment variables by itself, so read CAPSKIP_HOST in your code and pass it in, as the full example below does.

Full working example

Here is the whole run in one file: the shortest complete way to solve Friendly Captcha in Node.js from nothing but a page URL.

// npm install capskip cheerio
// solve-friendly.mjs: run with node solve-friendly.mjs
import * as cheerio from "cheerio";
import { CapSkip, CapSkipError, ValidationException } from "capskip";

const PAGE_URL = "https://example.com/signup";
const V2_FILES = ["site.min.js", "site.compat.min.js"];
const V1_FILES = ["widget.module.min.js", "widget.min.js", "widget.polyfilled.min.js"];

const jar = new Map();
function remember(res) {
  for (const line of res.headers.getSetCookie()) {
    const pair = line.split(";")[0];
    const eq = pair.indexOf("=");
    jar.set(pair.slice(0, eq).trim(), pair.slice(eq + 1).trim());
  }
}
const cookieHeader = () => [...jar].map(([k, v]) => `${k}=${v}`).join("; ");

function friendlyVersion(scripts, widget) {
  for (const src of scripts) {
    if (src.includes("@friendlycaptcha/sdk")) return { version: "v2", script: src };
    if (src.includes("friendly-challenge")) return { version: "v1", script: src };
  }
  for (const src of scripts.filter((s) => s.toLowerCase().includes("friendly"))) {
    const file = new URL(src).pathname.split("/").pop();
    if (V2_FILES.includes(file)) return { version: "v2", script: src };
    if (V1_FILES.includes(file)) return { version: "v1", script: src };
  }
  if (widget.is("[data-api-endpoint], [data-form-field-name]")) return { version: "v2" };
  if (widget.is("[data-puzzle-endpoint], [data-solution-field-name]")) return { version: "v1" };
  throw new Error("v1 or v2? Read the page and set it by hand.");
}

const solver = new CapSkip({
  apiKey: process.env.CAPSKIP_API_KEY ?? "capskip",
  host: process.env.CAPSKIP_HOST ?? "127.0.0.1",
  port: Number(process.env.CAPSKIP_PORT ?? 8080),
});

const page = await fetch(PAGE_URL);
remember(page);
const $ = cheerio.load(await page.text());
const widget = $(".frc-captcha").first();
if (!widget.attr("data-sitekey")) {
  throw new Error("No frc-captcha widget in the HTML; it may be built by JS.");
}
const form = widget.closest("form");
const scripts = $("script[src]").not("[nomodule]")
  .map((_, el) => new URL($(el).attr("src"), PAGE_URL).href).get();

const { version, script } = friendlyVersion(scripts, widget);
let result;
try {
  result = await solver.friendlyCaptcha(widget.attr("data-sitekey"), PAGE_URL, {
    version,
    moduleScript: script,
    apiServer: widget.attr("data-api-endpoint") ?? widget.attr("data-puzzle-endpoint"),
  });
} catch (err) {
  if (err instanceof ValidationException) throw new Error(`not sent: ${err.message}`);
  if (err instanceof CapSkipError) throw new Error(`solve failed: ${err.name}: ${err.message}`);
  throw err;
}

const field = widget.attr("data-form-field-name")
  ?? widget.attr("data-solution-field-name")
  ?? (version === "v2" ? "frc-captcha-response" : "frc-captcha-solution");

// Post wherever the form's action points, with its other fields.
const body = new URLSearchParams(form.serializeArray().map((f) => [f.name, f.value]));
body.set("email", "YOUR_EMAIL");
body.set(field, result.token);

const res = await fetch(new URL(form.attr("action") || PAGE_URL, PAGE_URL), {
  method: "POST",
  // Browsers send the page as Referer; some servers refuse a post without it.
  headers: { cookie: cookieHeader(), referer: PAGE_URL },
  body,
});
console.log(res.status, version, field);

The version is decided once and used twice, for the solve and for the field name, so the two can never disagree. When cheerio finds no widget, the page usually builds it from JavaScript, and you need the sitekey from the rendered page or from the script that creates it. Every parameter the raw endpoint accepts is in the Friendly Captcha API reference.

Common errors and what they mean

What you seeCauseFix
403, or a CSRF error, with a token CapSkip returnedThe post went out without the page’s cookies, its hidden fields or a RefererSend the cookie header from Step 1 and a Referer, and build the body from serializeArray
The site rejects a token with no other errorThe wrong version was solved, often v1 by defaultDecide the version in code as in Step 2 and pass it
Rejected on a site whose widget has data-api-endpoint (v2) or data-puzzle-endpoint (v1)The token came from the global endpoint and the site uses the EU onePass the attribute’s value as apiServer
A v2 solve fails on a site that self-hosts the widgetmoduleScript was a relative path, so the widget never loadedResolve the src against the page URL with new URL
ValidationException naming pollingIntervalThe per-call option is spelled polling_intervalUse snake case per call, or set pollingInterval on the constructor
SyntaxError about await or importThe file runs as CommonJSUse an .mjs extension or set the package type to module
TypeError: getSetCookie is not a functionNode.js older than 18.15 or 19.7Upgrade to Node.js 22 or later
ApiException saying ERROR_CAPTCHA_UNSOLVABLE within seconds, every timeFriendly Captcha refuses the sitekey or the page’s origin, or the account has no v2 or EU endpoint enabledCheck the sitekey, the page URL and apiServer; a retry will not help
ApiException saying ERROR_CAPTCHA_UNSOLVABLE after two minutes or moreA solve ran past CapSkip’s Row Timeout of 120 seconds, or waited past its Wait Timeout of 250 seconds for a free threadKeep the pool at Max. Threads, add proxies, or raise Row Timeout in the Friendly Captcha settings
TimeoutException after 300 secondsA task waited for a free thread and then solved for longer than the SDK pollsKeep the pool at Max. Threads, or pass a longer timeout
NetworkException with ECONNREFUSEDCapSkip is not running, or the host and port are wrongStart CapSkip, then check Local mode against Server mode

FAQ

Do I need Puppeteer or Playwright to solve Friendly Captcha in Node.js?

No. Your script only needs the HTML, which fetch gets. The browser work happens inside CapSkip, which solves v2 widgets in its own browser and v1 puzzles directly. If your script already drives a browser for other reasons, the same call works: read the sitekey and script address from the live page instead of the fetched HTML.

Does it work in TypeScript?

Yes. The capskip package ships its own type definitions, and the options object is typed with both the camelCase names used here and the snake_case API names. One catch: the shared result type declares token as optional and still describes it as an ALTCHA field, because one result shape covers every method. friendlyCaptcha always fills it, so a non-null assertion on result.token is safe. cheerio’s attr also returns string or undefined, so assert the sitekey and the script src the same way in strict mode.

Can a Node app on a VPS use the solver on my Windows PC?

Yes. Put CapSkip in Server mode under connection settings so it listens on a network address instead of loopback, set CAPSKIP_HOST where the app runs, and pass it to the client as the full example does. A VPS, a container and a hosted runner all connect over the same HTTP API. Use a static public IP with a firewall rule when the route crosses the internet. The solver stays on hardware you own, so running more solves never changes what you pay.

How is this different from the Python guide?

The endpoint, the options and the result are the same in every CapSkip SDK; the code around them is not. Python’s requests.Session keeps cookies for you, while Node’s fetch needs the small jar from Step 1. Python’s AsyncCapSkip is a separate asynchronous client, while in Node every method is already asynchronous, so the only thing to add is a limit on how many run at once. The Python version of this workflow is in the Python Friendly Captcha guide.

The short version

To solve Friendly Captcha in Node.js, fetch the page and keep its cookies with getSetCookie, then read the frc-captcha element and its script tags with cheerio, resolving every src to a full address. Decide v1 or v2 from the package or file name, or from the widget’s own attributes, and throw if neither says. Call friendlyCaptcha with the sitekey, the page URL, that version and the script address, plus apiServer for EU sites. Post the token once, in the body, next to the form’s own fields and in the field the version names.

Friendly Captcha charges its difficulty in CPU time, not money, so a busy day costs solve time on your own machine and nothing else. Run a local captcha solver and the only limits are that machine and the threads you give it.