Skip to content

Core ​

Cap includes a stateless server-side library for generating and verifying JWT-based challenges, which is used internally for Standalone.

For most users we recommend Cap Standalone which runs on Docker and is batteries included. We only recommend using the core library directly when you can't run Docker, want to embed challenge generation into an existing service, or need to deploy to an environment without persistent storage (Cloudflare Workers, Lambda, edge functions).

Installation ​

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

Getting started ​

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

// Long, random, high-entropy. Keep this consistent across processes.
const SECRET = process.env.CAP_SECRET;

// 1) Server route: create a challenge
const ch = await generateChallenge(SECRET, {
  scope: "signup", // optional
  instrumentation: true, // optional, see below
});
// → { challenge: { c, s, d }, token, expires, instrumentation? }

// 2) Server route: validate the redeemed challenge
const result = await validateChallenge(
  SECRET,
  {
    token: req.body.token,
    solutions: req.body.solutions,
    instr: req.body.instr,
  },
  {
    scope: "signup",
    consumeNonce: async (sigHex, ttlMs) => myStore.setIfNotExists(`cap:${sigHex}`, 1, ttlMs),
  },
);

if (result.success) {
  // result.token, result.tokenKey, result.expires, result.scope
}

The widget calls generateChallenge to receive { challenge, token, expires, instrumentation }, solves the proof of work client-side, and POSTs { token, solutions, instr } back. You then call validateChallenge to verify.

How it differs from @cap.js/server ​

Aspect@cap.js/servercapjs-core
StateIn-memory + filesystem token storeStateless. Challenge tokens are signed JWTs
Constructornew Cap({ ... })None — pass secret per call
Replay preventionBuilt-in token list with cleanup intervalOpt-in via consumeNonce callback
Cleanup hooksSIGINT/beforeExit flushNone — TTL is encoded in JWT exp
FilesystemRequired for persistenceNever touched
Worker compatibleNo (filesystem)Yes

Unlike the old library, capjs-core does not validate redeem tokens for you - it returns a tokenKey you store yourself, and a token you give the user. To validate later, re-derive the key from the user's submitted token and look it up:

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

// Validation route
const [id, verToken] = req.body.token.split(":");
const tokenKey = `${id}:${createHash("sha256").update(verToken).digest("hex")}`;
const expires = await myStore.get(`cap-token:${tokenKey}`);
if (!expires || Number(expires) < Date.now()) {
  return res.status(401).end();
}

API ​

generateChallenge(secret, opts?) ​

Returns Promise<{ challenge, token, expires, instrumentation? }>.

  • secret — string or Buffer, ≥16 bytes. Master HMAC key, must be consistent across processes.
  • opts.challengeCount — number of PoW puzzles. Default 50.
  • opts.challengeSize — salt length in hex chars. Default 32.
  • opts.challengeDifficulty — target prefix length in hex chars. Default 4.
  • opts.expiresMs — challenge TTL. Default 600_000 (10 min).
  • opts.scope — optional string bound to the challenge. Validation must pass the same scope.
  • opts.extra — optional object embedded in the JWT payload (visible to anyone with the token).
  • opts.instrumentation — true for defaults, or an object: { blockAutomatedBrowsers, obfuscationLevel }.
  • opts.instrumentationGenerator — escape hatch for offloading script generation to a worker pool.

The token is a signed JWT containing the challenge config. expires is the JWT expiry in ms. instrumentation, if requested, is the deflate+base64 client-side script to be run by the widget.

validateChallenge(secret, body, opts?) ​

Returns Promise<{ success: true, token, tokenKey, expires, scope, iat, riskFlags } | { success: false, reason, instr_error?, blockedBy? }>.

riskFlags lists non-gating automation signals from instrumentation (currently only native_tamper), useful for raising difficulty on the next challenge. blockedBy is set when reason is instr_automated_browser and names the detection checks that failed.

body:

  • token — challenge token from generateChallenge
  • solutions — number array, length must equal challenge.c
  • instr — instrumentation result (if you enabled it)
  • instr_blocked, instr_timeout — flags from the widget when instrumentation rejected the page

opts:

  • scope — must match the original challenge's scope
  • tokenTtlMs — TTL for the redeem token. Default 1_200_000 (20 min).
  • consumeNonce(sigHex, ttlMs) — replay prevention via your storage. See below.
  • signToken(data) — async fn returning a custom redeem token format. By default returns id:secret.

Failure reasons ​

reasonmeaning
invalid_bodybody isn't an object
missing_tokenno token provided
missing_solutionssolutions missing or not an array
invalid_tokenJWT signature mismatch / malformed / out-of-bounds params
scope_mismatchtoken's scope doesn't match opts.scope
expiredchallenge JWT expired
invalid_solutionslength mismatch or non-numbers
nonce_store_errorconsumeNonce callback threw
already_redeemedconsumeNonce returned false
invalid_solutionsolutions don't satisfy the PoW
instr_*instrumentation failed (with instr_error: true)

Replay prevention ​

The library is stateless by design. To prevent a captured submission from being redeemed twice, pass a consumeNonce callback. capjs-core calls it with the JWT's signature hex and the remaining TTL; you store that hex in your KV with SET NX EX semantics and return false on repeats.

js
import { Redis } from "ioredis";
const redis = new Redis(process.env.REDIS_URL);

const consumeNonce = async (sigHex, ttlMs) => {
  const ttlSec = Math.ceil(ttlMs / 1000);
  const ok = await redis.set(`cap:${sigHex}`, "1", "NX", "EX", ttlSec);
  return ok === "OK";
};
js
const consumeNonce = async (sigHex, ttlMs) => {
  const key = `cap:${sigHex}`;
  if (await env.NONCES.get(key)) return false;
  await env.NONCES.put(key, "1", {
    expirationTtl: Math.ceil(ttlMs / 1000),
  });
  return true;
};
js
const consumeNonce = async (sigHex, ttlMs) => {
  const expiresAt = new Date(Date.now() + ttlMs).toISOString();
  try {
    await db`INSERT INTO cap_nonces (sig, expires_at) VALUES (${sigHex}, ${expiresAt})`;
    return true;
  } catch (e) {
    if (e.code === "23505") return false; // unique violation
    throw e;
  }
};

The check runs after PoW and instrumentation verification, so an attacker who replays a captured submission with garbage solutions can't burn the legitimate user's nonce.

Instrumentation ​

Pass instrumentation: true (or an options object) to generateChallenge to receive a deflate+base64 client script. The widget runs it, sends back a fingerprint, and validateChallenge verifies it.

js
const ch = await generateChallenge(SECRET, {
  instrumentation: {
    blockAutomatedBrowsers: true, // reject playwright/puppeteer/selenium
    obfuscationLevel: 3, // 1-10, default 3
  },
});

When blockAutomatedBrowsers is on, the script runs realm-escape and marker checks in the browser and ships a probe vector (text metrics, window geometry, navigator.webdriver, engine markers) that the server evaluates with detectAutomation. The server-side verdict is what blocks. See Instrumentation for the full check list and what each one is safe against.

detectAutomation(vector) is exported for callers who want to run the detector over a vector they collected themselves. It returns { pass, checks, blockedBy, riskFlags }.

Higher obfuscation levels are slower to generate. Levels 4–7 add a custom string-table indirection plus esbuild minification. Levels 8–10 layer in javascript-obfuscator (string-array, control-flow flattening, dead-code injection) — these block the event loop for tens of milliseconds per challenge, so use them only for low-volume routes or supply your own instrumentationGenerator that runs in a worker pool.

Stateless deployment patterns ​

Warning: These scripts don't include built-in replay protection. Make sure to add it yourself.

Cloudflare Workers ​

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

const SECRET = (env) => env.CAP_SECRET;

export default {
  async fetch(req, env) {
    const url = new URL(req.url);

    if (url.pathname === "/challenge" && req.method === "POST") {
      const ch = await generateChallenge(SECRET(env), { instrumentation: true });
      return Response.json(ch);
    }

    if (url.pathname === "/redeem" && req.method === "POST") {
      const body = await req.json();
      const result = await validateChallenge(SECRET(env), body, {
        consumeNonce: async (sigHex, ttlMs) => {
          if (await env.NONCES.get(`cap:${sigHex}`)) return false;
          await env.NONCES.put(`cap:${sigHex}`, "1", {
            expirationTtl: Math.ceil(ttlMs / 1000),
          });
          return true;
        },
      });
      return Response.json(result);
    }

    return new Response("not found", { status: 404 });
  },
};

Bun ​

js
import { generateChallenge, validateChallenge } from "capjs-core";
const SECRET = process.env.CAP_SECRET;

Bun.serve({
  port: 3000,
  routes: {
    "/challenge": {
      POST: () => Response.json(generateChallenge(SECRET, { instrumentation: true })),
    },
    "/redeem": {
      POST: async (req) => {
        const body = await req.json();
        return Response.json(await validateChallenge(SECRET, body));
      },
    },
  },
});

HashWX challenges ​

Since v0.1.1 and widget v0.1.51, both pieces understand a richer wire format that supports multiple challenge protocols in a single response: SHA-256 PoW (the default), HashWX, the deprecated RSW time-lock puzzle, and instrumentation.

Minimum opt-in ​

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

const SECRET = process.env.CAP_SECRET;

app.post("/api/challenge", async () => {
  return await generateChallenge(SECRET, {
    format: 2,
    protocols: ["hashwx", "instrumentation"],
    hashwxDifficulty: 1_000_000, // optional, this is the default
  });
});

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

HashWX needs no key material and no setup at boot. The first verification compiles the embedded WebAssembly module, which takes a few milliseconds; call hashwxReady() at startup to move that off the first request.

hashwxDifficulty is the expected number of hashes a client must compute, split across hashwxChallengeCount independent sub-challenges (default 4). One challenge on its own has an exponentially distributed solve time, so one visitor waits 30 ms and the next waits three seconds. At the same total difficulty, four sub-challenges cut the p90 by about a quarter and make the median about a third slower, since the attacker's expected work stays d hashes either way. See client cost. Each extra sub-challenge adds about 20 µs to verification. hashwxNoncesPerHash (default 65_536) is also accepted.