Home>Blog>Security
Security

Stateless CAPTCHA Tokens: Eliminating Brute Force Script Attacks Without Session Bloat

AR
Alex Rivera
Published on April 29, 20269 min read
Share this article:

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.

Stateless HMAC-SHA256 CAPTCHA verification workflow between browser and server without database session store

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.
Figure 1 — Two cost models

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
The security is comparable. What differs is who decides how much memory the server spends.

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.

Figure 2 — Issue, travel, verify
  1. 1Render the challengeA randomised alphanumeric code is drawn as an SVG with light distortion and noise strokes.
  2. 2Sign the payloadA salted hash of the answer plus an expiry and a nonce are signed with HMAC-SHA256 using the server key.
  3. 3Ship both to the clientThe SVG renders in the form; the opaque token rides in a hidden field. Nothing hits the database.
  4. 4Verify on submitRecompute the signature, check the expiry, compare the answer hash in constant time. Constant work, no lookup.
Nothing is written between step 1 and step 3. The token is the session.
// 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.
Figure 3 — Server writes under a one-million-request flood
Stateful sessions1,000,000 rows
Signed tokens0 rows

Illustrative comparison of storage behaviour, not a benchmark of a specific deployment.

Worked example: an attacker requesting a million challenges and solving none. The stateless design turns their volume into their problem, not yours.

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.

  1. 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.
  2. Never place the plaintext answer in the payload. Base64 is not encryption; assume the client reads everything.
  3. Sign the encoded body, verify the encoded body. Re-serialising the parsed object before verifying is how key ordering silently breaks the signature.
  4. Bind the expiry inside the signed region. An expiry passed alongside the token is an expiry the client can edit.
  5. Rotate the key with a key id. Include kid in the payload so you can roll the secret without invalidating every in-flight form.
  6. 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.

Figure 4 — Endpoint hardening checklist

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
Each layer is cheap on its own; the combination is what makes automated abuse uneconomic.

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.

References & Sources

  1. RFC 7519: JSON Web Token (JWT) Specification Standards
  2. NIST HMAC-SHA256 Cryptographic Verification Guidelines
  3. OWASP Authentication Cheat Sheet
  4. RFC 2104: HMAC — Keyed-Hashing for Message Authentication

Frequently Asked Questions

If nothing is stored server-side, what stops someone reusing one solved CAPTCHA forever?

Two things: a short expiry baked into the signed payload, and a single-use marker recorded only after a successful solve. The marker is a tiny key with a TTL equal to the remaining validity, so the storage cost is bounded by successful solves rather than by issued challenges — which is exactly the asymmetry an attacker cannot exploit.

Is a signed token weaker than a server-side session?

No. The security property comes from the HMAC, not from where the answer lives. What changes is the denial-of-service surface: an attacker can force you to store a million session rows, but cannot force you to store a single byte when the challenge is self-contained.

Can the client read the expected answer out of the token?

Only if you put it there in plaintext. Store a salted hash of the answer in the payload, not the answer itself. Verification compares hashes, so the token reveals nothing useful even when it is fully decoded.

Why an SVG challenge rather than an image?

SVG renders crisply at any density, weighs a fraction of a PNG, and is generated without an image processing library in the request path. It also inherits the page's theme, so the challenge does not look like a bolted-on third-party widget.

Does this replace rate limiting?

It complements it. Rate limiting bounds how fast an attacker can try; a proof-of-work-style challenge raises the cost per attempt. Neither one alone is enough on an endpoint worth attacking.

Ready to deploy secure, custom AI agents?

Train your ChatGPT experts in seconds on manual links, files, and PDFs. Get started for free.

Build Your Chatbot Free