This site is not affiliated with or endorsed by Cloudflare, Inc. It showcases deployable experiments across most Cloudflare products.
Edge Platform

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

  • requireAccess Hono middleware - Verifies the JWT and sets c.var.accessIdentity for downstream handlers
  • Header or cookie - Reads Cf-Access-Jwt-Assertion first, then the CF_Authorization cookie
  • Full verification - RS256 signature, issuer, audience, and expiry, with typed error codes
  • Cached JWKS - One createRemoteJWKSet per 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_DOMAIN or POLICY_AUD not 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 the requireAccess middleware. jose errors are mapped to TOKEN_EXPIRED, INVALID_TOKEN, or JWKS_UNAVAILABLE
  • src/lib/jwks.ts - Caches createRemoteJWKSet per team domain. Tests mock this module with a local JWKS built from a jose-generated RSA key pair
  • verifyAccessJwt(token, config, source, keySet) takes an optional keySet, so you can inject createLocalJWKSet in tests or pass pre-fetched keys
  • Only RS256 is 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 jose refetches when it sees an unknown kid
  • wrangler dev has no Access in front of it. Pass a real token manually, for example from cloudflared 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-identity with 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.

Dependenciesjose, honoBindingsNonePlatformCloudflare Access (Zero Trust), Vars: TEAM_DOMAIN, POLICY_AUD
Install
npm install jose hono
import { 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

Click the deploy button

Deploy to Cloudflare Workers

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 dev
TOKEN=$(cloudflared access token -app=https://your-app.example.com)
curl "http://localhost:8787/me" -H "Cf-Access-Jwt-Assertion: $TOKEN"

Configuration

VarExamplePurpose
TEAM_DOMAINhttps://yourteam.cloudflareaccess.comJWT issuer and JWKS host
POLICY_AUD32eafc76…c4228Expected aud (the Access application AUD tag)

No bindings are required. Both values are public identifiers, not secrets.

Cloudflare Features Used

Next Steps

On this page