Fix TURNSTILE_SECRET_KEY is not configured (503) on Cloudflare Workers
I shipped a contact form that worked perfectly in npm run dev, passed a local submit test, and then returned 503 the first time I tried it on production:
{ "error": "Contact form is not configured. Set Turnstile keys on the server." }
That message is deliberate. On mayfield.io the API route refuses to accept mail in production unless Turnstile server verification is wired. The bug is almost never the form markup—it is env parity between your laptop and the Worker.
Not this post: If Search Console or your UI shows turnstile verification failed, Verification failed. Please try again., or failed to verify cloudflare turnstile token, the secret is usually present but verification failed (403). Use Turnstile verification failed: fix in production instead.
Related query language you might see in logs or Search Console:
turnstile token missingturnstile_secret_key is not configured.
What triggers the 503
In src/pages/api/contact.ts, production checks whether Turnstile is configured before it verifies a token or calls Resend:
if (isTurnstileConfigured(locals)) {
// verify token, then send mail
} else if (!import.meta.env.DEV) {
return Response.json(
{ error: 'Contact form is not configured. Set Turnstile keys on the server.' },
{ status: 503 },
);
}
isTurnstileConfigured is a single boolean: does readEnv('TURNSTILE_SECRET_KEY') return a non-empty string?
That is the entire gate. The public site key does not count here. You can have PUBLIC_TURNSTILE_SITE_KEY set, render the widget on /contact, and still hit 503 because the secret never reached the Worker runtime.
Local dev skips this branch (import.meta.env.DEV), which is why the mismatch feels sudden after deploy.
Fast diagnosis checklist
1) Confirm the response is 503, not 400 or 403
| Status | Meaning |
|---|---|
| 503 + Turnstile not configured | TURNSTILE_SECRET_KEY missing at request time |
| 400 + complete the verification challenge | Route works; token not sent |
| 403 + Verification failed | Secret present but token/domain mismatch — fix Turnstile verification failed in production |
| 502 | Turnstile passed; Resend failed |
If you are on 503, stop debugging widget JavaScript and fix secrets.
2) Check Cloudflare Worker secrets, not just Astro .env
Production values live in Workers → your service → Settings → Variables and Secrets.
You need at minimum:
TURNSTILE_SECRET_KEY— secret (server verify)PUBLIC_TURNSTILE_SITE_KEY— public var (widget on/contact)
A secret in .dev.vars does not exist in production until you create it there too:
wrangler secret put TURNSTILE_SECRET_KEY
Set the public site key as a plain text variable in the dashboard (or in wrangler.json if you prefer non-secrets in config).
3) Verify readEnv can see the Worker binding
If you added the secret in the dashboard but the Worker was never redeployed, or you typoed the name (TURNSTILE_SECRET vs TURNSTILE_SECRET_KEY), readEnv returns undefined and you get 503. See Astro secrets on Cloudflare Workers for how the helper works.
Match names exactly to astro.config.mjs env.schema and .dev.vars.example.
4) Production-safe curl probe
This should not return 503 once the secret is bound (it will fail later on token validation—that is fine):
curl -i -X POST "https://mayfield.io/api/contact" \
-H "Content-Type: application/json" \
-d '{"name":"Probe","email":"you@example.com","message":"Turnstile config probe from curl"}'
- Still 503 → secret not visible to the route; fix bindings and redeploy.
- 400 asking for verification → Turnstile is configured; move on to widget/token debugging.
Use a real browser submit for the happy path. Do not paste live Turnstile tokens into tickets.
Common mistakes
Site key only. The widget needs PUBLIC_TURNSTILE_SITE_KEY; the API needs TURNSTILE_SECRET_KEY. Setting only the public key makes the page look healthy while the server correctly returns 503.
Tested locally, assumed production. .dev.vars and .env are not deployed. Treat dashboard secrets as part of the ship checklist.
Stale deploy. After wrangler secret put, run a fresh deploy. I have burned time editing code when the Worker had not picked up a new binding.
Fix flow that actually sticks
- Open Cloudflare dashboard → Workers → your Astro service → Variables and Secrets.
- Add
TURNSTILE_SECRET_KEYas a secret with the value from the Turnstile widget settings. - Confirm
PUBLIC_TURNSTILE_SITE_KEYexists as a public variable. - Redeploy the Worker.
- Re-run the curl probe—expect 400, not 503.
- Submit once from
/contactin a normal browser session and confirm 200 + inbox delivery.
Once the secret is readable, the same route handles verification and hands off to Resend. If mail still fails after 503 is gone, you are in a different error class (502 or Turnstile verification failed / 403), not configuration.
For how secrets reach readEnv in the first place, see Astro secrets on Cloudflare Workers. For the local vs production file split, Local .dev.vars vs production secrets is the checklist I copy into every new project.
Hero photo: Cat-and-computer-monitor-3388 by Vmenkov, licensed CC BY-SA 3.0.