Skip to content

Authentication

Cloudflare Access is a great way to put a single authentication layer in front of every app on your team. The problem: Access runs at Cloudflare's edge, not in your local dev server — so the access token your Worker relies on in production simply isn't there when you run vite dev or your Vitest suite.

The Cloudflare Toolkit closes that gap with two pieces that share the same policies and the same verification code, so your Worker's authentication code is written once and runs unchanged from local development to production:

  • cloudflareAccess (/hono) — the production middleware that validates the Access JWT on every request.
  • cloudflareAccessPlugin (/vite) — a dev-only Vite plugin that emulates the Access edge during vite dev.

The problem: Cloudflare Access lives at the edge

In production, Cloudflare Access sits in front of your Worker. It authenticates the user, then injects a signed JWT into every request before that request ever reaches your code. Your Worker's only job is to ensure that the access token is valid.

Locally there is no Cloudflare Access in the loop. vite dev serves your app straight from Miniflare, which doesn't emulate Cloudflare Access, so no access token is ever injected.

The mechanism: two halves, one code path

The Cloudflare Toolkit provides two halves of the system. Within your worker, the cloudflareAccess middleware makes it easy to validate the token that the Cloudflare Access system provides. Outside the worker, the Cloudflare Toolkit provides the cloudflareAccessPlugin for vite, which emulates the functionality of Cloudflare Access, making it simple to emulate any user of the system without complicated authentication logic. Your code goes from development to production seamlessly.

Both halves of the process are built on the same internal JWT/JWKS/policy module and take the same PathPolicy array, so a session minted locally is accepted by the exact verification code that runs in production.

StepProductionLocal vite dev
Authenticates the userCloudflare Access (edge)cloudflareAccessPlugin login form
Signs the JWTCloudflare AccesscloudflareAccessPlugin (dev key)
Injects the request headerCloudflare Access (edge)cloudflareAccessPlugin (connect)
Verifies the JWTcloudflareAccess (JWKS)cloudflareAccess (dev key)
Your handler codeunchangedunchanged

The rest of this guide shows you how to wire each half, then covers configuring both for production and development in Security hardening.

Provisioning production Access

The runtime middleware validates tokens that Cloudflare Access has already issued; it does not create the production Access applications or reusable policies. For an Access-only project, use cf-access-policy with a typed access.config.ts to reconcile those resources through the cf CLI:

ts
import { defineAccessConfig } from "@adrianhall/cloudflare-toolkit";

export default defineAccessConfig({
  policies: [
    {
      name: "example staff",
      decision: "allow",
      include: [{ email_domain: { domain: "example.com" } }]
    }
  ],
  applications: [
    {
      name: "example API",
      domain: "api.example.com",
      destinations: [{ type: "public", uri: "api.example.com/*" }],
      policies: [{ name: "example staff", precedence: 1 }]
    }
  ]
});

Run cf-access-policy apply after cf deploy; use cf-access-policy remove before deleting the Worker. The CLI uses Cloudflare's reusable-policy model, so one named policy can be referenced by multiple configured self-hosted applications.

The deployment AccessConfig and runtime PathPolicy are separate models and are not automatically cross-validated. AccessConfig determines which edge applications, destinations, identity rules, and policy precedence Cloudflare provisions. PathPolicy determines which requests the Worker validates and which audience each protected path accepts. Keep their route coverage aligned deliberately. cf-access-policy does not output application audience IDs; copy each Audience (AUD) Tag from the Access application overview into runtime configuration when setting the top-level or path-specific audience.

Protecting your Worker

Add cloudflareAccess as middleware. It reads the JWT, verifies it, and on success sets one typed identity object for every downstream handler:

ts
import { Hono } from "hono";
import { cloudflareAccess, type AuthVariables } from "@adrianhall/cloudflare-toolkit/hono";

const app = new Hono<{ Bindings: Env; Variables: AuthVariables }>();

app.use(
  cloudflareAccess({
    policies: [
      { pattern: /^\/api\/version$/, authenticate: false },
      { pattern: /^\/api\//, authenticate: true }
    ],
    enableDevTokens: import.meta.env.DEV
  })
);

app.get("/api/version", (c) => c.json({ version: "1.0.0" })); // public
app.get("/api/me", (c) => c.json(c.get("Cloudflare_Access_Identity"))); // protected

The AuthVariables (and the combined CloudflareToolkitVariables) provides Cloudflare_Access_Identity within the Hono context:

  • email — the JWT email claim.
  • sub — the JWT sub claim, a stable per-user identifier ideal for authorization.
  • source"header" for Cf-Access-Jwt-Assertion or "cookie" for CF_Authorization. The header takes precedence when both are present.

Every 401 the middleware returns is itself an RFC 9457 application/problem+json response — the same shape problemDetailsErrorHandler and notFoundHandler produce, so errors stay uniform across your app (see Error Handling).

Path policies

policies is an ordered PathPolicy array; the first match wins:

  • authenticate: false — public; skip JWT validation entirely.
  • authenticate: true — protected; a missing or invalid JWT returns 401.

For a path that matches no policy, defaultAction decides:

  • "block" (default) — treat it as protected.
  • "bypass" — let it through unauthenticated. If a valid JWT happens to be present, AuthVariables are still set.
ts
app.use(
  cloudflareAccess({
    policies: [{ pattern: /^\/admin\//, authenticate: true }],
    defaultAction: "bypass" // everything outside /admin/ is public by default
  })
);

See CloudflareAccessOptions for the full option surface.

Path-specific audiences

A single Worker can front several path-scoped Cloudflare Access applications on the same hostname — for example a contributor API, a reviewer API, and a publisher API, each with its own Access Application and Audience (AUD) Tag. Give a policy its own audience so each path is validated against the exact application it belongs to, instead of one flat allowlist that would let a token minted for one application pass audience validation on another application's routes:

ts
app.use(
  cloudflareAccess({
    policies: [
      { pattern: /^\/api\/version$/, authenticate: false },
      { pattern: /^\/api\/contributor/, authenticate: true, audience: contributorAud },
      { pattern: /^\/api\/reviewer/, authenticate: true, audience: reviewerAud },
      { pattern: /^\/api\/publisher/, authenticate: true, audience: publisherAud }
    ]
  })
);

A matched policy's audience overrides the top-level audience fallback for that request rather than merging with it. Keep the top-level audience set as a fallback for any authenticated path that doesn't need its own override (including an unmatched path when defaultAction is "block") — cloudflareAccess logs a one-time warning at construction time whenever some authenticated request path could still reach verification with no audience configured at all, whether that's because neither a fallback nor a per-policy override was set.

Developing locally

Register cloudflareAccessPlugin in vite.config.ts, and pass it the same policy array you gave the Worker:

ts
// vite.config.ts
import { defineConfig } from "vite";
import { cloudflare } from "@cloudflare/vite-plugin";
import { cloudflareAccessPlugin } from "@adrianhall/cloudflare-toolkit/vite";
import { authPolicies } from "./src/auth-policies";

export default defineConfig({
  plugins: [
    // MUST come before cloudflare() so its connect middleware runs first and can inject the
    // Access headers before the request is dispatched into the Worker.
    cloudflareAccessPlugin({ policies: authPolicies }),
    cloudflare()
  ]
});

Define authPolicies in its own module and import it into both configs — that single shared array is what keeps dev and production agreeing on which routes are protected (see the Testing guide's Vite + Vitest configuration section for the full wrangler.jsonc/vite.config.ts/vitest.config.ts pairing).

During vite dev, visiting a protected route redirects the browser to a login form the plugin serves at loginPath (default /cdn-cgi/access/login). Submitting it mints a dev-signed JWT and hands you back to your app, now authenticated. The plugin also serves /cdn-cgi/access/logout and /cdn-cgi/access/get-identity, mirroring the real Access edge endpoints.

If your policies use path-specific audiences, the plugin's login form issues one session token whose aud claim covers every audience referenced across policies — so a single local sign-in can still traverse every role-specific page, exactly like a real Cloudflare Access session that's been granted access to several applications. A session token that doesn't carry the audience a given path requires is treated as unauthenticated for that specific request (redirected to the login form, or 401 for an API route), the same way cloudflareAccess would reject it in production.

By default the login form is a free-text email box. Supply users to pick from named accounts instead:

ts
cloudflareAccessPlugin({
  policies: authPolicies,
  users: [
    { email: "alice@example.com", name: "Alice (admin)" },
    { email: "bob@example.com", name: "Bob (read-only)" }
  ]
});

The remaining CloudflareAccessPluginOptions:

OptionDefaultPurpose
devSecretpublic dev keyMust match the Worker's devSecret if you overrode it.
users(free-text email)Selectable DevLoginUser identities on the login form.
loginPath/cdn-cgi/access/loginPathname for the login form.
tokenLifetime86400 (24 h)Dev JWT lifetime, in seconds.

Security hardening

Both halves default to safe behavior, but a production deployment and a local dev session want opposite settings for a couple of options. Configure them explicitly.

In production

  • Set audience to your Access application's Audience (AUD) Tag — found on the Access Application's Overview tab. Every Access app on your team shares one JWKS, so without the aud check a token minted for any app on the team is accepted here too.
  • Keep enableDevTokens statically false. Gate it on a build-time signal (import.meta.env.DEV), never a runtime env var — a deployed Worker with dev tokens on would trust a forgeable HS256 token signed with the public DEFAULT_DEV_SECRET. false is the default precisely so this fails closed.
  • Provide the team domain via the CLOUDFLARE_TEAM_DOMAIN binding (the Getting Started wrangler.jsonc pattern) — cloudflareAccess reads it at request time to fetch the JWKS — or pass teamDomain explicitly.
ts
app.use(
  cloudflareAccess({
    policies: authPolicies,
    audience: "4714c1358e65fe4b21c711123456effd",
    enableDevTokens: import.meta.env.DEV // statically false once bundled for production
  })
);

In local development

  • Set enableDevTokens: import.meta.env.DEV so the Worker accepts the dev-signed tokens the Vite plugin (and /testing) produce. Leaving devSecret unset uses the public dev key and logs a one-time warning — fine on localhost.
  • Match devSecret on both sides only if you override the default: the value passed to cloudflareAccess and to cloudflareAccessPlugin must be identical, or locally-minted tokens won't verify.

Diagnostics

Pass a logger (a Logger) to surface the warnings above plus per-request debug output. It defaults to silent, so diagnostics are opt-in (see Logging):

ts
import { createLogger, createConsoleTransport } from "@adrianhall/cloudflare-toolkit/logging";

app.use(
  cloudflareAccess({
    policies: authPolicies,
    logger: createLogger({ level: "warn", transport: createConsoleTransport() })
  })
);

Beyond the browser: testing

cloudflareAccessPlugin emulates the browser login flow for a human clicking around in vite dev. For Vitest, /testing's signDevJwt signs a token directly — no Vite server involved — so you can call your Worker's fetch handler with a ready-made JWT_HEADER:

ts
import { signDevJwt, JWT_HEADER } from "@adrianhall/cloudflare-toolkit/testing";

const token = await signDevJwt("alice@example.com");
const res = await app.fetch(
  new Request("http://localhost/api/me", { headers: { [JWT_HEADER]: token } }),
  env
);

Pass audience to exercise a path-specific audience policy directly, without a real Cloudflare Access deployment:

ts
const contributorToken = await signDevJwt("alice@example.com", { audience: contributorAud });

Both paths require enableDevTokens on the Worker for the tokens they produce to be accepted. See Testing for more details.

See also

  • Error Handling — the RFC 9457 shape of every 401 this middleware returns.
  • Logging — the Logger you hand to cloudflareAccess for its diagnostics.
  • TestingsignDevJwt, buildCookieHeader, and clearCookieHeader for asserting against Access-protected routes in Vitest, plus the full wrangler.jsonc + vite.config.ts + vitest.config.ts pairing that keeps npm run dev and npm run test agreeing on the same Worker.