Verify the token before you touch the counter
If your rate limiter keys on user-supplied input — an email address, a username — it must run after you verify the CAPTCHA token, not before. Otherwise anyone can spend a stranger’s quota with a junk token and lock that address out for the whole window, without ever solving a challenge. We shipped this bug, found it in an audit, and fixed it by moving four lines.
The short version
Both of our Cloudflare Pages Functions checked a Cloudflare KV rate-limit counter before calling Turnstile’s siteverify. The request body was validated with Zod, but the token field was only shape-checked:
turnstileToken: z.string().min(1, { message: 'Anti-spam check missing — reload and try again.' }),
z.string().min(1) is satisfied by "x". So a request carrying turnstileToken: "x" parses cleanly, reaches the counters, increments them, and only then fails verification and returns 403. Three such requests against the same email exhausted that address’s hourly quota. The fourth genuine attempt from the real owner got a 429.
Nothing was bypassed — Turnstile still gated every side effect, so no mail was sent and no subscriber was created. This is denial of service, not a spam hole. But denial of service aimed at someone else’s address, from anywhere, at three requests a pop.
The second half of the bug
The two counters ran concurrently:
const [ipHash, emailHash] = await Promise.all([sha256Hex(ip), sha256Hex(email.toLowerCase())]);
const limited = await Promise.all([
isRateLimited(env.RATE_LIMIT, `contact:ip:${ipHash}`, { limit: 5, windowSeconds: 3600 }),
isRateLimited(env.RATE_LIMIT, `contact:email:${emailHash}`, { limit: 3, windowSeconds: 3600 }),
]);
if (limited.some(Boolean)) { /* 429 */ }
Promise.all means both branches always execute. The IP counter returning true doesn’t stop the email counter from being read and written — and because the limiter returns early without a put once it’s over the limit, the IP counter stops extending its own TTL while every subsequent request still writes a new email counter.
The practical effect: one IP, already past its own limit, could keep locking out new addresses indefinitely at three requests each. The IP cap that looks like it bounds the damage doesn’t.
The fix
Verify first. Then check the cheap counter. Then the expensive one, sequentially, so a request that’s already over one limit never touches the next.
const ip = request.headers.get('CF-Connecting-IP') ?? '0.0.0.0';
// Turnstile before the counters, deliberately. The token is only shape-checked
// by Zod, so if the limiter ran first anyone could spend a stranger's email
// quota with a junk token and lock that address out for the window.
if (!(await verifyTurnstile(env.TURNSTILE_SECRET_KEY, parsed.data.turnstileToken, ip))) {
return respond(request, 403, false, "Anti-spam check didn't pass — reload the page and try again.", BACK);
}
// Sequential, not Promise.all: a request already over the IP limit must not
// go on to increment the email counter.
const ipHash = await sha256Hex(ip);
if (await isRateLimited(env.RATE_LIMIT, `contact:ip:${ipHash}`, { limit: 5, windowSeconds: 3600 })) {
return respond(request, 429, false, 'Too many messages — please try again in an hour.', BACK);
}
const emailHash = await sha256Hex(email.toLowerCase());
if (await isRateLimited(env.RATE_LIMIT, `contact:email:${emailHash}`, { limit: 3, windowSeconds: 3600 })) {
return respond(request, 429, false, 'Too many messages — please try again in an hour.', BACK);
}
The cost is one extra serialized KV read on the happy path — single-digit milliseconds against a network round trip to Turnstile that already happened.
Why it’s easy to get backwards
Every instinct says put the cheap check first. A KV read is fast and local; siteverify is an outbound HTTPS request to challenges.cloudflare.com. Ordering the cheap guard first is the reflex you’ve built from a hundred other hot paths, and it’s usually right.
It’s wrong here because the two checks aren’t the same kind of thing. The rate limiter isn’t a filter — it’s a mutation. Every call writes state keyed on data the attacker chose. Letting an unauthenticated request reach it isn’t a performance decision, it’s granting write access to a resource belonging to someone else.
The rule that generalizes: anything that writes state keyed on user input belongs behind authentication, no matter how cheap it is to run. Cost ordering applies to pure predicates. A counter isn’t one.
What we changed besides the order
Two things worth copying:
The invariant is now recorded where the mistake would be made, not in a commit message nobody re-reads:
// Callers MUST verify Turnstile before calling this. The counters key off
// user-supplied input (the email), so letting unsolved requests reach them
// hands anyone a way to lock a stranger's address out for the whole window.
export async function isRateLimited(/* … */)
And the handlers got a top-level try/catch. Every network hop in them was a bare await — the KV get and put, the siteverify fetch, the provider call. A rejected fetch (DNS failure, TLS error, connection reset) escaped as Cloudflare’s generic 500 page instead of the JSON contract every other exit honors. That was always fail-hard rather than fail-open, so it was never a security issue — but a transient provider blip shouldn’t take the form’s error handling with it.
How to check your own
Post your endpoint five times with a deliberately invalid token and a fixed email address:
403, 403, 403, 429, 429— your limiter runs first. That fourth response means an attacker just locked that address out, and you can do it to any address you like.403, 403, 403, 403, 403— verification runs first and the counters were never touched.
It’s a two-minute check and the output is unambiguous.
Verified on 31 August 2026 against Astro 7.2.0 on Cloudflare Pages Functions, with Turnstile and Workers KV.