Protecting the admin panel: Cloudflare Access SSO — and verifying the JWT inside the Worker
Cloudflare Access is the easiest SSO you’ll ever configure: point an application at /admin,
pick an identity provider, done. But there’s a subtlety that turns into a real
authentication bypass if you miss it — and this demo found it live. This post is the pattern
that avoids it: verify the JWT inside the Worker; never trust the header’s presence.
The story
The Access application covers /admin only — not /api/admin/*. Cloudflare strips and
injects the Cf-Access-Jwt-Assertion header solely on paths an Access app covers. On any path
it does not cover, the header arrives only if the client supplies it — meaning an attacker
can simply add Cf-Access-Jwt-Assertion: anything to a request against /api/admin/*.
An earlier version of this demo accepted the header on presence. That was a live authentication bypass: any caller with curl could mint admin API access by inventing a header. Found and fixed on 2026-09-21; the fix is the full verification below.
How it works
The Worker verifies the token completely — signature against the team’s JWKS, then iss,
aud, exp and nbf, with alg: none and algorithm-confusion attempts rejected up front:
export async function verifyAccessJwt(token: string, env: Env): Promise<AccessJwtPayload | null> {
const teamDomain = env.CF_ACCESS_TEAM_DOMAIN;
const expectedAud = env.CF_ACCESS_AUD;
if (!teamDomain || !expectedAud) return null; // fail closed: unconfigured = never trusted
const header = JSON.parse(/* decode header */);
if (header.alg !== 'RS256' || !header.kid) return null; // no "alg": "none", no confusion
const keys = await getAccessKeys(teamDomain); // team JWKS, cached 1h
const key = keys[header.kid];
const valid = await crypto.subtle.verify('RSASSA-PKCS1-v1_5', key,
base64urlToBytes(signatureB64), new TextEncoder().encode(`${headerB64}.${payloadB64}`));
if (!valid) return null;
const payload = JSON.parse(/* decode payload */);
if (payload.iss !== `https://${teamDomain}`) return null;
if (payload.exp <= now) return null;
if (!audList.includes(expectedAud)) return null;
return payload;
}
Fail-closed matters: if CF_ACCESS_TEAM_DOMAIN or CF_ACCESS_AUD is unset, the header is
never trusted at all. Both values are public identifiers (the AUD is in every signed token), so
there’s no secret in the config — the verification is the control.
Three credentials, one precedence. The admin API accepts, in order: the verified Access
JWT (production sign-in path), an HMAC-signed session cookie (minted at Access sign-in — more
below), or Authorization: Bearer for scripts and CI, compared in constant time.
The Access → session bridge. Because Access doesn’t cover /api/admin/*, the panel’s own
fetches arrive with no JWT. A verified Access sign-in therefore mints the session cookie in the
same response. Without this bridge the panel 401s on every API call and bounces back to
/admin in a redirect loop. The cookie itself is an HMAC-SHA256 signature over an expiry — the
admin secret is never in the cookie, so a stolen cookie can’t be replayed as a Bearer token.
The ?token= removal. The legacy /admin?token=<ADMIN_SECRET> URL used to authorize
every route on every host — leaking the secret into browser history, Referer headers and
access logs. It’s gone: humans sign in (session cookie), automation uses Bearer. The old
bookmark still works exactly once — it mints a cookie and 302s to a clean /admin.
What the demo shows
Visit /admin in production → Access SSO intercepts, authenticates, redirects back with the
JWT; the Worker verifies it and the panel loads. Visit the same URL from curl → you get the
Worker’s own sign-in form (401), because Access isn’t in front of curl. Try inventing a
Cf-Access-Jwt-Assertion header against /api/admin/* → rejected, signature check fails.
Evidence: what to capture
- Production
/admin→ Access SSO login page (your IdP) →10-access-sso.png - After sign-in: the admin panel (Users / Products / Orders tabs) →
10-admin-panel.png - Copy the
Cf-Access-Jwt-Assertionfrom DevTools → decode at jwt.io showingaud/iss/expclaims →10-jwt-claims.png - Cloudflare Zero Trust → Access → Applications → the app covering
/admin→10-access-app.png - Access request logs showing the sign-in event →
10-access-logs.png - (Optional) curl with a forged JWT header → 401 →
10-forgery-rejected.png
Key takeaways
- An Access-injected header is only trustworthy on paths the Access app covers — anywhere else, treat it as attacker-controlled.
- Verify the full JWT in the Worker (JWKS signature +
iss/aud/exp/nbf) and fail closed when unconfigured. - Plan the identity bridge: Access covers the page, but your API routes need their own credential — mint it at sign-in.
- Query-parameter auth is a secret-leak bug wearing a convenience hat. Cookies for humans, Bearer for machines.