# Cloudflare Access

> First-class JWT verification for Cloudflare Access — not the same as your application auth

Package: Koze
Canonical: https://kuratchi.dev/docs/koze/cloudflare-access
Markdown: https://kuratchi.dev/docs/koze/cloudflare-access.md

## What this is

Cloudflare Access is the platform's edge identity layer. When a user hits a URL behind an Access App, Cloudflare authenticates them against your IdP (Google, Azure AD, Okta, GitHub, etc.) and signs a JWT into the request before forwarding it to your Worker. The header is `cf-access-jwt-assertion`.

Koze verifies that JWT as a first-class framework primitive in `koze/access`. The verification is **strict by default** — signature, audience, issuer, and expiry are all checked against the team's signing keys. Failed verification short-circuits with a 403; a successful verification populates `locals.access` and the request continues.

## What this isn't

Cloudflare Access answers "is this user from my org?" It is **not** the same thing as `kyzen`, which answers "is this app user signed in?" The two compose orthogonally — see [Composing with kyzen](#composing-with-kuratchi-auth) below.

If you only need application auth (signup, signin, sessions, role checks), use `kyzen` and skip this page. If you only need edge identity for an internal admin tool, use this page and skip `kyzen`. Both, neither, either — all valid.

## Quick start

```ts
// src/middleware.ts
import { defineMiddleware } from 'koze:middleware';
import { requireCloudflareAccess } from '@kuratchi/koze/access';
import { env } from 'cloudflare:workers';

export default defineMiddleware({
  access: requireCloudflareAccess({
    audience: env.CF_ACCESS_AUD,
    teamDomain: 'mycompany.cloudflareaccess.com',
  }),
});
```

That's the whole integration. Every request is now verified against your Access App's policies; routes can read the verified identity through the `koze:access` virtual module.

## Configuration

| Option | Required | What it is |
|---|---|---|
| `audience` | Yes | The Application Audience (AUD) tag from your Access App. Found under the App's **Overview → Application Audience (AUD)**. JWT verification fails if the JWT's `aud` claim doesn't match. |
| `teamDomain` | Yes | Your team domain — `mycompany.cloudflareaccess.com` (no scheme). The framework derives the JWKS endpoint as `https://<teamDomain>/cdn-cgi/access/certs` and validates the JWT's issuer claim against `https://<teamDomain>`. |
| `exclude` | No | Path patterns to skip — `/health`, `/api/public/*`, etc. Matches against `pathname` only (query strings don't influence). Trailing slash normalized. |

`audience` is intentionally a single string. Each Access App has its own AUD. If a single Worker serves multiple Access Apps, compose multiple middleware steps with different audiences and `exclude` filters:

```ts
export default defineMiddleware({
  adminAccess: requireCloudflareAccess({
    audience: env.ADMIN_AUD,
    teamDomain: 'mycompany.cloudflareaccess.com',
    exclude: ['/api/*', '/'],   // only protect /admin/*
  }),
  apiAccess: requireCloudflareAccess({
    audience: env.API_AUD,
    teamDomain: 'mycompany.cloudflareaccess.com',
    exclude: ['/admin/*', '/'], // only protect /api/*
  }),
});
```

## Reading the verified identity

Routes consume the verified identity via the `koze:access` virtual module:

```html
<!-- src/routes/admin/index.koze -->
<script>
  import { user, isAuthenticated } from 'koze:access';

  if (!isAuthenticated()) throw new ActionError('Forbidden', 403);

  const me = user();
</script>

<h1>Welcome, {me.email}!</h1>
{#if me.groups?.includes('admins')}
  <a href="/admin/danger">Admin actions</a>
{/if}
```

### `user(): AccessIdentity`

Returns the verified identity. **Throws** if called on an unauthenticated request — guard with `isAuthenticated()` first if your route can serve both states.

```ts
interface AccessIdentity {
  /** Authenticated user email. Always present for user identities. */
  email: string;
  /** Stable Access user ID (`sub` claim). Persists across email changes. */
  sub: string;
  /** Identity provider (`azureAD`, `google`, `onetimepin`, etc.). */
  idp?: string;
  /** Group memberships defined in your IdP, surfaced by Access. */
  groups?: string[];
  /** Country code Cloudflare derived from the request. */
  country?: string;
  /** Custom claims set on the Access policy (e.g. `department`, `costCenter`). */
  custom?: Record<string, unknown>;
}
```

### `jwt(): Record<string, unknown>`

Returns the raw verified JWT payload. Escape hatch for unusual claims that aren't on the standard `AccessIdentity` projection. Same throw semantics as `user()`.

### `isAuthenticated(): boolean`

Non-throwing check. `true` when the current request has a verified Access identity, `false` otherwise. Use this in routes that conditionally show authenticated content (e.g. an admin link in a header) without taking the throw of `user()`.

## Excluding paths

Use `exclude` for endpoints that shouldn't require Access:

```ts
requireCloudflareAccess({
  audience: env.CF_ACCESS_AUD,
  teamDomain: 'mycompany.cloudflareaccess.com',
  exclude: [
    '/health',           // exact match
    '/api/public/*',     // glob — matches /api/public/foo, /api/public/foo/bar
    '/_assets/*',
    '/webhooks/stripe',
  ],
});
```

| Pattern | Matches |
|---|---|
| `/health` | `/health`, `/health/` |
| `/api/public/*` | `/api/public`, `/api/public/foo`, `/api/public/foo/bar/baz` |
| `/foo` | `/foo`, `/foo/` (NOT `/foo/bar`) |

Excluded paths skip JWT verification entirely. Routes under excluded paths cannot call `user()` or `jwt()` (those throw without a verified identity); they can call `isAuthenticated()` and it'll return `false`.

## Composing with kyzen

`kyzen` and Cloudflare Access answer different questions and run at different layers — they compose cleanly without conflict. A common shape:

```ts
// src/middleware.ts — admin panel + customer app on one Worker
import { defineMiddleware } from 'koze:middleware';
import { requireCloudflareAccess } from '@kuratchi/koze/access';
import { kyzenAuthMiddleware } from '@kuratchi/kyzen/middleware';
import { authConfig } from '$server/auth-config';
import { env } from 'cloudflare:workers';

export default defineMiddleware({
  // Edge-level identity: only employees with Access policies hit /admin/*
  access: requireCloudflareAccess({
    audience: env.CF_ACCESS_AUD,
    teamDomain: 'mycompany.cloudflareaccess.com',
    exclude: ['/auth/*', '/api/public/*', '/'],
  }),
  // App-level identity: customers sign in to the public app via credentials
  auth: kyzenAuthMiddleware(authConfig),
});
```

Two independent identity systems, neither knowing about the other. Routes can read both:

```html
<script>
  import { user as accessUser, isAuthenticated as hasAccess } from 'koze:access';
  import { getAuth } from '@kuratchi/kyzen';

  const accessIdentity = hasAccess() ? accessUser() : null;
  const auth = await getAuth();
  const session = await auth.getSession();
</script>
```

A request might have:

- **Access only**: an employee hitting an admin URL via Access, no app session.
- **App auth only**: a customer signed in to the public app, no Access (path was in `exclude`).
- **Both**: an employee who's also signed in to the customer app for testing.
- **Neither**: a public route excluded from Access, no app session.

Each route decides what it requires.

## How verification works

The framework uses [`jose`](https://github.com/panva/jose) under the hood — the standard, Workers-compatible JWT library.

1. `requireCloudflareAccess` is created at module load. The team's JWKS endpoint (`https://<teamDomain>/cdn-cgi/access/certs`) is registered for lazy fetch via `jose.createRemoteJWKSet`.
2. On each request, the JWT is read from the `cf-access-jwt-assertion` header.
3. `jose.jwtVerify` validates: signature (against the JWKS), `iss === https://<teamDomain>`, `aud === <audience>`, `exp` not in the past, `nbf` not in the future.
4. Verified payload is projected onto `AccessIdentity` and stored on `locals.access`. Standard claims fill named fields; everything else flows into `identity.custom`.
5. Failure → 403 with a brief reason (e.g. `Forbidden: Missing Cloudflare Access JWT`, `Forbidden: signature verification failed`).

JWKS keys are cached per team domain at module scope — first request after a cold start pays the JWKS fetch; subsequent requests use the cached keys. The cache survives within a Worker isolate's lifetime.

## Security notes

The middleware is **strict by default** and there is no `mode: 'trust-headers'` escape hatch. Three reasons:

1. **Header trust is a footgun.** Any path on your Worker that bypasses Access (a misconfigured policy, a forgotten `exclude` rule, a route Access doesn't cover) lets clients spoof identity by setting `cf-access-authenticated-user-email` themselves. JWT verification proves Cloudflare actually signed the assertion.

2. **The same code becoming secure or insecure based on env is how incidents ship.** A "loose in dev, strict in prod" toggle would be a permanent vector for accidental production exposure.

3. **`jose` makes this fast.** JWKS is fetched once, cached. Verification on each request is ~1ms of CPU. The cost of strict-by-default is ~no different than trust-headers in practice.

If you need a non-Access path during local dev (where Access isn't running), compose middleware around the dev flag rather than loosening the verifier:

```ts
import { dev } from '@kuratchi/koze/environment';

export default defineMiddleware({
  // Skip Access entirely in dev — let local requests through unverified.
  // Production requests always go through `requireCloudflareAccess`.
  access: dev
    ? { request: (_, next) => next() }
    : requireCloudflareAccess({ audience, teamDomain }),
});
```

This is explicit. There's no env-conditional inside the verifier; the dev branch is a different middleware step entirely. If you forget to gate it on `dev`, you get a no-op middleware in production — a behavior change you'll catch immediately rather than a silent verification skip.

## Testing locally

Cloudflare Access only injects the JWT for requests that come through your team's hostname after the user authenticates. Local Vite dev hits `localhost`, which doesn't go through Access — so `cf-access-jwt-assertion` is absent and `requireCloudflareAccess` returns 403 on every request.

Two options for local development:

1. **Skip Access in dev** (shown above). Fastest, but you're running unauthenticated.
2. **Use `cloudflared` to tunnel** through your team domain. Authentic Access flow, but slower to set up.

For most apps, option 1 is the right tradeoff — local dev is for iterating on UI / business logic, not for testing Access policies. Validate Access policies on a staging deployment.

## Configuration in `wrangler.jsonc`

`audience` typically lives in `wrangler.jsonc` `vars` so it's available to `env.CF_ACCESS_AUD`:

```jsonc
{
  "name": "my-app",
  "main": "src/worker.ts",
  "vars": {
    "CF_ACCESS_AUD": "your-application-audience-tag-here"
  }
}
```

The `teamDomain` is typically a literal in your code (it doesn't change per environment). If you have multiple environments with different team domains (rare), put it in `vars` too.

## Errors and edge cases

| Symptom | Cause | Fix |
|---|---|---|
| `Forbidden: Missing Cloudflare Access JWT` on every request | Either Access isn't enabled for this hostname, OR you're hitting localhost (Access doesn't sign requests there). | Verify the hostname is bound to an Access App; locally, follow [Testing locally](#testing-locally). |
| `Forbidden: signature verification failed` | The JWKS was fetched but the JWT's `kid` doesn't match any cached key. Usually means the key rotated and the cached set is stale (rare — Cloudflare bakes in long overlap windows). | Restart the Worker isolate (deploy a no-op change, or wait for cold-start). |
| `Forbidden: "iss" claim check failed` | Wrong `teamDomain`. The JWT's `iss` is `https://<actual-team>` and won't match the configured one. | Check the `Settings → General` page in Cloudflare Zero Trust for your actual team name. |
| `Forbidden: "aud" claim check failed` | The audience tag in your config doesn't match the Access App's AUD. | Copy the AUD from `Access → Applications → <app> → Overview → Application Audience (AUD)`. |
| Request with valid JWT still gets 403 | Token expired between the user authenticating and the request hitting your Worker (rare, requires a long-lived browser tab). | The user re-authenticates on next nav — Cloudflare handles it transparently. |

## Related

- [`kyzen`](/auth) — the application-auth quickstart library. Different layer, composes orthogonally.
- [Middleware](/docs/koze/middleware) — composition, hooks, error phases, and lifecycle.
- [Cloudflare Access docs](https://developers.cloudflare.com/cloudflare-one/policies/access/) — the platform side: policies, IdPs, and Application Audience.
