A cat resting on top of a CRT computer monitor in a bookshop

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 missing
  • turnstile_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

StatusMeaning
503 + Turnstile not configuredTURNSTILE_SECRET_KEY missing at request time
400 + complete the verification challengeRoute works; token not sent
403 + Verification failedSecret present but token/domain mismatch — fix Turnstile verification failed in production
502Turnstile 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_KEYsecret (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

  1. Open Cloudflare dashboard → Workers → your Astro service → Variables and Secrets.
  2. Add TURNSTILE_SECRET_KEY as a secret with the value from the Turnstile widget settings.
  3. Confirm PUBLIC_TURNSTILE_SITE_KEY exists as a public variable.
  4. Redeploy the Worker.
  5. Re-run the curl probe—expect 400, not 503.
  6. Submit once from /contact in 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.