Cloudflare Access | Koze | Primitives Docs

Cloudflare Access

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

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 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

// 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:

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:

<!-- 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.

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:

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:

// 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:

<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 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:

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:

{
  "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.
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.
  • kyzen — the application-auth quickstart library. Different layer, composes orthogonally.
  • Middleware — composition, hooks, error phases, and lifecycle.
  • Cloudflare Access docs — the platform side: policies, IdPs, and Application Audience.