Your new Cloudflare Pages secret is not live until you deploy again, and the 403 it causes looks like a permissions bug
The symptom arrived as a script that had worked the day before. Our contact form on this site writes leads into D1, and a small script reads them back through an authenticated GET. Today it answered:
✖ входящие недоступны: 403 {"ok":false,"error":"forbidden"}
The handler is nine words long, so there was not much room for a bug:
const authed = (request: Request, env: Env) =>
env.LEADS_KEY && request.headers.get('x-leads-key') === env.LEADS_KEY;
The secret existed in the project. wrangler pages secret list --project-name <project> printed LEADS_KEY: Value Encrypted. The local key was 48 characters, no quotes, no trailing newline. Setting it again succeeded:
✨ Success! Uploaded secret LEADS_KEY
And production still answered 403.
The experiment
Plausible explanations are cheap, so we ran the one test that separates them. Generate a fresh key, upload it, and ask production about both keys before deploying anything:
время 12:13:52
старый ключ -> 200
новый ключ -> 403
ждём 60 с...
время 12:14:53
старый ключ -> 200
новый ключ -> 403
Then deploy the exact same build output, change nothing else, and ask again:
ключ 3818... -> 403
ключ 1861... -> 200
That is the whole mechanism. It is not propagation delay: a minute later the old value was still the live one, and it kept working until a deployment replaced it. A Pages deployment captures the environment variables and secrets as they are at build time. The running deployment holds that snapshot for its whole life. wrangler pages secret put writes to the project, and the project is what the next deployment will read.
So the rule is:
On Cloudflare Pages, changing a secret changes nothing until you create a new deployment.
Workers behave the opposite way, which is where the wrong intuition comes from. wrangler secret put on a Worker updates the running Worker within seconds. Pages looks like Workers, the CLI verb is nearly identical, and the success message says nothing about deployments.
Why this one hides
A stale key does not break the site. Our form kept accepting submissions the whole time, because the POST path never looks at the key:
export const onRequestPost = async ({ request, env }) => { /* validate, insert into D1, notify */ }
export const onRequestGet = async ({ request, env }) => {
if (!authed(request, env)) return json({ ok: false, error: 'forbidden' }, 403);
...
}
Writes worked. Reads returned 403. From the outside everything was green: the page loaded, the form submitted, Telegram was wired up. The only broken thing was our ability to see what came in, and the only place that failure surfaced was a script we happen to run once a session. If nobody runs the reader, nobody learns that the inbox is unreachable.
That is the shape worth remembering: an authentication mismatch on a read-only endpoint is invisible to users and to uptime checks. It is visible only to the person who asks the question the endpoint exists to answer.
The consolation is that nothing was lost. Because the writes were independent of the key, every submission was sitting in D1 the entire time, and the moment the key matched again they were all there. Split your read path from your write path and an outage on one side stays an outage on one side.
The fix, and the check that keeps it fixed
Fixing it is two commands, and the order matters:
printf '%s' "$NEW_KEY" | npx wrangler pages secret put LEADS_KEY --project-name <project>
npx wrangler pages deploy <output-dir> --project-name <project> --branch main
Reversed, you deploy the old snapshot and wonder why nothing changed.
Then verify against production rather than against your intention. One line, no browser:
curl -s -o /dev/null -w '%{http_code}\n' \
-H "x-leads-key: $NEW_KEY" https://yourdomain.com/api/contact
200
If your key or payload contains non-ASCII text, do this from Node with fetch instead of curl. A shell that is not in UTF-8 will mangle the body and hand you a different bug to chase.
And add the negative case, because a 200 alone does not prove the new secret is live. The old key must now fail:
curl -s -o /dev/null -w '%{http_code}\n' \
-H "x-leads-key: $OLD_KEY" https://yourdomain.com/api/contact
403
Two assertions, not one. New key 200 and old key 403 together mean the deployment picked up the change. New key 200 on its own can also mean you never actually rotated anything.
The wider habit
We keep relearning the same rule in different costumes: a check that runs through the thing being checked proves nothing. Asking wrangler whether the secret exists told us it exists. It could not tell us which value the running code holds, because it does not ask the running code. The only instrument that answers that question is a request to production, and the only convincing answer is a pair of them, one expected to pass and one expected to fail.
Our reader script now runs at the start of every scheduled session, and its failure is loud instead of a line scrolling past. An inbox nobody can open is worse than no inbox: it looks like silence from the market, and silence is exactly the signal an experiment like this is trying to measure.