Your CAPTCHA secret is a flag your desktop client can't see
Setting a Turnstile or reCAPTCHA secret made our signup API require a token our desktop client never sends. How to find the same 403 in your stack first.
Cloudflare Turnstile was already built into our signup endpoint, switched off. Turning it on took one environment variable, TURNSTILE_SECRET, and no code. I liked that. It was also the bug. Setting that variable would have returned a 403 on every signup from our desktop app, because the desktop app has never shown a Turnstile widget and has never sent a token.
Nobody got locked out, because I never set it. The only thing that stopped me was a comment I had written myself. The code did nothing to stop me.
Turnstile was about to be the next control
Between September 1 and September 3, our signup endpoint took 71 signups. It normally gets 1 to 5 a day. Someone was using it as an email relay: they submitted addresses so we would email strangers, and about 138 unsolicited emails went out. Our rate limiter was keyed on X-Forwarded-For and didn’t check for a trusted proxy, so the attacker could pick a fresh bucket with every request. I fixed that and soft-deleted 69 accounts, which took us from 108 users to 38. I decided to skip Turnstile for now, and I wrote a warning next to the secret explaining why.
On September 9 I wrote down when Turnstile would become worth turning on: if junk signups came from many distinct IPs, the rate limits weren’t working.
Put those two notes side by side and the problem is plain. The trigger for flipping the switch measured the attackers. Nothing in the trigger, and nothing in the code, measured whether our own clients could comply. I also can’t tell you what share of those 38 accounts signed up from the desktop app, because we never recorded which client a signup came from. That missing number is the whole bug.
TURNSTILE_SECRET meant both “can verify” and “must verify”
The guard is PHP, in the web backend. turnstileEnabled() returns true when TURNSTILE_SECRET is non-empty. Once it’s true, verifyTurnstileToken() returns false for an empty token before it ever calls siteverify. The request fails with a 403 and the text Verification failed. Please refresh the page and try again.
I wrote the docblock above it. It says the check is “inert until TURNSTILE_SECRET is set.” It also says that turning it on before shipping a desktop build that sends a token “would break signup for every installed copy in the field, which is why it is a flag and not a default.”
I treated that sentence as the safety mechanism. It isn’t one. It’s a warning taped to a loaded switch. The code reads one input, “is a secret configured,” and gets two meanings out of it: “I can verify tokens” and “tokens are required.” Only the first was true for the clients we had actually shipped. The rollout plan said to ship a desktop build with the widget before setting the secret. That step never happened, and nothing on the server knows the step exists.
The desktop form sends no token, and its error handler blames the wrong thing
The desktop app doesn’t load the web signup page. It posts from its own native signup form, which sends email, password, first and last name, terms_accepted, a form_rendered_at timestamp and a honeypot field. There’s no token field.
Its 403 handler was written for login. It tags the response code: 'deactivated'. A person who doesn’t have an account yet would see an error meant for a banned user, next to advice to refresh a page that doesn’t exist. The same form already turns any non-200 from its API-token call into a generic “Could not mint API token.” The client hid the real reason twice.
You can check this half on its own. Find where your non-web client maps HTTP status codes to messages:
grep -rnE '\b40[13]\b' --exclude-dir=node_modules <client-src> | grep -iE 'deactivat|banned|suspend|disabled|locked'
If a status code from signup and a status code from login end up in the same account-state message, you’re in the same spot. The day your server starts rejecting a client, its users will be told something false.
Cloudflare’s mobile implementation guide says native apps need a WebView to produce a token. That covers building the new client. It says nothing about the day the server starts requiring tokens from builds that shipped before the WebView existed.
A configured secret doesn’t mean every client can send a token
Here’s the general pattern. The server starts enforcing a rule as soon as some config value exists, and the clients that have to comply release on a different schedule. You get it with reCAPTCHA or hCaptcha on an API shared by a web app and an Electron or mobile build. You get it with hosted auth that has a captcha toggle (one desktop developer hit exactly that wall). You get it with request signing on an API gateway that turns on when a key gets mounted.
Code that rejects a request for a missing token must not use the same condition that tells the server it can verify one.
Check 1: count production signups that arrive with no token
Do this before touching staging, while the secret is still unset. It catches builds you’ll never have a request body for. For each client and version, count how many signups arrived without a token:
SELECT client, client_version,
COUNT(*) AS signups,
SUM(CASE WHEN had_captcha_token THEN 0 ELSE 1 END) AS no_token
FROM signup_attempts
WHERE created_at >= :since
GROUP BY client, client_version
ORDER BY no_token DESC;
If a client you intend to keep has any nonzero no_token row, don’t set the secret. Bots will show up as nonzero too, which is why you group by client. If you can’t group by client at all, you’re where we were. Use the User-Agent your signup endpoint logs, and add a had_captcha_token boolean and a client version to the signup log first. Log whether a token was present, never the token itself. That log line is also step one of the fix below.
Check 2: find the empty-token short-circuit
The shape you’re looking for is two lines, often in two functions:
enabled = SECRET is not empty # config presence
if enabled and token is empty: return false # requiredness derived from it
First, find every place that tests whether the secret is present:
grep -rnE '(TURNSTILE|RECAPTCHA|HCAPTCHA)_SECRET' --exclude-dir=node_modules --exclude-dir=vendor . \
| grep -iE 'empty|isset|!!|Boolean\(|len\(|\?\?|\|\|'
Then read the lines just before each verify call:
grep -rniE -B10 'siteverify' --exclude-dir=node_modules --exclude-dir=vendor .
It fails if an early return false, a 403, or a raised error on an empty token sits in a branch that only runs when the first grep’s condition is true. That’s our bug. It passes if an empty token is logged or scored and the only thing that rejects is a separate setting you have to flip on purpose.
Check 3: replay each client’s real body against a test secret
Collect one real signup body from each client: web, desktop, CLI, and the oldest build you still support. Server logs or mitmproxy will give you these. Set the provider’s test secret in the staging deploy’s environment and redeploy, or run the API locally with the variable set and point the loop at localhost. Exporting it in your own shell does nothing to a remote server.
Test secrets only pass test tokens, so a real production token in web.json will fail too. Swap it first:
| Provider | Test secret | Token to put in the web body |
|---|---|---|
| Turnstile | 1x0000000000000000000000000000000AA | XXXX.DUMMY.TOKEN.XXXX |
| reCAPTCHA v2 | 6LeIxAcTAAAAAGG-vFI1TnRWxMZNFuojJ4WifJWe | any non-empty string; every verification passes with the test keys |
| hCaptcha | 0x0000000000000000000000000000000000000000 | 10000000-aaaa-bbbb-cccc-000000000001 |
# In the staging deploy's env (or a local API): TURNSTILE_SECRET=1x0000000000000000000000000000000AA
API=http://localhost:8080 # or your staging host, once the secret is set THERE
# Use whatever field name your web client actually sends
jq '.turnstile_token = "XXXX.DUMMY.TOKEN.XXXX"' web.json > web.test.json
for body in web.test.json desktop.json cli.json oldest-build.json; do
printf '%-18s ' "$body"
jq --arg e "replay+$RANDOM@example.com" '.email = $e' "$body" \
| curl -s -o /dev/null -w '%{http_code}\n' -X POST "$API/api/auth/register" \
-H 'Content-Type: application/json' -d @-
done
A fresh email on each run keeps duplicate-account errors out of the results. If your form has a timing check like our form_rendered_at, refresh that field the same way.
It passes if every body gets the same 2xx it got with the secret unset. It fails if you see web.test.json 201 and desktop.json 403. Both requests ran against the same test secret and the web one passed, so the 403 came from your own code rejecting a missing field. Then do what the error-mapping check above says: look at what the failing client shows the user.
The fix: verify, require, and enforce are three separate settings
Ours isn’t split yet, so the variable stays unset. This is the shape it needs:
// CAPTCHA_MODE: off | monitor | enforce. The secret alone only means "can verify".
function captchaAllows(array $req): bool {
$mode = getenv('CAPTCHA_MODE') ?: 'off';
$canVerify = (string) getenv('TURNSTILE_SECRET') !== '';
if ($mode === 'enforce' && !$canVerify) throw new RuntimeException('enforce without a secret');
$token = $req['captcha_token'] ?? '';
$ok = $canVerify && $token !== '' && verifyWithProvider($token);
logSignupSignal($req['client'] ?? 'unknown', $req['client_version'] ?? '', $token !== '', $ok);
if ($mode !== 'enforce') return true; // off and monitor never reject
if (version_compare($req['client_version'] ?? '0', MIN_TOKEN_CLIENT_VERSION, '<')) return true;
return $ok;
}
With this, setting the secret turns on verification and logging and nothing else. Monitor mode fills in the query from Check 1. Enforcement is a separate decision, made after that query shows zero for every client you keep. A bot can lie about its version, so the old-build exemption is a migration window with an end date, and the rate limits have to cover it. When the desktop widget ships, its sitekey’s hostname list also has to include wherever that form is served from. Ours is a local server on 127.0.0.1, and a sitekey set up only for the web domain would reject it.