Access JWT Validator
Verify Cloudflare Zero Trust Access JWTs in a Worker with jose, team JWKS, and reusable Hono middleware - edge auth for protected APIs
Verify Cloudflare Access JWTs inside a Worker. The token comes from the Cf-Access-Jwt-Assertion header, or from the CF_Authorization cookie if the header is missing. The Worker checks it with jose against your team's public keys at https://<team>.cloudflareaccess.com/cdn-cgi/access/certs, with issuer = team domain and audience = the application's AUD tag.
Features
requireAccessHono middleware - Verifies the JWT and setsc.var.accessIdentityfor downstream handlers- Header or cookie - Reads
Cf-Access-Jwt-Assertionfirst, then theCF_Authorizationcookie - Full verification - RS256 signature, issuer, audience, and expiry, with typed error codes
- Cached JWKS - One
createRemoteJWKSetper team domain per isolate, so keys aren't fetched on every request - Service tokens - Returns
common_name(the client ID) when the token was issued for a service token rather than a user
API Reference
GET /me
Verifies the Access JWT and returns its identity claims.
Example Request
curl "https://app.example.com/me" \
-H "Cf-Access-Jwt-Assertion: <jwt>"Success Response
{
"email": "user@example.com",
"sub": "7335d417-61da-459d-899c-0a01c76a2f94",
"country": "US",
"identity_nonce": "6ei69kawdKzMIAPF",
"type": "app",
"iss": "https://yourteam.cloudflareaccess.com",
"aud": ["32eafc7626e974616deaf0dc3ce63d7bcbed58a2731e84d06bc3cdf1b53c4228"],
"iat": 1759336000,
"exp": 1759422400,
"source": "header"
}source is "header" or "cookie". Service-token requests include common_name and usually have no email.
Error Codes
401- No token (MISSING_TOKEN), bad signature, issuer, or audience (INVALID_TOKEN), expired (TOKEN_EXPIRED)500-TEAM_DOMAINorPOLICY_AUDnot set (NOT_CONFIGURED)502- Could not fetch or parse the team JWKS (JWKS_UNAVAILABLE)
GET /protected
An example route behind app.use("/protected/*", requireAccess). It returns the same errors as /me.
Success Response
{
"message": "Hello, user@example.com. This response was only sent after verifying your Access JWT.",
"user": "user@example.com",
"country": "US"
}GET /
App info, including configured: true | false to show whether both vars are set.
Setup
Create an Access application
In Zero Trust, go to Access → Applications → Add an application → Self-hosted and enter the Worker's hostname (a custom domain or *.workers.dev). Attach a policy, for example "emails ending in @yourcompany.com". You can also enable Access from the Worker's Settings → Domains & Routes in the Workers dashboard.
Copy the AUD tag
Open the application's Overview (or Basic information) tab and copy the Application Audience (AUD) Tag. That value is POLICY_AUD.
Find your team domain
Your team domain is https://<team-name>.cloudflareaccess.com. Find the team name under Settings → Custom Pages (or Settings → General) in Zero Trust. That value is TEAM_DOMAIN. A bare hostname or a trailing slash also works; the Worker normalizes it to an https:// origin.
Don't trust identity headers by themselves
Access also adds Cf-Access-Authenticated-User-Email. That header is not signed: anyone who
can reach your Worker without going through Access (another route, a workers.dev URL you forgot
to protect, or a misconfigured hostname) can set it to any value. Always verify
Cf-Access-Jwt-Assertion and read the email from the verified claims. This experiment never reads
the email header, and a test confirms that a request carrying only that header gets 401.
Implementation Details
src/lib/access.ts-getAccessToken,verifyAccessJwt, and therequireAccessmiddleware.joseerrors are mapped toTOKEN_EXPIRED,INVALID_TOKEN, orJWKS_UNAVAILABLEsrc/lib/jwks.ts- CachescreateRemoteJWKSetper team domain. Tests mock this module with a local JWKS built from ajose-generated RSA key pairverifyAccessJwt(token, config, source, keySet)takes an optionalkeySet, so you can injectcreateLocalJWKSetin tests or pass pre-fetched keys- Only
RS256is accepted (algorithms: ["RS256"]), which blocks algorithm-confusion attacks
If Access invokes your Worker directly, newer runtimes also expose the identity as ctx.access
without any JWT parsing (see Cloudflare Access for
Workers). Verifying
the JWT yourself still matters when your origin or Worker is reached through other paths, or when
you forward the token to another service.
Use Cases
- Protect internal tools, admin APIs, or staging Workers with Zero Trust policies
- Attribute actions to the verified Access user (
email/sub) in logs or audit trails - Accept both browser sessions (cookie) and service tokens (header) on one route
- Use as a reference for validating Access JWTs at any origin, not just Workers
Limitations
- Requests that bypass Access never get a valid token. This is intentional: such requests receive
401 - The JWKS is cached per isolate. Access rotates keys periodically, and
joserefetches when it sees an unknownkid wrangler devhas no Access in front of it. Pass a real token manually, for example fromcloudflared access token -app=https://your-app.example.com- Only identity claims in the JWT are returned. For groups or IdP attributes, call
https://<team>.cloudflareaccess.com/cdn-cgi/access/get-identitywith the cookie
Use in your project
Copy these files into an existing Worker. Prefer Deployment to try the full experiment first. Source: apps/experiments/access-jwt-validator.
npm install jose honoimport { errors, jwtVerify, type JWTPayload, type JWTVerifyGetKey } from "jose";import { createMiddleware } from "hono/factory";import { ACCESS_COOKIE, ACCESS_JWT_ALGORITHMS, ACCESS_JWT_HEADER } from "../constants/defaults";import type { AccessAppEnv, AccessConfig, AccessErrorCode, AccessIdentity, TokenSource,} from "../types/access";import type { Env } from "../types/env";import { jsonError } from "../utils/response";import { getRemoteKeySet } from "./jwks";export class AccessError extends Error { constructor( message: string, readonly code: AccessErrorCode, readonly status: 401 | 500 | 502 ) { super(message); }}/** Normalizes `yourteam.cloudflareaccess.com` or `https://.../` to `https://yourteam.cloudflareaccess.com`. */export function normalizeTeamDomain(input: string): string | null { const trimmed = input.trim(); if (!trimmed) return null; try { const url = new URL(/^https?:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`); return url.protocol === "https:" ? url.origin : null; } catch { return null; }}export function getAccessConfig(env: Env): AccessConfig | null { const teamDomain = normalizeTeamDomain(env.TEAM_DOMAIN ?? ""); const audience = env.POLICY_AUD?.trim(); return teamDomain && audience ? { teamDomain, audience } : null;}/** Reads the Access JWT from the `Cf-Access-Jwt-Assertion` header, falling back to the `CF_Authorization` cookie. */export function getAccessToken(request: Request): { token: string; source: TokenSource } | null { const header = request.headers.get(ACCESS_JWT_HEADER)?.trim(); if (header) return { token: header, source: "header" }; for (const part of (request.headers.get("Cookie") ?? "").split(";")) { const [name, ...rest] = part.trim().split("="); const value = rest.join("=").trim(); if (name === ACCESS_COOKIE && value) return { token: value, source: "cookie" }; } return null;}function toIdentity(payload: JWTPayload, source: TokenSource): AccessIdentity { const str = (key: string) => typeof payload[key] === "string" ? (payload[key] as string) : undefined; return { email: str("email"), sub: payload.sub ?? "", common_name: str("common_name"), country: str("country"), identity_nonce: str("identity_nonce"), type: str("type"), iss: payload.iss ?? "", aud: Array.isArray(payload.aud) ? payload.aud : payload.aud ? [payload.aud] : [], iat: payload.iat, exp: payload.exp, source, };}/** * Verifies signature (RS256 via the team JWKS), issuer, audience, and expiry. * Pass `keySet` to inject a local JWKS (tests, or pre-fetched keys). */export async function verifyAccessJwt( token: string, config: AccessConfig, source: TokenSource = "header", keySet: JWTVerifyGetKey = getRemoteKeySet(config.teamDomain)): Promise<AccessIdentity> { try { const { payload } = await jwtVerify(token, keySet, { issuer: config.teamDomain, audience: config.audience, algorithms: ACCESS_JWT_ALGORITHMS, }); return toIdentity(payload, source); } catch (e) { if (e instanceof errors.JWTExpired) { throw new AccessError("Access token has expired", "TOKEN_EXPIRED", 401); } if ( e instanceof errors.JWKSTimeout || e instanceof errors.JWKSInvalid || !(e instanceof errors.JOSEError) || e.code === "ERR_JOSE_GENERIC" ) { throw new AccessError("Unable to fetch Access signing keys", "JWKS_UNAVAILABLE", 502); } throw new AccessError("Invalid Access token", "INVALID_TOKEN", 401); }}/** Hono middleware: verifies the Access JWT and sets `c.var.accessIdentity`, or responds 401/500/502. */export const requireAccess = createMiddleware<AccessAppEnv>(async (c, next) => { const config = getAccessConfig(c.env); if (!config) { return jsonError(c, "TEAM_DOMAIN and POLICY_AUD must be configured", "NOT_CONFIGURED", 500); } const found = getAccessToken(c.req.raw); if (!found) { return jsonError( c, `Missing ${ACCESS_JWT_HEADER} header or ${ACCESS_COOKIE} cookie`, "MISSING_TOKEN", 401 ); } try { c.set("accessIdentity", await verifyAccessJwt(found.token, config, found.source)); } catch (e) { if (e instanceof AccessError) return jsonError(c, e.message, e.code, e.status); throw e; } await next();});Deployment
Configure vars and Access
Set TEAM_DOMAIN and POLICY_AUD in wrangler.json vars (or in the dashboard under Settings → Variables). Then put an Access application in front of the Worker's hostname, as described in Setup.
Test your deployment
Open https://<your-worker-host>/me in a browser. After the Access login you'll see your claims. Without Access, the request fails:
curl -i "https://<your-worker-host>/me"
# 401 {"error":"Missing Cf-Access-Jwt-Assertion header or CF_Authorization cookie","code":"MISSING_TOKEN"}Local Development
cd apps/experiments/access-jwt-validator
npm install
npm run devTOKEN=$(cloudflared access token -app=https://your-app.example.com)
curl "http://localhost:8787/me" -H "Cf-Access-Jwt-Assertion: $TOKEN"Configuration
| Var | Example | Purpose |
|---|---|---|
TEAM_DOMAIN | https://yourteam.cloudflareaccess.com | JWT issuer and JWKS host |
POLICY_AUD | 32eafc76…c4228 | Expected aud (the Access application AUD tag) |
No bindings are required. Both values are public identifiers, not secrets.
Cloudflare Features Used
- Cloudflare Access - Zero Trust authentication in front of the Worker
- Validate Access JWTs - Team JWKS (
/cdn-cgi/access/certs) and theCf-Access-Jwt-Assertionheader - Workers - Edge compute runtime