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 duringvite 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.
| Step | Production | Local vite dev |
|---|---|---|
| Authenticates the user | Cloudflare Access (edge) | cloudflareAccessPlugin login form |
| Signs the JWT | Cloudflare Access | cloudflareAccessPlugin (dev key) |
| Injects the request header | Cloudflare Access (edge) | cloudflareAccessPlugin (connect) |
| Verifies the JWT | cloudflareAccess (JWKS) | cloudflareAccess (dev key) |
| Your handler code | unchanged | unchanged |
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:
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:
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"))); // protectedThe AuthVariables (and the combined CloudflareToolkitVariables) provides Cloudflare_Access_Identity within the Hono context:
email— the JWTemailclaim.sub— the JWTsubclaim, a stable per-user identifier ideal for authorization.source—"header"forCf-Access-Jwt-Assertionor"cookie"forCF_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 returns401.
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,AuthVariablesare still set.
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:
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:
// 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:
cloudflareAccessPlugin({
policies: authPolicies,
users: [
{ email: "alice@example.com", name: "Alice (admin)" },
{ email: "bob@example.com", name: "Bob (read-only)" }
]
});The remaining CloudflareAccessPluginOptions:
| Option | Default | Purpose |
|---|---|---|
devSecret | public dev key | Must match the Worker's devSecret if you overrode it. |
users | (free-text email) | Selectable DevLoginUser identities on the login form. |
loginPath | /cdn-cgi/access/login | Pathname for the login form. |
tokenLifetime | 86400 (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
audienceto 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 theaudcheck a token minted for any app on the team is accepted here too. - Keep
enableDevTokensstaticallyfalse. 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 publicDEFAULT_DEV_SECRET.falseis the default precisely so this fails closed. - Provide the team domain via the
CLOUDFLARE_TEAM_DOMAINbinding (the Getting Startedwrangler.jsoncpattern) —cloudflareAccessreads it at request time to fetch the JWKS — or passteamDomainexplicitly.
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.DEVso the Worker accepts the dev-signed tokens the Vite plugin (and/testing) produce. LeavingdevSecretunset uses the public dev key and logs a one-time warning — fine on localhost. - Match
devSecreton both sides only if you override the default: the value passed tocloudflareAccessand tocloudflareAccessPluginmust 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):
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:
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:
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
401this middleware returns. - Logging — the
Loggeryou hand tocloudflareAccessfor its diagnostics. - Testing —
signDevJwt,buildCookieHeader, andclearCookieHeaderfor asserting against Access-protected routes in Vitest, plus the fullwrangler.jsonc+vite.config.ts+vitest.config.tspairing that keepsnpm run devandnpm run testagreeing on the same Worker.