Your Cloudflare Pages secret is set. wrangler pages secret list shows it. Production cannot see it.
I lost most of a day to this on a paid download route, so here is the mechanism, the one-line reason, and a check you can run that answers it exactly instead of guessing.
The symptom
Two delivery routes on a Pages project started returning 403 to everyone. The routes read a secret, the secret was missing, so they failed closed, which is correct behaviour and also completely invisible from outside.
Every obvious thing looked right:
$ npx wrangler pages secret list --project-name my-project
The "production" environment of your Pages project "my-project" has access to the following secrets:
- DELIVERY_KEYS_VAULT49: Value Encrypted
- DELIVERY_KEYS_PERMIT39: Value Encrypted
There they are. The code reads env.DELIVERY_KEYS_VAULT49. The names match. The route 403s.
The reason, from Cloudflare's own docs
"When setting secrets with Wrangler or in the Cloudflare dashboard, it needs to be done before a deployment that uses those secrets."
That one sentence is the whole bug. Pages binds environment variables and secrets at deploy time, into that deployment. They are not looked up per-request from some live project-level store. So wrangler pages secret put does not change what the currently-serving build can see. It changes what the next build will see.
Set a secret and walk away, and it sits in the project doing nothing at all until something deploys. The secret list is telling you the truth about the project. It is telling you nothing about production.
This is different from Workers, which is where the intuition comes from. On a Worker, wrangler secret put applies to the running script. The same muscle memory on Pages gets you a secret that exists, reads correctly in every tool you check, and is invisible to the thing serving traffic.
Why it is so hard to see
Every signal points the wrong way.
- The secret list shows it. Correct, and irrelevant.
- The dashboard shows it. Same.
- The code is right, so a code review finds nothing.
- The route fails closed, so you get a 403 and not a stack trace. A good failure mode hides this one.
- Redeploying for an unrelated reason fixes it silently, so it looks intermittent, which sends you hunting for a caching problem that does not exist.
Ours only surfaced because someone asked why a paid link had never worked.
The fix
npx wrangler pages deploy . --project-name my-project
That is it. Deploy anything, even a no-op, and the secret binds. Order matters: secret put first, then deploy. Both orders "work" in the sense that neither errors, and only one of them serves.
The part worth keeping: how to detect it
"Redeploy after setting a secret" is a rule you will follow for two weeks and then forget at 11pm. I wanted a check instead, and my first instinct was to compare timestamps: when was this secret last changed, versus when did we last deploy?
Cloudflare does not give you that. There is no modification time on a secret. Not in wrangler pages secret list, not on the project's deployment_configs (every entry is literally {"type": "secret_text", "value": ""}), nowhere I could find.
But you do not need a timestamp, because every deployment record carries its own env_vars snapshot — the exact set of names that were bound when that build ran:
curl -s -H "Authorization: Bearer $CF_TOKEN" \
"https://api.cloudflare.com/client/v4/accounts/$ACCOUNT/pages/projects/$PROJECT/deployments?per_page=25" \
| jq '.result[] | {id: .short_id, on: .created_on, vars: (.env_vars // {} | keys)}'
Which makes the question a set difference, with no clock involved:
set-but-not-bound = (project's current secrets) − (last successful deploy's secrets)
That is strictly better than the timestamp comparison I originally wanted. An mtime would tell me when a secret changed. This tells me whether production can actually see it, which is the thing that 403s a user.
Walking my own history, the snapshots grow 3 → 4 → 5 → 6 → 8, and each step is a secret becoming visible. The two that broke the routes first appear in a specific deploy — which is exactly when those routes started working. The outage window is sitting there in the data, if you know to look.
Two traps if you build this into a gate
Skipped and failed builds carry an empty env_vars. Pick your baseline by recency alone and you will land on one of those, and then every secret in the account reports as stale. A gate that goes red on a perfectly healthy project gets deleted within a week. Filter to: production environment, not skipped, status success, and a non-empty snapshot.
Run it after the deploy, not before. As a pre-deploy check this is red for every legitimate secret put → deploy, which is the correct workflow and the one you are trying to protect. Run it after, and a red means the binding genuinely did not happen. I got this backwards first and the check immediately failed on the exact thing it was written to support.
I write this kind of thing up at kit.sdvsignal.com, and there is a free MIT starter repo at kit-claude-code-starter if you want a working .claude/ to copy rather than assemble. The hooks guide is the same flavour of "this looks like it works and does not".
Has anyone found a way to get a real modification time out of a Pages secret? I could not, and the snapshot trick is a workaround for something I would rather just ask the API directly.
Top comments (0)