# Verify the token before you touch the counter

31 August 2026 · Lessons

-   Cloudflare
-   Turnstile
-   Rate limiting
-   Pages Functions

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:

```ts
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:

```ts
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.

```ts
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:

```ts
// 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.

---

Source: https://silverpepperlabs.com/blog/turnstile-before-the-counter/
Silver Pepper Labs — https://silverpepperlabs.com
