TL;DR / Quick Summary: A stateful CAPTCHA stores one row per challenge issued — which means an attacker chooses how much memory your server spends. CustomerGPT issues cryptographically signed stateless challenges instead: the SVG code, its salted hash and an expiry travel together in an HMAC-SHA256 token, and verification is a signature recomputation with zero lookups. Same security, no session store to exhaust.

Every public sign-up form is a small invitation to spend your money. A script does not need to break in — it only needs to make you do work. Issue a challenge, store a row, wait for an answer that never comes, repeat a million times before lunch.
The fix is not a harder puzzle. It is removing the server-side state that made the attack cheap in the first place.
The Problem With Stateful CAPTCHAs
The textbook implementation looks harmless. Generate a code, save it against a session id, send the picture, compare on submit. The cost model is what makes it fragile:
- The attacker controls your write volume. One GET to the challenge endpoint equals one row. Nothing about that transaction costs them anything.
- Abandoned challenges dominate. Real users solve maybe one challenge; a scanner requests thousands and solves none, so the store fills with garbage.
- Eviction becomes a security decision. Under pressure you start dropping entries, and now legitimate users see “challenge expired” because a bot filled the cache.
- It does not scale sideways. The row must be visible to whichever node handles the submit, so you need sticky sessions or a shared store on the hot path.
Stateful challenge
- One write per challenge issued, attacker-controlled
- Shared store or sticky sessions on the submit path
- Cache pressure turns into false expiries for real users
- Cleanup jobs and TTL tuning become permanent chores
Signed stateless token
- Zero writes per challenge issued
- Any node can verify — the token carries the truth
- Memory pressure is unrelated to challenge volume
- Expiry is enforced by the payload, not by a sweeper
How the Signed Challenge Works
The whole scheme rests on one property of an HMAC: only a holder of the server key can produce a valid signature, but anyone can carry the signed blob around. So the answer travels with the client, and the client still cannot forge it.
- 1Render the challengeA randomised alphanumeric code is drawn as an SVG with light distortion and noise strokes.
- 2Sign the payloadA salted hash of the answer plus an expiry and a nonce are signed with HMAC-SHA256 using the server key.
- 3Ship both to the clientThe SVG renders in the form; the opaque token rides in a hidden field. Nothing hits the database.
- 4Verify on submitRecompute the signature, check the expiry, compare the answer hash in constant time. Constant work, no lookup.
// Issuing a challenge — note that nothing is persisted
import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
function issueChallenge(secret) {
const answer = randomCode(5); // e.g. "7XKQ2"
const salt = randomBytes(8).toString('hex');
const payload = {
h: sha256(salt + answer.toUpperCase()), // never the answer itself
s: salt,
exp: Date.now() + 5 * 60_000,
n: randomBytes(8).toString('hex') // nonce, for single-use marking
};
const body = Buffer.from(JSON.stringify(payload)).toString('base64url');
const sig = createHmac('sha256', secret).update(body).digest('hex');
return { svg: renderSvg(answer), token: body + '.' + sig };
}Verification is the same three lines in reverse, plus the checks that actually matter:
function verifyChallenge(token, userInput, secret) {
const [body, sig] = token.split('.');
const expected = createHmac('sha256', secret).update(body).digest('hex');
if (!timingSafeEqual(Buffer.from(expected), Buffer.from(sig))) return false;
const p = JSON.parse(Buffer.from(body, 'base64url').toString());
if (Date.now() > p.exp) return false; // expired
if (await nonceSeen(p.n)) return false; // already used
const given = sha256(p.s + userInput.trim().toUpperCase());
return timingSafeEqual(Buffer.from(p.h), Buffer.from(given));
}The One Piece of State You Still Need
A purely stateless token can be replayed until it expires. The honest answer is that you need exactly one small marker — and the important detail is when you write it.
- Do not record issued challenges. That is the attacker-controlled write you just removed.
- Do record consumed nonces, with a TTL equal to the remaining validity of the token. A few bytes, written only after a genuine solve.
- Keep the expiry short — five minutes is generous for a form. The shorter the window, the smaller the replay set and the marker table.
Illustrative comparison of storage behaviour, not a benchmark of a specific deployment.
Implementation Details That Decide Whether This Is Actually Secure
The scheme is simple enough that the bugs are all in the details. These are the ones worth reviewing line by line.
- Compare in constant time. A byte-by-byte early-exit comparison leaks the signature through timing. Use
timingSafeEqual, and check that both buffers are the same length first. - Never place the plaintext answer in the payload. Base64 is not encryption; assume the client reads everything.
- Sign the encoded body, verify the encoded body. Re-serialising the parsed object before verifying is how key ordering silently breaks the signature.
- Bind the expiry inside the signed region. An expiry passed alongside the token is an expiry the client can edit.
- Rotate the key with a key id. Include
kidin the payload so you can roll the secret without invalidating every in-flight form. - Normalise input once, consistently. Trim and upper-case on both sides, or a user who typed the right code gets rejected for typing it in lower case.
Where This Fits in the Wider Defence
A challenge is a cost multiplier, not a wall. It belongs in a stack alongside the controls described in our write-up on guardrails against jailbreaks and prompt injection.
Before you call an auth endpoint protected
- Signed stateless challenge on sign-up and password reset
- Per-IP and per-account rate limits with separate budgets
- Constant-time comparison on every secret-bearing check
- Short token expiry plus single-use nonce marking
- Key rotation with a kid claim, tested in staging
- Structured logs on every failure reason, alerting on rate spikes
- Generic error copy so failures do not enumerate accounts
The result is an auth surface that stays fast and flat under bot traffic: the attacker burns their bandwidth, your database never learns they were there.