Every app we run has a part only we should see. Perron, our train-station chat app, has an admin dashboard with usage numbers, moderation and config. Our cloud-save service has a support hub, where we look up a player's save when they write in about lost progress. These pages are small, and they get used by one or two people. But they can see a lot, so they need a proper lock.
For all of them, the lock is Cloudflare Access. This post is about how we set it up on Workers, why we still verify Access's token inside the Worker even though Access already checked it, and a related lesson about Turnstile: where it protects something, and where it only gets in the way.
Why not just add a login
The first question is why the admin dashboard isn't simply a screen in the app, behind an admin account. We wrote the answer down as an architecture decision in July, and it still holds.
Perron has no login. Every user is an anonymous session, by design. Adding a root account to the app would mean adding password handling to a binary that currently has none, for the benefit of one person. That's a real step backwards for a privacy-first app.
An admin screen in the app is also a map for attackers. Anyone can unzip an iOS or Android app and read the strings inside. Server-side checks would still hold, but we'd have handed out the endpoint list, the parameter names and the shape of every response for free.
There were two smaller reasons. App Store review treats hidden admin features badly, and losing a release to a rejection over a dashboard nobody else uses would be absurd. And a web page redeploys in seconds, while an app screen waits on review.
So the dashboard is a web page served by the same Worker at /admin, and it never ships in the app.
What Access does
Cloudflare Access is part of Cloudflare's Zero Trust product. You create an Access application for a hostname and a path, and choose who's allowed in and how they prove it: a Google account, GitHub, or a one-time PIN sent to an email address. Access puts a login page in front of that path at Cloudflare's edge. A request only reaches your Worker after the person has signed in and matched your policy.
When it lets a request through, Access adds a signed token to it, a JSON Web Token in the Cf-Access-Jwt-Assertion header. It also sets a CF_Authorization cookie in the browser. The token says who signed in, which Access application it's for, and when it expires. It's signed with keys that belong to your Zero Trust team, and the public half of those keys is published at a URL on your team domain.
The thing we like most about this: there's no secret of ours involved. No password database, no shared API key, no session secret. There's nothing to leak, rotate or lose. Access proves who the person is, and the Worker checks Access's signature with public keys.
Why verify the token at all
Here's the question that comes up whenever we explain this. If Access already checked the person before the request reached the Worker, why does the Worker check the token again?
Because the Worker can sometimes be reached without going through Access.
Access protects a hostname. If your Worker also answers on its *.workers.dev address, and Access is only attached to your custom domain, then /admin on the workers.dev address has no Access in front of it at all. The same goes for any other route to the Worker you forgot about. Access does strip and re-set the Cf-Access-Jwt-Assertion header on every request that passes through it, so a client can't forge one on the protected hostname. But on a path Access never sees, the header is whatever the client sent.
So the Worker verifies the signature on every admin request, as if Access weren't there. If the request came through Access, the check passes. If it didn't, there's no valid token, and the check fails. The admin routes stay safe even if the setup around them is wrong.
And we turned the workers.dev address off in production:
// wrangler.jsonc
"workers_dev": false
The comment next to it in our config says why: the workers.dev hostname can't sit behind Cloudflare Access, so leaving it on means /admin is reachable on a route Access can never protect. We verify the token anyway, but there's no reason to leave the door there.
Verifying an Access token in a Worker
You don't need a JWT library for this. Workers have WebCrypto built in, and an Access token is a standard RS256 JWT. Our verifier is about a hundred lines with comments, and every step is there for a reason.
It starts by splitting the token and decoding the header and payload. Then it checks the algorithm, and this is the line people most often get wrong:
// Reject `none`, and reject HS256: an attacker who knows the public key
// could otherwise sign an HMAC with it and have us "verify" it. Access only
// ever issues RS256.
if (header.alg !== "RS256") return { ok: false, reason: "unsupported_alg" };
Never let the token tell you which algorithm to use. A JWT library that accepts whatever alg the header says has been the cause of real security bugs. If the token says none, a careless verifier skips the check entirely. If it says HS256, a careless verifier uses the public key as an HMAC secret, and anybody can make a token that passes. Access only issues RS256, so we accept only RS256.
Next, the key. Access publishes several public keys, because it rotates them, and each token says which key signed it in its kid field. We find that key, import it with WebCrypto, and check the signature:
const jwk = jwks.find((k) => k.kid === header.kid);
if (!jwk) return { ok: false, reason: "unknown_kid" };
const key = await crypto.subtle.importKey(
"jwk",
{ kty: jwk.kty, n: jwk.n, e: jwk.e, alg: "RS256", ext: true },
{ name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" },
false,
["verify"],
);
const signed = new TextEncoder().encode(`${rawHeader}.${rawPayload}`);
const valid = await crypto.subtle.verify("RSASSA-PKCS1-v1_5", key, b64urlToBytes(rawSignature), signed);
// Signature first: never read claims from a token we have not authenticated.
if (!valid) return { ok: false, reason: "bad_signature" };
The signature is checked before any of the claims, because a claim in an unverified token is just text somebody typed.
Then the claims. The token must not have expired, and if it has a not-before time, that time must have passed. Access's clocks and Cloudflare's Workers clocks aren't the same clock, so we allow 60 seconds either way:
const CLOCK_SKEW_SEC = 60;
const nowSec = Math.floor(now / 1000);
if (typeof payload.exp !== "number" || payload.exp + CLOCK_SKEW_SEC <= nowSec) {
return { ok: false, reason: "expired" };
}
if (typeof payload.nbf === "number" && payload.nbf - CLOCK_SKEW_SEC > nowSec) {
return { ok: false, reason: "not_yet_valid" };
}
A token with no expiry at all is treated as expired. That's deliberate. Every Access token has one, so a token without one isn't an Access token.
The audience comes next. Each Access application has an AUD tag, and the token's aud claim says which application it was issued for. It can be a string or a list. If it doesn't include our application's tag, the token was issued for something else, maybe another app in the same Zero Trust team, and it doesn't get into this one:
const audiences = Array.isArray(payload.aud) ? payload.aud : payload.aud ? [payload.aud] : [];
if (!audiences.includes(aud)) return { ok: false, reason: "wrong_audience" };
Last, the issuer must be our team domain. A token from another Cloudflare team would be signed by different keys and would already have failed, but the check costs one comparison, so we do it.
The verifier returns either the signed-in person's email and Access user ID, or a reason for failing: malformed, unsupported_alg, unknown_kid, bad_signature, expired, not_yet_valid, wrong_audience or wrong_issuer. A named reason is worth the extra lines. "Unauthorized" tells you nothing. wrong_audience tells you you're using the staging app's token on production.
Caching the signing keys
The verifier needs the public keys, and fetching them on every admin request would be slow and wasteful. So the Worker keeps them in memory, at module scope, for an hour. Access rotates its signing keys much less often than that.
const JWKS_TTL_MS = 60 * 60 * 1000;
let cache: { keys: AccessJwk[]; fetchedAt: number } | null = null;
let inflight: Promise<AccessJwk[]> | null = null;
async function jwksFor(teamDomain: string): Promise<AccessJwk[]> {
const now = Date.now();
if (cache && now - cache.fetchedAt < JWKS_TTL_MS) return cache.keys;
if (inflight) return inflight;
inflight = (async () => {
const response = await fetch(accessCertsUrl(teamDomain), { cf: { cacheTtl: 3600, cacheEverything: true } });
if (!response.ok) {
// Serve stale keys rather than locking the operator out on a blip.
if (cache) return cache.keys;
throw new Error(`access certs: HTTP ${response.status}`);
}
const body = (await response.json()) as { keys?: AccessJwk[] };
cache = { keys: body.keys ?? [], fetchedAt: now };
return cache.keys;
})().finally(() => { inflight = null; });
return inflight;
}
Three small things in there are worth copying.
The inflight promise means that if ten admin requests arrive on a fresh isolate at once, only one of them fetches the keys, and the other nine wait for that fetch. Without it, a dashboard that loads ten panels at once makes ten identical requests.
The fetch also asks Cloudflare's own cache to keep the response for an hour, so a new isolate in the same data centre usually doesn't go all the way to the keys URL either.
And if the fetch fails but we already have keys, we use the old ones. Stale public keys are still the right keys unless Access happened to rotate in that exact hour. Locking ourselves out of our own dashboard because of a brief network error would be the worse failure.
If we have no keys at all and can't get any, the admin route returns a 503 with access_unavailable. Not a 401. The person isn't unauthorised, we just can't check right now, and the difference matters when you're the one looking at the error.
Configuration that isn't secret
The Worker needs two pieces of configuration: the team domain and the application's AUD tag. Both are plain variables in our Wrangler config, not secrets. The comment next to them says it: these are identifiers, not secrets. The team domain is your Zero Trust subdomain, and the AUD is a public tag on the application.
It's tempting to store everything that looks like an ID as a secret, just in case. We don't, because it hides configuration that you want to be able to read, and because it suggests a protection that isn't there. Knowing our AUD tag doesn't help anybody make a token. Only Access's private key can do that.
If either value is missing, the admin routes return a 404, as if they don't exist. A Worker that hasn't been given an Access application doesn't have an admin area. Staging has its own custom domain and its own Access application with a different AUD, so a staging token can't be used on production, and the reverse.
Every config change made through the dashboard is recorded with the email from the verified token. Since nobody shares an account, the audit log says who changed what.
The same pattern, without the framework
Our cloud-save service uses the same design for its support hub, in plain JavaScript without a framework. The hub sits behind an Access application. The Worker verifies the token on every admin call, against the team's keys, with the same checks on audience, issuer and expiry. The comment there puts it in five words: the origin never trusts the edge alone.
The game endpoints on the same Worker are deliberately outside the Access application. Games save in the background, with no person there to sign in, and a policy mistake on the admin side must never lock players out of their own saves.
We also use Access in one more way, for machines rather than people: a service token that lets one of our Workers reach a moderation model on one of our own machines through Cloudflare Tunnel. That's a different setup, and we wrote it up in Cloudflare Tunnel with an Access service token.
Turnstile: both halves or nothing
Access answers "who is this person". Turnstile, Cloudflare's CAPTCHA replacement, answers a different question: "was a human present for this request". We use it in two places, and we removed it from a third.
The first is the contact form on this website. The Worker that sends the email checks the Turnstile token on the server before sending anything, and returns a 403 if it fails. That's the textbook use: a public form, filled in by a person, that a bot would love to fill in for free.
The second is Perron's account deletion form. Google Play requires a way to ask for your account to be deleted without installing the app, so there's a public web form. A public form that sends email is a target, so it has Turnstile.
Getting Turnstile right there taught us two things. The first is that the feature is a pair. Turnstile has a site key, which goes in the page, and a secret, which the server uses to verify. Our code only turns Turnstile on when both are configured:
// A sitekey with no secret renders a challenge nobody verifies: security theatre.
// A secret with no sitekey renders no challenge and then rejects every submission,
// which locks people out of asking to be deleted. Neither half is useful alone.
function turnstileOn(env: Env): boolean {
return Boolean(env.TURNSTILE_SITEKEY && env.TURNSTILE_SECRET);
}
Both of those half-configured states are easy to end up in during a deploy, and both are bad in opposite ways.
The second lesson is about failing closed. If the call to Cloudflare's verify endpoint fails, the form refuses the request rather than letting it through. That's only acceptable because there's another way in: the page also offers an email address for deletion requests, with no challenge on it. Failing closed is the right default when nobody's left without a way to do the thing.
The form has other protections that aren't about Turnstile. It accepts one open request per email address, so it can't be used to send a stranger forty emails. And it answers a repeat exactly like a first request, because "you already asked" would tell whoever pressed the button something true about somebody else.
Where Turnstile doesn't belong
The third place was our Mahjong game's cloud save, and we took Turnstile out.
It had been guarding the endpoint that writes a save. But a save write happens when a board is finished. It's fired by code, with nobody looking at a challenge. There was no honest place to put one. Either the game interrupts a finished board to ask the player to prove they're human, or the token is minted somewhere unrelated, at which point it proves nothing about the request carrying it.
The commit that removed it was titled "Cloud save, and no CAPTCHA in a machine's path", and that's the rule we follow now. A human-presence check only makes sense where a human is present.
What actually protects a save is different. The save's ID is unguessable, and that ID is what you'd need to touch somebody's save. To stop the storage being used as free hosting, there's a limit on how often each ID can be written, checked against the storage's own upload time rather than anything in the request. A rate limit that the client can reset by editing a field it sends isn't a rate limit. And per-IP limiting lives in Cloudflare's rules, where it runs before the Worker is ever invoked. We wrote about the save design itself in never lose a save with compare-and-swap, and about rate limiting inside Workers in the Workers rate limiting binding.
What we'd tell you
Put admin pages on the web, not in your app. Put Cloudflare Access in front of them, and verify the Access token in your Worker as well, every time. Accept only RS256, look up the key by kid, check the signature before anything else, then expiry, audience and issuer. Cache the keys for an hour, share one fetch between concurrent requests, and keep using old keys if a refresh fails. Turn off workers.dev on anything with an admin route. Keep identifiers as plain config, not secrets.
And use Turnstile where a person is filling something in, turn it on only when both halves are configured, and fail closed when there's another way through. Where a machine is making the request, leave it out. It can't help there, and it gets in the way.