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-Assertion from DevTools → decode at jwt.io showing aud/iss/exp claims → 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.