# Access JWT Validator

URL: /docs/experiments/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
Tags: edge, security, zero-trust, jwt, access, auth, middleware
Markdown: /llms.mdx/experiments/access-jwt-validator/content.md



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`](https://github.com/panva/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 [#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 [#api-reference]

### GET /me [#get-me]

Verifies the Access JWT and returns its identity claims.

#### Example Request [#example-request]

```bash
curl "https://app.example.com/me" \
  -H "Cf-Access-Jwt-Assertion: <jwt>"
```

#### Success Response [#success-response]

```json
{
  "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 [#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 [#get-protected]

An example route behind `app.use("/protected/*", requireAccess)`. It returns the same errors as `/me`.

#### Success Response [#success-response-1]

```json
{
  "message": "Hello, user@example.com. This response was only sent after verifying your Access JWT.",
  "user": "user@example.com",
  "country": "US"
}
```

### GET / [#get-]

App info, including `configured: true | false` to show whether both vars are set.

## Setup [#setup]

<Steps>
  <Step>
    ### Create an Access application [#create-an-access-application]

    In [Zero Trust](https://one.dash.cloudflare.com/), 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.
  </Step>

  <Step>
    ### Copy the AUD tag [#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`.
  </Step>

  <Step>
    ### Find your team domain [#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.
  </Step>
</Steps>

<Callout type="warning" title="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`.
</Callout>

## Implementation Details [#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

<Callout>
  If Access invokes your Worker directly, newer runtimes also expose the identity as `ctx.access`
  without any JWT parsing (see [Cloudflare Access for
  Workers](https://developers.cloudflare.com/workers/configuration/cloudflare-access/)). 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.
</Callout>

## Use Cases [#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 [#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 [#use-in-your-project]

<UseInYourProject
  experiment="access-jwt-validator"
  dependencies="[&#x22;jose&#x22;, &#x22;hono&#x22;]"
  bindings="[]"
  platform="[&#x22;Cloudflare Access (Zero Trust)&#x22;, &#x22;Vars: TEAM_DOMAIN, POLICY_AUD&#x22;]"
  files="[
  &#x22;src/lib/access.ts&#x22;,
  &#x22;src/lib/jwks.ts&#x22;,
  &#x22;src/types/access.ts&#x22;,
  &#x22;src/constants/defaults.ts&#x22;,
  &#x22;src/utils/response.ts&#x22;,
  &#x22;examples/usage.ts&#x22;,
]"
  sources="[{&#x22;path&#x22;:&#x22;src/lib/access.ts&#x22;,&#x22;title&#x22;:&#x22;access.ts&#x22;,&#x22;lang&#x22;:&#x22;ts&#x22;,&#x22;code&#x22;:&#x22;import { errors, jwtVerify, type JWTPayload, type JWTVerifyGetKey } from \&#x22;jose\&#x22;;\nimport { createMiddleware } from \&#x22;hono/factory\&#x22;;\nimport { ACCESS_COOKIE, ACCESS_JWT_ALGORITHMS, ACCESS_JWT_HEADER } from \&#x22;../constants/defaults\&#x22;;\nimport type {\n  AccessAppEnv,\n  AccessConfig,\n  AccessErrorCode,\n  AccessIdentity,\n  TokenSource,\n} from \&#x22;../types/access\&#x22;;\nimport type { Env } from \&#x22;../types/env\&#x22;;\nimport { jsonError } from \&#x22;../utils/response\&#x22;;\nimport { getRemoteKeySet } from \&#x22;./jwks\&#x22;;\n\nexport class AccessError extends Error {\n  constructor(\n    message: string,\n    readonly code: AccessErrorCode,\n    readonly status: 401 | 500 | 502\n  ) {\n    super(message);\n  }\n}\n\n/** Normalizes `yourteam.cloudflareaccess.com` or `https://.../` to `https://yourteam.cloudflareaccess.com`. */\nexport function normalizeTeamDomain(input: string): string | null {\n  const trimmed = input.trim();\n  if (!trimmed) return null;\n  try {\n    const url = new URL(/^https?:\\/\\//i.test(trimmed) ? trimmed : `https://${trimmed}`);\n    return url.protocol === \&#x22;https:\&#x22; ? url.origin : null;\n  } catch {\n    return null;\n  }\n}\n\nexport function getAccessConfig(env: Env): AccessConfig | null {\n  const teamDomain = normalizeTeamDomain(env.TEAM_DOMAIN ?? \&#x22;\&#x22;);\n  const audience = env.POLICY_AUD?.trim();\n  return teamDomain && audience ? { teamDomain, audience } : null;\n}\n\n/** Reads the Access JWT from the `Cf-Access-Jwt-Assertion` header, falling back to the `CF_Authorization` cookie. */\nexport function getAccessToken(request: Request): { token: string; source: TokenSource } | null {\n  const header = request.headers.get(ACCESS_JWT_HEADER)?.trim();\n  if (header) return { token: header, source: \&#x22;header\&#x22; };\n\n  for (const part of (request.headers.get(\&#x22;Cookie\&#x22;) ?? \&#x22;\&#x22;).split(\&#x22;;\&#x22;)) {\n    const [name, ...rest] = part.trim().split(\&#x22;=\&#x22;);\n    const value = rest.join(\&#x22;=\&#x22;).trim();\n    if (name === ACCESS_COOKIE && value) return { token: value, source: \&#x22;cookie\&#x22; };\n  }\n  return null;\n}\n\nfunction toIdentity(payload: JWTPayload, source: TokenSource): AccessIdentity {\n  const str = (key: string) =>\n    typeof payload[key] === \&#x22;string\&#x22; ? (payload[key] as string) : undefined;\n  return {\n    email: str(\&#x22;email\&#x22;),\n    sub: payload.sub ?? \&#x22;\&#x22;,\n    common_name: str(\&#x22;common_name\&#x22;),\n    country: str(\&#x22;country\&#x22;),\n    identity_nonce: str(\&#x22;identity_nonce\&#x22;),\n    type: str(\&#x22;type\&#x22;),\n    iss: payload.iss ?? \&#x22;\&#x22;,\n    aud: Array.isArray(payload.aud) ? payload.aud : payload.aud ? [payload.aud] : [],\n    iat: payload.iat,\n    exp: payload.exp,\n    source,\n  };\n}\n\n/**\n * Verifies signature (RS256 via the team JWKS), issuer, audience, and expiry.\n * Pass `keySet` to inject a local JWKS (tests, or pre-fetched keys).\n */\nexport async function verifyAccessJwt(\n  token: string,\n  config: AccessConfig,\n  source: TokenSource = \&#x22;header\&#x22;,\n  keySet: JWTVerifyGetKey = getRemoteKeySet(config.teamDomain)\n): Promise<AccessIdentity> {\n  try {\n    const { payload } = await jwtVerify(token, keySet, {\n      issuer: config.teamDomain,\n      audience: config.audience,\n      algorithms: ACCESS_JWT_ALGORITHMS,\n    });\n    return toIdentity(payload, source);\n  } catch (e) {\n    if (e instanceof errors.JWTExpired) {\n      throw new AccessError(\&#x22;Access token has expired\&#x22;, \&#x22;TOKEN_EXPIRED\&#x22;, 401);\n    }\n    if (\n      e instanceof errors.JWKSTimeout ||\n      e instanceof errors.JWKSInvalid ||\n      !(e instanceof errors.JOSEError) ||\n      e.code === \&#x22;ERR_JOSE_GENERIC\&#x22;\n    ) {\n      throw new AccessError(\&#x22;Unable to fetch Access signing keys\&#x22;, \&#x22;JWKS_UNAVAILABLE\&#x22;, 502);\n    }\n    throw new AccessError(\&#x22;Invalid Access token\&#x22;, \&#x22;INVALID_TOKEN\&#x22;, 401);\n  }\n}\n\n/** Hono middleware: verifies the Access JWT and sets `c.var.accessIdentity`, or responds 401/500/502. */\nexport const requireAccess = createMiddleware<AccessAppEnv>(async (c, next) => {\n  const config = getAccessConfig(c.env);\n  if (!config) {\n    return jsonError(c, \&#x22;TEAM_DOMAIN and POLICY_AUD must be configured\&#x22;, \&#x22;NOT_CONFIGURED\&#x22;, 500);\n  }\n\n  const found = getAccessToken(c.req.raw);\n  if (!found) {\n    return jsonError(\n      c,\n      `Missing ${ACCESS_JWT_HEADER} header or ${ACCESS_COOKIE} cookie`,\n      \&#x22;MISSING_TOKEN\&#x22;,\n      401\n    );\n  }\n\n  try {\n    c.set(\&#x22;accessIdentity\&#x22;, await verifyAccessJwt(found.token, config, found.source));\n  } catch (e) {\n    if (e instanceof AccessError) return jsonError(c, e.message, e.code, e.status);\n    throw e;\n  }\n  await next();\n});&#x22;},{&#x22;path&#x22;:&#x22;src/lib/jwks.ts&#x22;,&#x22;title&#x22;:&#x22;jwks.ts&#x22;,&#x22;lang&#x22;:&#x22;ts&#x22;,&#x22;code&#x22;:&#x22;import { createRemoteJWKSet, type JWTVerifyGetKey } from \&#x22;jose\&#x22;;\nimport { ACCESS_CERTS_PATH } from \&#x22;../constants/defaults\&#x22;;\n\n// Module scope so the fetched keys are reused across requests in the same isolate.\nconst keySets = new Map<string, JWTVerifyGetKey>();\n\n/** Returns a cached remote JWKS for `<teamDomain>/cdn-cgi/access/certs`. */\nexport function getRemoteKeySet(teamDomain: string): JWTVerifyGetKey {\n  let keySet = keySets.get(teamDomain);\n  if (!keySet) {\n    keySet = createRemoteJWKSet(new URL(`${teamDomain}${ACCESS_CERTS_PATH}`));\n    keySets.set(teamDomain, keySet);\n  }\n  return keySet;\n}&#x22;},{&#x22;path&#x22;:&#x22;src/types/access.ts&#x22;,&#x22;title&#x22;:&#x22;access.ts&#x22;,&#x22;lang&#x22;:&#x22;ts&#x22;,&#x22;code&#x22;:&#x22;import type { Env } from \&#x22;./env\&#x22;;\n\nexport type TokenSource = \&#x22;header\&#x22; | \&#x22;cookie\&#x22;;\n\nexport interface AccessConfig {\n  teamDomain: string;\n  audience: string;\n}\n\n/** Identity claims from a verified Access JWT. Service tokens have `common_name` instead of `email`. */\nexport interface AccessIdentity {\n  email?: string;\n  sub: string;\n  common_name?: string;\n  country?: string;\n  identity_nonce?: string;\n  type?: string;\n  iss: string;\n  aud: string[];\n  iat?: number;\n  exp?: number;\n  source: TokenSource;\n}\n\nexport type AccessErrorCode =\n  | \&#x22;NOT_CONFIGURED\&#x22;\n  | \&#x22;MISSING_TOKEN\&#x22;\n  | \&#x22;INVALID_TOKEN\&#x22;\n  | \&#x22;TOKEN_EXPIRED\&#x22;\n  | \&#x22;JWKS_UNAVAILABLE\&#x22;;\n\nexport type AccessAppEnv = {\n  Bindings: Env;\n  Variables: { accessIdentity: AccessIdentity };\n};&#x22;},{&#x22;path&#x22;:&#x22;src/constants/defaults.ts&#x22;,&#x22;title&#x22;:&#x22;defaults.ts&#x22;,&#x22;lang&#x22;:&#x22;ts&#x22;,&#x22;code&#x22;:&#x22;export const ACCESS_JWT_HEADER = \&#x22;Cf-Access-Jwt-Assertion\&#x22;;\nexport const ACCESS_COOKIE = \&#x22;CF_Authorization\&#x22;;\nexport const ACCESS_CERTS_PATH = \&#x22;/cdn-cgi/access/certs\&#x22;;\nexport const ACCESS_JWT_ALGORITHMS = [\&#x22;RS256\&#x22;];&#x22;},{&#x22;path&#x22;:&#x22;src/utils/response.ts&#x22;,&#x22;title&#x22;:&#x22;response.ts&#x22;,&#x22;lang&#x22;:&#x22;ts&#x22;,&#x22;code&#x22;:&#x22;import type { Context } from \&#x22;hono\&#x22;;\n\nexport function jsonError(\n  c: Context,\n  message: string,\n  code: string,\n  status: 400 | 401 | 404 | 500 | 502 = 400\n) {\n  return c.json({ error: message, code }, { status });\n}\n\nexport function jsonSuccess<T>(c: Context, data: T, status: 200 = 200) {\n  return c.json(data, { status });\n}&#x22;},{&#x22;path&#x22;:&#x22;examples/usage.ts&#x22;,&#x22;title&#x22;:&#x22;usage.ts&#x22;,&#x22;lang&#x22;:&#x22;ts&#x22;,&#x22;code&#x22;:&#x22;/**\n * Minimal paste example for docs (\&#x22;Use in your project\&#x22;).\n * Not part of the Worker runtime — copy into your own project.\n *\n * Set vars TEAM_DOMAIN (https://<team>.cloudflareaccess.com) and POLICY_AUD (the app's AUD tag).\n * Every route registered after `app.use(\&#x22;*\&#x22;, requireAccess)` only runs for verified Access users.\n */\nimport { Hono } from \&#x22;hono\&#x22;;\nimport { requireAccess } from \&#x22;../src/lib/access\&#x22;;\nimport type { AccessAppEnv } from \&#x22;../src/types/access\&#x22;;\n\nconst app = new Hono<AccessAppEnv>();\n\napp.use(\&#x22;*\&#x22;, requireAccess);\n\napp.get(\&#x22;/dashboard\&#x22;, (c) => {\n  const { email, sub } = c.var.accessIdentity;\n  return c.json({ hello: email ?? sub });\n});\n\nexport default app;&#x22;}]"
/>

## Deployment [#deployment]

<Steps>
  <Step>
    ### Click the deploy button [#click-the-deploy-button]

    [![Deploy to Cloudflare Workers](https://deploy.workers.cloudflare.com/button)](https://deploy.workers.cloudflare.com/?url=https://github.com/shrinathsnayak/cloudflare-experiments/tree/main/apps/experiments/access-jwt-validator)
  </Step>

  <Step>
    ### Configure vars and Access [#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](#setup).
  </Step>

  <Step>
    ### Test your deployment [#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:

    ```bash
    curl -i "https://<your-worker-host>/me"
    # 401 {"error":"Missing Cf-Access-Jwt-Assertion header or CF_Authorization cookie","code":"MISSING_TOKEN"}
    ```
  </Step>
</Steps>

## Local Development [#local-development]

```bash
cd apps/experiments/access-jwt-validator
npm install
npm run dev
```

```bash
TOKEN=$(cloudflared access token -app=https://your-app.example.com)
curl "http://localhost:8787/me" -H "Cf-Access-Jwt-Assertion: $TOKEN"
```

## Configuration [#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-features-used]

* **[Cloudflare Access](https://developers.cloudflare.com/cloudflare-one/access-controls/)** - Zero Trust authentication in front of the Worker
* **[Validate Access JWTs](https://developers.cloudflare.com/cloudflare-one/access-controls/applications/http-apps/authorization-cookie/validating-json/)** - Team JWKS (`/cdn-cgi/access/certs`) and the `Cf-Access-Jwt-Assertion` header
* **[Workers](https://developers.cloudflare.com/workers/)** - Edge compute runtime

## Next Steps [#next-steps]

<Cards>
  <Card title="JWT Inspector" href="/experiments/jwt-inspector">
    Decode and verify arbitrary JWTs with Web Crypto
  </Card>

  <Card title="Turnstile Verify" href="/experiments/turnstile-verify">
    Bot protection for public forms
  </Card>
</Cards>
