Skip to content

Core library ​

capjs-core generates and verifies challenges inside your own server. It's stateless: a challenge is a signed JWT, so it needs no database, no filesystem and no in-memory state, and it runs on Cloudflare Workers, Lambda and other edge runtimes. capd is built on it.

Most people should run capd, Cap's server, instead, which adds a dashboard, stats, rate limiting and token storage. Use the library when you can't run a container, or want challenges inside an existing service.

sh
npm i capjs-core
sh
pnpm add capjs-core
sh
bun add capjs-core

Getting started ​

You need two routes, one that creates a challenge and one that checks the solution. Point the widget's data-cap-api-endpoint at their common prefix, and the widget calls <prefix>challenge and <prefix>redeem.

js
import { generateChallenge, validateChallenge } from "capjs-core";

const SECRET = process.env.CAP_SECRET;

app.post("/cap/challenge", async () => {
  return await generateChallenge(SECRET, { instrumentation: true });
});

app.post("/cap/redeem", async (req) => {
  return await validateChallenge(SECRET, req.body, { consumeNonce });
});

SECRET signs every challenge. Make it long and random, at least 16 bytes, and use the same one in every process.

consumeNonce stops a solved challenge from being redeemed twice, and you supply it from your own storage. See replay prevention.

Verifying tokens ​

A successful validateChallenge returns a token for the visitor and a tokenKey for you. The library doesn't store either, so save tokenKey with its expires:

js
const result = await validateChallenge(SECRET, req.body, { consumeNonce });
if (result.success) {
  await store.set(`cap-token:${result.tokenKey}`, result.expires);
}
return result;

The widget hands token to your form. When the form arrives, derive the key from the submitted token and look it up:

js
import { createHash } from "node:crypto";

const [id, secret] = req.body["cap-token"].split(":");
const tokenKey = `${id}:${createHash("sha256").update(secret).digest("hex")}`;
const expires = await store.get(`cap-token:${tokenKey}`);

if (!expires || Number(expires) < Date.now()) {
  return new Response("invalid captcha", { status: 403 });
}
await store.delete(`cap-token:${tokenKey}`);

Deleting the key makes each token single-use.

HashWX ​

generateChallenge issues HashWX proof of work by default, the same format capd uses for new keys. It resists GPUs far better than SHA-256. instrumentation: true and behavior add an instrumentation challenge next to it.

To list the protocols yourself, pass them with format: 2:

js
return await generateChallenge(SECRET, {
  format: 2,
  protocols: ["hashwx", "instrumentation"],
});

format: 1 switches back to SHA-256 proof of work, and so does passing challengeCount, challengeSize or challengeDifficulty without a format. The widget detects the format on its own, and validateChallenge handles both. HashWX needs widget 0.1.58 or later, so update a pinned widget before you update the library.

OptionDefaultWhat it does
hashwxDifficulty1_000_000Expected hashes per solve, across all sub-challenges.
hashwxChallengeCount4Sub-challenges the difficulty is split into. More of them make solve times less random. See client cost.
hashwxNoncesPerHash65_536Nonces covered by each generated hash function.

HashWX needs no keys or setup. The first verification compiles a small WebAssembly module, and you can await hashwxReady() at startup to do that early.

Some runtimes don't allow compiling WebAssembly at runtime. Cloudflare Workers get a precompiled module automatically. Elsewhere, import it the way your platform supports and pass it in before the first verification:

js
import { setHashwxModule } from "capjs-core";
import hashwxModule from "capjs-core/hashwx.wasm";

setHashwxModule(hashwxModule);

Replay prevention ​

validateChallenge calls consumeNonce(signature, ttlMs) once the proof of work checks out, and before it checks instrumentation. Store the signature if it's new and return true. Return false if it was already used. A request with a wrong proof of work never reaches it, so it can't use up a real visitor's challenge. A request with a valid proof of work and a failing instrumentation result does use it up, so a bot can't retry the instrumentation check against the same proof of work.

js
const consumeNonce = async (sig, ttlMs) =>
  (await redis.set(`cap:${sig}`, "1", "NX", "PX", ttlMs)) === "OK";
js
const consumeNonce = async (sig, ttlMs) => {
  if (await env.NONCES.get(`cap:${sig}`)) return false;
  await env.NONCES.put(`cap:${sig}`, "1", { expirationTtl: Math.max(60, Math.ceil(ttlMs / 1000)) });
  return true;
};
js
const consumeNonce = async (sig, ttlMs) => {
  try {
    await sql`INSERT INTO cap_nonces (sig, expires_at) VALUES (${sig}, ${new Date(Date.now() + ttlMs)})`;
    return true;
  } catch (e) {
    if (e.code === "23505") return false;
    throw e;
  }
};

Workers KV isn't atomic, so two requests that arrive together can both pass. Use a Durable Object if that matters.

Instrumentation ​

instrumentation: true adds an instrumentation challenge to format-1 challenges. With format 2, add "instrumentation" to protocols. In both cases you can pass an object in place of true:

js
instrumentation: {
  blockAutomatedBrowsers: true,
  obfuscationLevel: 3,
}
OptionDefaultWhat it does
blockAutomatedBrowsersfalseFails solves from automated browsers with instr_automated_browser.
detectAutomatedInteractionsfalseAdds interaction_synthetic_clicks and interaction_synthetic_keydowns to riskFlags. See interaction detection.
blockAutomatedInteractionsfalseFails those solves with instr_automated_interaction. Turns on detection too.
obfuscationLevel5From 1 to 10. Levels 1 to 3 strip whitespace, 4 to 7 also encode strings and minify, and 8 to 10 run javascript-obfuscator when it is installed. Higher levels are slower to generate.

Levels 8 to 10 need the optional javascript-obfuscator package and block the event loop for tens of milliseconds per challenge, so keep them for low-traffic routes, or generate scripts in a worker pool with instrumentationGenerator. It receives the instrumentation options and has to resolve to what generateInstrumentation returns.

Behavior and agents ​

behavior turns on behavior scoring and agent blocking. It adds an instrumentation challenge if the challenge has none, and the policy is signed into the challenge token, so the client can't turn it off.

js
await generateChallenge(SECRET, {
  format: 2,
  protocols: ["hashwx", "instrumentation"],
  behavior: { block: true, blockAgents: "identified" },
});

behavior: true scores without blocking. The object takes:

OptionDefaultWhat it does
blockfalseFails automated verdicts with behavior_automated, and no_interaction verdicts with behavior_no_interaction.
threshold0.75Score from 0.1 to 1 at which the verdict becomes automated.
strictfalseLifts the 0.6 cap on scores made only of weak flags.
requireInteractiontrueSet it to false to let no_interaction through when block is on, for invisible and floating mode.
blockAgentsfalse"identified" fails agents detected with high confidence, and "all" fails every detected agent, with behavior_agent.
allowVerifiedAgentsfalseLets agents with a verified Web Bot Auth signature through blockAgents.

A solve with no behavior trace always fails with behavior_missing, whatever the policy.

To use request headers as agent evidence, pass the /redeem request to validateChallenge:

js
const result = await validateChallenge(SECRET, body, {
  consumeNonce,
  request: { method: req.method, url: fullUrl, headers: req.headers },
  verifyWebBotAuth: true,
});

url has to be the full URL, since Web Bot Auth signs it. With verifyWebBotAuth: true, a request that carries a Signature-Agent header is checked against the agent's key directory, which validateChallenge fetches over HTTPS and caches for an hour. It only trusts the signers Cap knows and the origins in trustedSignatureAgents. Pass webBotAuthNonceCheck(nonce, ttlMs) to reject replayed signatures. It works like consumeNonce. The signed @authority has to match the host in url. If a proxy rewrites url to an internal address, pass your public host as webBotAuthAuthority.

behaviorReplayCheck(digest, ttlMs) works the same way for behavior traces. Return true if the digest was seen before, and the trace gets bh_replayed. Only traces with 8 or more pointer moves and key presses have a digest.

With behavior on, a successful result has behavior: { score, verdict, flags, signals, agent }. With it off, agent is returned at the top level whenever there's evidence, built from page marks and headers only, and it's never blocked. Either way, a detected agent adds agent:<name> to riskFlags. agent looks like this:

js
{
  detected: true,
  name: "claude-in-chrome",
  confidence: "high",
  verified: false,
  evidence: ["artifact:cic_active"],
}

API ​

generateChallenge(secret, options?) ​

OptionDefaultWhat it does
challengeCount50SHA-256 puzzles per challenge.
challengeSize32Salt length in hex characters.
challengeDifficulty4Required hash prefix length in hex characters.
expiresMs600_000How long the visitor has to solve it.
scopenoneA string bound to the challenge. Validation has to pass the same one.
extranoneData embedded in the JWT. Anyone with the token can read it.
instrumentationfalseSee instrumentation.
instrumentationGeneratorgenerateInstrumentationAsync function that generates the instrumentation script.
behaviorfalseSee behavior and agents.
format, protocolsformat 2 with HashWXSee HashWX. Passing keypair or rsw without protocols picks the deprecated RSW protocol.
hashwxDifficulty, hashwxChallengeCount, hashwxNoncesPerHashsee HashWXHashWX settings for format 2.
keypair, t, rswnoneSettings for the deprecated RSW protocol.

Format 1 returns { challenge, token, expires, instrumentation? }, and format 2 returns { token, format: 2, challenges, expires }. Both add behavior: true when behavior scoring is on. Send the whole object to the widget.

validateChallenge(secret, body, options?) ​

body is what the widget posts to /redeem. The options are:

OptionDefaultWhat it does
consumeNoncenoneSee replay prevention.
scopenoneMust match the challenge's scope.
tokenTtlMs1_200_000How long the visitor's token stays valid.
signTokenid:secretFunction that receives { scope, expires, iat } and returns your own token. tokenKey is left out.
requestnoneThe /redeem request as { method, url, headers }. See behavior and agents.
verifyWebBotAuthfalseVerifies Web Bot Auth signatures on request.
fetchglobal fetchUsed to download Web Bot Auth key directories.
trustedSignatureAgentsnoneExtra signer origins to trust, like "https://agent.example.com".
webBotAuthNonceChecknoneRejects replayed Web Bot Auth signatures.
webBotAuthAuthorityhost of request.urlHost that Web Bot Auth signatures have to cover.
behaviorReplayChecknoneFlags replayed behavior traces.

On success it returns { success: true, token, tokenKey, expires, scope, iat, riskFlags }, plus behavior or agent as described in behavior and agents. riskFlags lists signals that don't block on their own, like native_tamper or legacy_widget, so you can raise the difficulty for that visitor. The instrumentation page lists the browser check flags and the behavior flags.

On failure it returns { success: false, reason }. Instrumentation failures also have instr_error: true, instr_automated_browser and instr_automated_interaction have a blockedBy list, and behavior failures have behavior. Every reason is listed in the error codes table.

Lower-level exports ​

capd and the tests use these. You only need them to build your own pipeline.

ExportWhat it does
generateInstrumentation(opts?)Generates an instrumentation script and the metadata to check it.
verifyInstrumentationResult(meta, payload, now?)Checks a result against that metadata. Returns { valid, reason?, riskFlags?, blockedBy?, checks?, agent?, trace? }.
serializeBound(data), canonicalize(value)Serialize collected data the way the script does.
boundValues(binding, serialized)The numbers a result has to contain for that data.
MAX_BOUND_DATALongest serialized data accepted, 65,536 characters.
detectAutomation(vector)Runs the automated browser checks on browser facts you collected yourself. Returns { pass, checks, blockedBy, riskFlags }.
analyzeBehavior(trace, ctx?)Scores a behavior trace. ctx takes { replayed, strict, threshold }. Returns { score, verdict, flags, signals, digest }.
BEHAVIOR_WEIGHTS, DEFAULT_BEHAVIOR_THRESHOLD, WEAK_CAPThe flag weights, 0.75 and 0.6.
agentFromHeaders(headers)Agent evidence from User-Agent, Signature-Agent and Sec-CH-UA-Platform.
agentFromTrace(trace)Agent evidence from a behavior trace's click and typing timing. Only pass traces that analyzeBehavior didn't flag as bh_malformed.
summarizeAgent(evidence, verification?)Combines evidence, and an optional verifyWebBotAuth result, into the agent object, or null if there's no evidence.
verifyWebBotAuth(req, opts?)Verifies a Web Bot Auth signature. Resolves to { verified, agent?, origin?, keyid?, reason? }.
parseSignatureAgent(value)Parses a Signature-Agent header into { label, url } entries.
hashwxReady(), setHashwxModule(module)Compile the HashWX module early, or supply one. See HashWX.
hashwxTarget(difficulty), hashwxSeed(challenge, block), hashwxHash(state, seed, nonce)HashWX primitives. state comes from hashwxReady().
DEFAULT_HASHWX_DIFFICULTY, DEFAULT_HASHWX_CHALLENGE_COUNT, DEFAULT_HASHWX_NONCES_PER_HASH1_000_000, 4 and 65_536.
generateRswKeypair(bits?), serializeRswKeypair(kp), deserializeRswKeypair(s), buildRswMinter(args, opts?)Keys and minters for the deprecated RSW protocol.

Migrating from @cap.js/server ​

@cap.js/server is the older library, superseded by this one. It kept challenges and tokens in storage you configured. capjs-core keeps nothing. Challenges are signed JWTs, replay prevention goes through consumeNonce, and you store tokenKey yourself as shown in verifying tokens. It never touches the filesystem, so it runs on Workers and Lambda.