
Using Cloudflare Turnstile for Multiple Requests Without Re-Challenging Users
Cloudflare Turnstile tokens are single-use. Learn how to verify once, mint a short-lived human token, and make multiple protected requests without re-challenging users.
Tristan Chin
The gotcha: Turnstile tokens are single-use
If you've ever worked with Cloudflare Turnstile, you probably ran into the following problem: a challenge is issued and succeeds, then the user clicks on a button, which triggers more than one backend requests that should be human verified. The first request passes, but the next one fails, because the Turnstile token was already spent. Some quick solutions you've probably thought of are:
- Re-challenge the user for each request: this significantly slows down your processing time and has a slight chance of failing one challenge out of all of them, breaking the process mid-way.
- Refactor your endpoints into one: this increases your backend logic and complexity to satisfy this one constraint.
This blog post will show you how we can leverage short-lived JWTs to challenge once, preserve UX and verify multiple requests safely.
The fix: verify once, then mint a short-lived "human token"
While this post uses React + Express or NestJS as example, the thought process of achieving this can be applied anywhere. This post just shows you a way of integrating the thought process in a way that you can forget about Turnstile and just call your endpoints as if they weren't protected. The process, at high-level, goes like this:
- Run the Turnstile challenge to get a Turnstile token.
- Send that Turnstile token to a backend endpoint that will verify it and send back a very short-lived JWT (5 minutes or less), which we'll call the "human token".
- For every protected requests, use the human token as protection mechanism instead of the Turnstile token.
I'm not sure how other captcha systems work, but if they're also single-use tokens, then this process could potentially apply to them too.
With that process in mind, you could just leave this post now and go implement it in whatever technology you use.
The rest of this blog post will show a concrete example of how you can use this with a React/NextJS frontend (using Axios as HTTP client) with an Express/NestJS backend. We'll start with the frontend and work ourselves to the backend.
Frontend: a "HumanGate" that runs challenges only when needed
We want to set this process up in a "plug and forget" way so that when new protected endpoints appear, we don't even need to think about how to run challenges for them.
Let's create an API that will handle challenge verifications and let promises through after the human token was created. We'll call this API the HumanGate. The HumanGate is in charge of running the actual challenge verification and getting a human token before letting a request promise through. I'm using @marsidev/react-turnstile here to render challenges.
npm i @marsidev/react-turnstile"use client";
import { type TurnstileInstance, Turnstile } from "@marsidev/react-turnstile";
import axios from "axios";
import React from "react";
import { createPortal } from "react-dom";
import { createRoot } from "react-dom/client";
type Status = "idle" | "verifying" | "verified";
export class HumanGate {
static instance: HumanGate;
private status: Status = "idle";
private pending?: Promise<void>;
private portalResolvers = (() => {
// Requires ES2024
if (Promise.withResolvers && typeof Promise.withResolvers === "function") {
return Promise.withResolvers<void>();
}
let resolve: () => void = () => {};
let reject: (reason?: any) => void = () => {};
const promise = new Promise<void>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
})();
private invisibleTurnstile: React.RefObject<TurnstileInstance | null>;
private constructor() {
this.invisibleTurnstile = React.createRef<TurnstileInstance>();
}
private get turnstile() {
return this.invisibleTurnstile;
}
private mountInvisibleTurnstile = () => {
const mount = document.createElement("div");
mount.id = "invisible-turnstile-challenge-portal";
document.body.appendChild(mount);
const Portal: React.FC = () => {
return createPortal(
<Turnstile
ref={this.invisibleTurnstile}
id="turnstile-invisible-challenge"
siteKey={process.env.NEXT_PUBLIC_TURNSTILE_INVISIBLE_SITEKEY!}
options={{ size: "invisible", retry: "never" }}
onSuccess={() => {
this.portalResolvers.resolve();
}}
onError={(err) => {
// Instead of rejecting here, you could try to mount a visible challenge instead that will resolve the promise on success.
// Though this won't be covered here for simplicity.
this.portalResolvers.reject(new Error(`Turnstile error: ${err}`));
}}
onUnsupported={() => {
console.error("Turnstile unsupported");
this.portalResolvers.reject(new Error("Turnstile unsupported"));
}}
/>,
mount,
);
};
const r = createRoot(mount);
r.render(<Portal />);
};
ensureVerified = async () => {
if (this.status === "verified") return;
if (this.status === "verifying" && this.pending) return this.pending;
this.mountInvisibleTurnstile();
this.status = "verifying";
this.pending = this.runVerification().finally(() => {
this.pending = undefined;
});
return this.pending;
};
reverify = async () => {
this.status = "idle";
return this.ensureVerified();
};
private runVerification = async () => {
await this.portalResolvers.promise;
try {
const token = await new Promise<string>((resolve, reject) => {
const t = setTimeout(() => reject(new Error("HumanGate timeout")), 10_000);
this.turnstile
.current!.getResponsePromise()
.then((value) => {
clearTimeout(t);
resolve(value);
})
.catch((err) => {
clearTimeout(t);
reject(err);
});
});
await axios.post(
`/api/turnstile/create-human-token`,
{
cfToken: token,
},
{
withCredentials: true,
},
);
} catch (e) {
this.status = "idle";
console.error("Human verification failed:", e);
throw new Error("Human verification failed");
}
this.status = "verified";
};
static init() {
if (!HumanGate.instance) HumanGate.instance = new HumanGate();
return HumanGate.instance;
}
}
The HumanGate exposes the ensureVerified method that can be called anywhere in code to generate a human token. It also guarantees a single in-flight challenge. When a challenge is already in progress, other calls to ensureVerified wait for it to complete before continuing with one human token.
This API is the bare minimum you'll probably need to get this working, though you may want to add your own touch for your use-case. For example, in practice, I mount a visible challenge when the invisible challenge fails. I've excluded it from this example for brevity, since this is already a lot to take in.
Frontend: The HumanGate Axios client instance
We'll create an Axios client instance that will call the ensureVerified method whenever one of our listed protected endpoints are called. The following function creates that client that accepts an isProtected function to define whether the HTTP call should be "Human Gated". It also leverages Axios interceptors to handle reverification whenever the human token expires, so that you don't have to worry about that either!
import axios, { AxiosInstance, isAxiosError, type CreateAxiosDefaults, type InternalAxiosRequestConfig } from "axios";
import { HumanGate } from "./human-gate";
const defaultIsProtected = (_config: InternalAxiosRequestConfig) => {
return true;
};
export function createHumanClient({
isProtected = defaultIsProtected,
...axiosConfig
}: CreateAxiosDefaults & {
isProtected?: (config: InternalAxiosRequestConfig) => boolean;
}): AxiosInstance {
const client = axios.create({ withCredentials: true, ...axiosConfig });
if (typeof window === "undefined") {
// Can't have Turnstile on the server
return client;
}
const gate = HumanGate.init();
// Request interceptor: queue until verified
client.interceptors.request.use(async (config) => {
if (isProtected(config)) {
await gate.ensureVerified();
}
return config;
});
// Response interceptor: on "human required", re-verify once and retry
client.interceptors.response.use(
(res) => res,
async (error) => {
if (!isAxiosError(error)) throw error;
const cfg = error.config as InternalAxiosRequestConfig & { __humanRetry?: boolean };
const status = error.response?.status;
const code = error.response?.data?.statusText;
const looksLikeHumanFail = status === 401 || status === 403 || code === "human-required";
if (looksLikeHumanFail && !cfg.__humanRetry && isProtected(cfg)) {
cfg.__humanRetry = true;
await gate.reverify();
return client(cfg); // retry once
}
throw error;
},
);
// You could use another interceptor here for your needs, such as dispatching an event with window.dispatchEvent
return client;
}
Now, all that's left is to create the Axios instance. Just make sure that you use this instance when making HTTP calls instead of the default axios instance.
export const API_BASE_URL = new URL(
"/api",
(() => {
if (process.env.NODE_ENV === "production") {
return new URL(`https://api.example.com`);
}
return new URL(`http://localhost:3000`);
})(),
);
const PROTECTED_ENDPOINTS = ["/coupons/apply-code", "/checkout/create-session"];
export const apiClient = createHumanClient({
baseURL: API_BASE_URL.href,
withCredentials: true,
isProtected: (config) => {
// Will be true whenever we request /api/coupons/apply-code or /api/checkout/create-session
return PROTECTED_ENDPOINTS.some((path) => config.url?.startsWith(path));
},
});
// create-checkout-session.ts
export const createCheckoutSession = () => {
// BEFORE: return axios.post("https://api.example.com/api/checkout/create-session");
return apiClient.post("/checkout/create-session");
}This example shows how to protect two API endpoints by their path (/coupons/apply-code and /checkout/create-session). Whenever you add another protected endpoint, just add its path in the PROTECTED_ENDPOINTS constant.
Backend: one endpoint that accepts a Turnstile token
Now, it's time to implement the backend part of things. Let's start with the endpoint that accepts a Turnstile token and turns it into a re-usable human token. After the backend implementation, no endpoint in your backend should accept a Turnstile token except this one.
import express from "express";
import jwt from "jsonwebtoken";
import axios from "axios";
import { Request, Response } from "express";
const app = express();
app.post("/api/turnstile/create-human-token", async (req: Request, res: Response) => {
const params = new URLSearchParams();
params.append("secret", process.env.TURNSTILE_INVISIBLE_SECRET_KEY!);
params.append("response", req.body.cfToken);
if (req.ip) {
params.append("remoteip", req.ip);
}
try {
await axios.post("https://challenges.cloudflare.com/turnstile/v0/siteverify", params, {
headers: { "Content-Type": "application/x-www-form-urlencoded" },
});
} catch {
res.status(401).send("Unauthorized");
return;
}
const humanToken = jwt.sign({ userAgent: req.headers["user-agent"], ip: req.ip }, process.env.JWT_SECRET!, {
expiresIn: "5m",
});
res.cookie("human-token", humanToken, {
httpOnly: true,
sameSite: "strict", // If your backend lives on a different domain, use "lax"
secure: process.env.NODE_ENV !== "development",
maxAge: 5 * 60 * 1000, // 5 minutes
path: "/",
});
res.status(200).send();
});Backend: guard/middleware for "human endpoints"
Now it's time to actually secure our endpoints by using the human token. For Express, this can be a simple middleware that checks the request cookies for the human token and verifies it. For NestJS, it's the same thing, but using a guard.
Note the "human-required" error message. This is important to stay the same because the frontend checks for this message explicitly!
import jwt from "jsonwebtoken";
import { NextFunction, Request, Response } from "express";
export const humanMiddleware = (req: Request, res: Response, next: NextFunction) => {
const humanToken = req.cookies["human-token"];
if (!humanToken) {
res.status(401).send("human-required");
return;
}
const payload = {
userAgent: req.headers["user-agent"],
ip: req.ip,
};
try {
const decodedPayload = jwt.verify(humanToken, process.env.JWT_SECRET!);
if (typeof decodedPayload !== "object" || decodedPayload === null) {
res.status(401).send("human-required");
return;
}
if (
(decodedPayload.userAgent && payload.userAgent !== decodedPayload.userAgent) ||
(decodedPayload.ip && payload.ip !== decodedPayload.ip)
) {
res.status(401).send("human-required");
return;
}
} catch (err) {
res.status(401).send("human-required");
return;
}
next();
};All that's left is to add this middleware/guard to your protected endpoints!
import { Request, Response } from "express";
import { humanMiddleware } from "src/middleware/human-middleware.ts";
app.post("/checkout/create-session", humanMiddleware, (req: Request, res: Response) => {
// From here, you can trust that the user is human
});Trade-offs and hard rules
While this whole setup let's you easily challenge once and make multiple verified requests, it's important to keep a few things in mind.
Understand the role of human tokens
Turnstile tokens will guarantee that for each request, a human challenge was made. With human tokens, you only have the guarantee that the token was created from a valid challenge. Requests made with a valid human token only means this challenge succeeded up to 5 minutes prior to the request. Which brings me to the next rule...
Ensure human tokens are (very) short-lived
In this example, we're using 5 minutes as lifetime for human tokens. While this is short, you could probably go even shorter if your use-case allows it. In my use-case, 5 minutes was the time determined it would take between two protected requests in a normal flow, because they both happen on the same page visit but split between two interactions. If you only make sequential requests one after the other very fast and then never need the token again for this flow, 30 seconds could suffice!
In other words, don't use human tokens as a way to temporarily stop challenging your users. Use human tokens to ensure an uninterrupted flow of operations, not to multiple flows.
During that 5 minute window, you need to keep in mind that it doesn't guarantee requests are made by a human. For example, a malicious actor could craft the human token "legally" (by manually going on the website), take the human token and use it in an automated script to act as a human for 5 minutes. Sure, our user-agent check stops easy attacks, but they can easily be spoofed. This again brings me to my next recommendation...
Don't sleep on rate limiting
In general, it's a good idea to rate limit your unauthenticated endpoints to prevent abuse. That being said, remember that human-gated endpoints are still unauthenticated and therefore should still be rate limited. You could even rate limit the create human token endpoint, since it too is an unauthenticated endpoint.

