Now liveThe Skillselion MCP - thousands of ranked skills, loaded into your agent mid-task. No install.Get it →
adrianhall avatar

Cloudflare Auth

  • 16 installs
  • Updated June 30, 2026
  • adrianhall/cloudflare-auth

Ship a Hono Cloudflare Worker behind Cloudflare Access with the same auth behavior in production JWT validation and local dev.

About

Cloudflare Auth is an integration skill for solo builders shipping Hono-based Cloudflare Workers that sit behind Cloudflare Access. Production requests arrive with signed JWT headers and the CF_Authorization cookie; local dev has none, which usually forces skipping auth or awkward proxies. The @adrianhall/cloudflare-auth package documents two middleware functions—developerAuthentication for local development and cloudflareAccess for production—that expose the same user identity so route handlers stay identical across environments. Load it when you are building a protected API, serving protected static frontend assets through a Worker, splitting public and protected paths, or debugging missing cookies before API calls. It pairs operational detail on wrangler.jsonc with the core problem statement: one auth story from laptop to edge.

  • developerAuthentication middleware for frictionless local dev without Cloudflare Access in the loop
  • cloudflareAccess middleware for production JWT validation via Access-injected headers and CF_Authorization
  • Environment-agnostic handlers: both middleware paths agree on the authenticated user shape
  • Guidance for path-based policies (public vs protected routes) and static assets served through the Worker
  • wrangler.jsonc configuration patterns when auth and static assets share one Worker

Cloudflare Auth by the numbers

  • 16 all-time installs (skills.sh)
  • Ranked #827 of 1,039 Cloud & Infrastructure skills by installs in the Skillselion catalog
  • Data as of Jul 28, 2026 (Skillselion catalog sync)
npx skills add https://github.com/adrianhall/cloudflare-auth --skill cloudflare-auth

Add your badge

Show developers this skill is listed on Skillselion. Paste this into your README.

Listed on Skillselion
Installs16
Last updatedJune 30, 2026
Repositoryadrianhall/cloudflare-auth

What it does

Ship a Hono Cloudflare Worker behind Cloudflare Access with the same auth behavior in production JWT validation and local dev.

Files

SKILL.mdMarkdownGitHub ↗

When to Load This Skill

Load this skill whenever a developer is:

  • Building a Hono Worker that will be deployed behind Cloudflare Access
  • Asking how to do local development against a Cloudflare Access-protected API
  • Serving protected static assets (frontend) through a Worker (not via wrangler's asset serving alone)
  • Setting up path-based auth policies (some routes public, some protected)
  • Asking why CF_Authorization cookie is not being set before API calls
  • Configuring wrangler.jsonc for a Worker that handles both auth and static assets
  • Provisioning Cloudflare Access infrastructure with Terraform (applications, policies, IdP linking)

---

The Core Problem This Library Solves

In production, Cloudflare Access injects signed JWT headers and the CF_Authorization cookie into every request before they reach your Worker. During local development, those headers are absent — there is no Cloudflare Access in the loop.

Without this library, developers either skip auth entirely in dev (risky) or run complex local proxies.

This library solves it with two middleware functions that always agree on the authenticated user:

MiddlewareProductionLocal dev
developerAuthenticationNo-op (JWT header already present)Drives a one-time-PIN–style login form, sets CF_Authorization cookie
cloudflareAccessValidates JWT via Cloudflare Access JWKSValidates the same dev-signed JWT via HMAC

---

Installation

This package is not published to npm. Install directly from GitHub (dist/ is committed):

npm install github:adrianhall/cloudflare-auth#1.2.0 hono
# or
pnpm add github:adrianhall/cloudflare-auth#1.2.0 hono

Peer dependency: hono ^4.0.0 Runtime dependency: jose ^6.2.3 (bundled)

---

Working Example

The repository includes a complete diagnostic app in `example/` — a React SPA + Hono API built with the Cloudflare Vite plugin. It exercises every setup pattern documented here (public/protected routes, cookie flow, curl access, production deployment).

The configuration recommendations in this skill were determined empirically using the experiments documented in `example/docs/MANUAL_TESTS.md`. When in doubt about a recommendation, refer to the experiment that produced it.

---

Critical Setup Rules

1. Middleware Order Is Non-Negotiable

developerAuthentication must always be registered before cloudflareAccess. In production developerAuthentication is a no-op, but in dev it injects the headers that cloudflareAccess then reads.

// CORRECT
app.use(developerAuthentication({ policies }));
app.use(cloudflareAccess({ policies }));

// WRONG — cloudflareAccess will 401 every dev request
app.use(cloudflareAccess({ policies }));
app.use(developerAuthentication({ policies }));

2. Share the Same Policies Array

Define PathPolicy[] once and pass the identical array to both middleware. If the arrays differ, you will get inconsistent behavior where one middleware allows a path the other blocks.

const authPolicies: PathPolicy[] = [
  { pattern: /^\/api\/version$/, authenticate: false },
  { pattern: /^\/api\//, authenticate: true, redirect: false }, // API: 401 in dev
  { pattern: /^\/dashboard/, authenticate: true } // Pages: redirect to login in dev
];

app.use(developerAuthentication({ policies: authPolicies }));
app.use(cloudflareAccess({ policies: authPolicies }));

The redirect property only affects developerAuthentication (cloudflareAccess always returns 401). Use redirect: false for API routes so unauthenticated requests get a 401 JSON response in local development, matching production behaviour.

3. Never Add /_auth/* to authPolicies

developerAuthentication owns /_auth/login and /_auth/callback internally. These internal routes are handled after the policy check in the middleware's evaluation order. If /_auth/* appears in authPolicies with authenticate: false, the policy check fires first, calls next(), and the internal login-form handler is never reached — the browser gets a 404.

// WRONG — policy fires before the internal login form handler; login returns 404
const authPolicies: PathPolicy[] = [
  { pattern: /^\/_auth\//, authenticate: false }, // ← never do this
  { pattern: /^\/api\//, authenticate: true }
];

// CORRECT — omit /_auth/* entirely; developerAuthentication handles it automatically
const authPolicies: PathPolicy[] = [{ pattern: /^\/api\//, authenticate: true }];

This also means you should not add /_auth/* to run_worker_first in a way that conflicts — the middleware chain must reach developerAuthentication for login routes to work.

4. Use run_worker_first: true — All Requests Must Flow Through the Worker

This is the most commonly missed setup decision.

If your frontend needs the CF_Authorization cookie to exist before it makes API calls, you must configure run_worker_first: true so that every request — including the initial page load — goes through the Worker and its middleware chain.

Without run_worker_first: true, the Cloudflare asset layer serves the page and all static assets directly, bypassing the Worker entirely. developerAuthentication never runs, the cookie is never set, and the React app's first API call fails silently (the 302 redirect to /_auth/login is swallowed by fetch() following the redirect into login-page HTML).

Why `binding: "ASSETS"` alone is not enough: The binding setting only makes env.ASSETS available to your Worker code — it does not change routing. Without run_worker_first: true, navigation requests and static assets still bypass the Worker regardless of whether the binding exists.

Wrong (assets bypass auth middleware):

// wrangler.jsonc — binding alone does NOT route requests through the Worker
{
  "assets": {
    "binding": "ASSETS",
    "not_found_handling": "single-page-application"
  }
}

Also wrong (selective routing misses the initial page load):

// wrangler.jsonc — page load bypasses the Worker; cookie is never set
{
  "assets": {
    "not_found_handling": "single-page-application",
    "run_worker_first": ["/api/*", "/_auth/*"]
  }
}

Correct:

// wrangler.jsonc — ALL requests go through the Worker.
// "binding": "ASSETS" lets the Worker serve static files via c.env.ASSETS.fetch().
// "not_found_handling": "single-page-application" enables client-side routing
// (direct navigation to /dashboard returns index.html instead of 404).
{
  "assets": {
    "binding": "ASSETS",
    "not_found_handling": "single-page-application",
    "run_worker_first": true
  }
}
// index.ts — middleware + catch-all for assets
app.use(developerAuthentication({ policies: authPolicies }));
app.use(cloudflareAccess({ policies: authPolicies }));

app.get("/api/me", (c) => c.json({ email: c.get("userEmail") }));

// Final catch-all: proxy to the Worker Assets binding
app.get("*", (c) => c.env.ASSETS.fetch(c.req.raw));
Do not use `serveStatic` from `hono/cloudflare-workers` here. That adapter reads
c.env.__STATIC_CONTENT — the KV namespace used by the legacy Workers Sites system.
With the current assets.binding wrangler config, __STATIC_CONTENT is undefined
and every asset request returns 404. Use c.env.ASSETS.fetch(c.req.raw) instead.
Production with Cloudflare Access: In production, CF Access sets the cookie and
JWT header at the edge for ALL requests before they reach the Worker, so
run_worker_first: true is technically optional. However, using true in both dev
and production avoids maintaining separate configs and ensures the middleware chain
always runs.

5. Set CLOUDFLARE_TEAM_DOMAIN in Wrangler Vars

cloudflareAccess reads c.env.CLOUDFLARE_TEAM_DOMAIN at request time to fetch the Cloudflare Access JWKS for JWT validation. If this variable is missing or incorrect, all production CF Access JWTs will fail verification — the middleware falls back to HMAC (dev tokens only) and rejects the RS256-signed production token.

// wrangler.jsonc
{
  "vars": {
    "CLOUDFLARE_TEAM_DOMAIN": "myteam.cloudflareaccess.com"
  }
}

The variable name must be exactly CLOUDFLARE_TEAM_DOMAIN unless you override it via the teamDomain option:

// Default: reads from c.env.CLOUDFLARE_TEAM_DOMAIN
app.use(cloudflareAccess({ policies: authPolicies }));

// Explicit override (if using a different env var name):
app.use(cloudflareAccess({ policies: authPolicies, teamDomain: c.env.MY_TEAM_DOMAIN }));

6. Register Middleware Directly — Do Not Wrap in Arrow Functions

Coding LLMs sometimes generate a wrapper pattern like this:

// WRONG — unnecessary wrapper obscures the middleware and can break types
app.use((c, next) => developerAuthentication({ policies })(c, next));
app.use((c, next) => cloudflareAccess({ policies })(c, next));

This creates a new middleware instance on every request (re-evaluating the settings object each time) and obscures Hono's type inference. It also masks the real return type from TypeScript, hiding type errors that would catch misconfiguration.

// CORRECT — register the middleware directly
app.use(developerAuthentication({ policies: authPolicies }));
app.use(cloudflareAccess({ policies: authPolicies }));

The middleware factories return a MiddlewareHandler — pass it directly to app.use().

---

Minimal Working Example

import { Hono } from "hono";
import {
  developerAuthentication,
  cloudflareAccess,
  type AuthVariables,
  type PathPolicy
} from "@adrianhall/cloudflare-auth";

// Generate wrangler types with: npx wrangler types
// (run after adding "binding": "ASSETS" to wrangler.jsonc)
type Env = {
  Bindings: {
    CLOUDFLARE_TEAM_DOMAIN: string;
    ASSETS: Fetcher; // Worker Assets binding — requires "binding": "ASSETS" in wrangler.jsonc
  };
  Variables: AuthVariables;
};

const app = new Hono<Env>();

const authPolicies: PathPolicy[] = [
  { pattern: /^\/api\/version$/, authenticate: false }, // public
  { pattern: /^\/api\//, authenticate: true, redirect: false } // protected API — 401 in dev
  // ⚠ Never add /_auth/* here — developerAuthentication owns those paths internally
];

// Order matters: developerAuthentication FIRST
app.use(developerAuthentication({ policies: authPolicies }));
app.use(cloudflareAccess({ policies: authPolicies }));

app.get("/api/me", (c) => {
  return c.json({ email: c.get("userEmail"), sub: c.get("userSub") });
});

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

// Final catch-all: proxy to Worker Assets binding (do NOT use serveStatic here)
app.get("*", (c) => c.env.ASSETS.fetch(c.req.raw));

export default app;

---

TypeScript Types

AuthVariables — Hono context variables

type AuthVariables = {
  userEmail: string; // JWT "email" claim
  userSub: string; // JWT "sub" claim (unique identifier)
};

Wire into Hono's generic so c.get("userEmail") is fully typed:

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

PathPolicy

interface PathPolicy {
  pattern: RegExp; // tested against request pathname
  authenticate: boolean; // true = require auth, false = public bypass
  redirect?: boolean; // true (default) = 302 to login, false = 401 JSON
}

Policies are evaluated in first-match-wins order.

The optional redirect property controls how developerAuthentication responds to unauthenticated requests on protected paths:

  • true _(default)_ -- 302 redirect to the login form. Use for page routes.
  • false -- 401 JSON { error: "Authentication required" }. Use for API

routes so local dev matches production (cloudflareAccess always returns 401).

cloudflareAccess ignores this property.

DeveloperAuthSettings

PropertyTypeDefaultDescription
policiesPathPolicy[]undefined (all paths require auth)Path matching rules
loginPathstring"/_auth/login"Login form route
callbackPathstring"/_auth/callback"Callback route
devSecretstringBuilt-in dev keyHMAC secret for signing dev JWTs
tokenLifetimenumber86400 (24 h)JWT lifetime in seconds
loggerLoggerConsole loggerCustom logger instance

CloudflareAccessSettings

PropertyTypeDefaultDescription
policiesPathPolicy[]undefinedPath matching rules
defaultAction`"block" \"bypass"`"block"
teamDomainstringc.env.CLOUDFLARE_TEAM_DOMAINCloudflare Access team domain
audiencestringundefined (skip check)Expected aud claim value
devSecretstringBuilt-in dev keyHMAC secret for verifying dev JWTs
loggerLoggerConsole loggerCustom logger instance

---

defaultAction for cloudflareAccess

Controls what happens when a request path matches no policy:

  • `"block"` (default) — treat as protected; return 401 if no valid JWT.
  • `"bypass"` — allow through. If a valid JWT is present, context vars are still set; otherwise the request proceeds with no authenticated user. Handlers can check c.get("userEmail") to detect the unauthenticated case.

---

Environment Variables

VariableRequiredDescription
CLOUDFLARE_TEAM_DOMAINYes (production)Your Cloudflare Access team domain, e.g. myteam.cloudflareaccess.com. Used to fetch the JWKS for JWT validation.
CLOUDFLARE_IDP_IDTerraform onlyUUID of the Identity Provider in Zero Trust. Used in Terraform to create IdP-linked Access policies. Not read by the Worker at runtime.

Set via wrangler config:

{
  "vars": {
    "CLOUDFLARE_TEAM_DOMAIN": "myteam.cloudflareaccess.com"
  }
}

---

How Each Middleware Handles Requests

developerAuthentication request flow

Incoming request
  │
  ├── cf-access-jwt-assertion header present? → no-op, next()    ← production path
  ├── Policy matches authenticate:false?      → next()            ← ⚠ /_auth/* here = 404 on login form
  ├── GET /_auth/login                        → render login form ← never reached if /_auth/* is in policies
  ├── POST /_auth/callback                    → sign dev JWT, set CF_Authorization cookie, redirect
  ├── CF_Authorization cookie valid?          → inject CF headers, next()
  ├── CF_Authorization cookie invalid/expired →
  │     redirect: true  (default)             → clear cookie, redirect to login
  │     redirect: false                       → clear cookie, 401 JSON
  └── No auth at all →
        redirect: true  (default)             → redirect to /_auth/login?redirect=<pathname>
        redirect: false                       → 401 JSON { error: "Authentication required" }

cloudflareAccess JWT verification order

1. Try HMAC verification with the dev secret (fast, no network) 2. If that fails, verify against the Cloudflare Access JWKS endpoint

Policy matchJWT valid?Result
authenticate: falseanyBypass
authenticate: trueyesSet userEmail/userSub, next()
authenticate: trueno/missing401
No match, defaultAction: "block"yesSet context vars, next()
No match, defaultAction: "block"no/missing401
No match, defaultAction: "bypass"yesSet context vars, next()
No match, defaultAction: "bypass"no/missingnext() (no user set)

---

Cookie & Header Reference

NameTypeDescription
CF_AuthorizationCookieJWT. Set by Cloudflare Access in production (not HttpOnly), by developerAuthentication in dev (HttpOnly, Secure, SameSite=Lax).
Cf-Access-Jwt-AssertionHeaderSame JWT. Set by Cloudflare Access in production, injected by developerAuthentication in dev. Read by cloudflareAccess.
Cf-Access-Authenticated-User-EmailHeaderUser email. Set by Cloudflare Access in production, injected by developerAuthentication in dev.
Cf-Access-UserHeaderUnique user identifier. Only injected by `developerAuthentication` in dev. Cloudflare Access does NOT set this header — the sub claim is extracted from the JWT by cloudflareAccess middleware instead.

---

Wrangler Configuration

Recommended config (React SPA + Hono API + auth)

All three settings in the assets block are required:

  • "run_worker_first": true — routes every request through the Worker so developerAuthentication can set the cookie on the initial page load. Without this, the page loads directly from the asset layer and the cookie is never set.
  • "binding": "ASSETS" — gives the Worker access to the static assets via c.env.ASSETS.fetch(). Without this, the catch-all route cannot serve the SPA.
  • "not_found_handling": "single-page-application" — returns index.html for paths that don't match a static file (needed for client-side routing, e.g. React Router).
{
  "name": "my-worker",
  "main": "src/index.ts",
  "compatibility_date": "2025-01-01",
  "vars": {
    "CLOUDFLARE_TEAM_DOMAIN": "myteam.cloudflareaccess.com"
  },
  "assets": {
    "binding": "ASSETS",
    "not_found_handling": "single-page-application",
    "run_worker_first": true
  }
}
Note for the Cloudflare Vite plugin: When using @cloudflare/vite-plugin, the
assets.directory field is not needed — the plugin points it to the client build
output automatically.

---

Cloudflare Access Terraform Configuration

When provisioning the Cloudflare Access application and policies with Terraform, use the v5 provider (cloudflare/cloudflare ~> 5.0) together with the jrhouston/dotenv provider to read credentials from .env.

Critical Rules

1. Use v5 Resource Names

The v5 provider renamed all Zero Trust resources. Using v4 names causes terraform apply to fail with "resource type not found".

v4 name (wrong)v5 name (correct)
cloudflare_access_applicationcloudflare_zero_trust_access_application
cloudflare_access_policycloudflare_zero_trust_access_policy
2. Policies Are Standalone Resources — Never Embedded

LLMs frequently try to embed policy decision/include blocks directly inside the application resource. This does not work in v5. Policies must be separate cloudflare_zero_trust_access_policy resources, and the application references them by ID with a numeric precedence.

# WRONG — policy embedded inline in the application block (v4 pattern, broken in v5)
resource "cloudflare_zero_trust_access_application" "app" {
  account_id = local.account_id
  domain     = "${local.worker_name}.${local.workers_domain}"
  type       = "self_hosted"
  policies = [{
    decision = "allow"
    include  = [{ login_method = { id = local.idp_id } }]
  }]
}

# CORRECT — standalone policy resource, linked to the application by ID
resource "cloudflare_zero_trust_access_policy" "allow_idp" {
  account_id = local.account_id
  name       = "${local.worker_name} - Allow IdP users"
  decision   = "allow"
  include = [{
    login_method = {
      id = local.idp_id
    }
  }]
}

resource "cloudflare_zero_trust_access_application" "app" {
  account_id                = local.account_id
  name                      = local.worker_name
  domain                    = "${local.worker_name}.${local.workers_domain}"
  type                      = "self_hosted"
  session_duration          = "24h"
  allowed_idps              = [local.idp_id]
  auto_redirect_to_identity = true
  policies = [{
    id         = cloudflare_zero_trust_access_policy.allow_idp.id
    precedence = 1
  }]
}

Complete Terraform Example

`terraform.tf`

terraform {
  required_version = ">= 1.0"

  required_providers {
    cloudflare = {
      source  = "cloudflare/cloudflare"
      version = "~> 5.0"
    }
    dotenv = {
      source  = "jrhouston/dotenv"
      version = "~> 1.0"
    }
  }
}

`main.tf`

data "dotenv" "env" {
  filename = "../.env"   # path relative to the infra/ working directory
}

locals {
  account_id     = data.dotenv.env.env.CLOUDFLARE_ACCOUNT_ID
  worker_name    = data.dotenv.env.env.TF_VAR_worker_name
  team_domain    = data.dotenv.env.env.CLOUDFLARE_TEAM_DOMAIN
  idp_id         = data.dotenv.env.env.CLOUDFLARE_IDP_ID
  workers_domain = data.dotenv.env.env.CLOUDFLARE_WORKERS_DOMAIN
}

provider "cloudflare" {
  api_token = data.dotenv.env.env.CLOUDFLARE_API_TOKEN
}

# Worker registration — Wrangler handles code deployment separately
resource "cloudflare_worker" "app" {
  account_id = local.account_id
  name       = local.worker_name
}

# Standalone Access policy — must NOT be embedded inside the application block
resource "cloudflare_zero_trust_access_policy" "allow_idp" {
  account_id = local.account_id
  name       = "${local.worker_name} - Allow IdP users"
  decision   = "allow"
  include = [{
    login_method = {
      id = local.idp_id
    }
  }]
}

# Access application — links to the policy by ID
resource "cloudflare_zero_trust_access_application" "app" {
  account_id                = local.account_id
  name                      = local.worker_name
  domain                    = "${local.worker_name}.${local.workers_domain}"
  type                      = "self_hosted"
  session_duration          = "24h"
  allowed_idps              = [local.idp_id]
  auto_redirect_to_identity = true
  policies = [{
    id         = cloudflare_zero_trust_access_policy.allow_idp.id
    precedence = 1
  }]
}

`.env.example`

CLOUDFLARE_ACCOUNT_ID=          # 32-char hex account ID
CLOUDFLARE_API_TOKEN=           # Account API token (cfat_...)
CLOUDFLARE_WORKERS_DOMAIN=      # e.g. yoursubdomain.workers.dev
CLOUDFLARE_TEAM_DOMAIN=         # e.g. your-org.cloudflareaccess.com
CLOUDFLARE_IDP_ID=              # UUID from Zero Trust → Integrations → Identity Providers
TF_VAR_worker_name=             # Logical name prefix for all resources
Finding `CLOUDFLARE_IDP_ID`: In the Cloudflare Zero Trust dashboard, navigate to Integrations → Identity Providers, select your IdP, and copy the UUID from the URL.

---

Testing

Why signDevJwt() is the right tool for integration and E2E tests

The login form flow (GET /_auth/loginPOST /_auth/callback) is the right flow to test _once_ to verify the login UI works. It is the wrong tool for testing your API handlers under different auth configurations, because:

  • It adds multi-step ceremony to every test case
  • It can only represent one user identity per flow execution
  • It tests infrastructure, not your business logic

signDevJwt() lets you mint a valid JWT for any identity in a single await. cloudflareAccess accepts it identically to a cookie-issued token. You can test admin users, regular users, unauthenticated paths, and expired tokens all in the same test file without any browser or form interaction.

signDevJwt() signature

signDevJwt(
  email: string,
  options?: {
    secret?: string;   // default: DEFAULT_DEV_SECRET
    lifetime?: number; // default: 86400 (24 h), in seconds
  }
): Promise<string>

Derived claims — these are set automatically and cannot be overridden:

ClaimValue
sub"dev-" + email
iss"dev-authentication"
type"dev"

So for signDevJwt("alice@example.com"), c.get("userSub") in your handler will be "dev-alice@example.com".

Injecting the token

Pass the signed token as the Cf-Access-Jwt-Assertion header. developerAuthentication treats this as the production path (no-op) and cloudflareAccess validates it via HMAC — no network call, no login redirect.

const token = await signDevJwt("alice@example.com");

const res = await app.fetch(
  new Request("http://localhost/api/me", {
    headers: { "cf-access-jwt-assertion": token }
  }),
  env
);

Use the exported JWT_HEADER constant instead of a raw string to stay in sync with the library:

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

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

Vitest integration test example

import { describe, it, expect } from "vitest";
import { Hono } from "hono";
import {
  developerAuthentication,
  cloudflareAccess,
  type AuthVariables,
  type PathPolicy
} from "@adrianhall/cloudflare-auth";
import { signDevJwt, JWT_HEADER } from "@adrianhall/cloudflare-auth/testing";

const MOCK_ENV = { CLOUDFLARE_TEAM_DOMAIN: "test.cloudflareaccess.com" };

const authPolicies: PathPolicy[] = [
  { pattern: /^\/api\/version$/, authenticate: false },
  { pattern: /^\/api\//, authenticate: true, redirect: false } // API routes return 401, not redirect
];

function createApp() {
  const app = new Hono<{ Bindings: typeof MOCK_ENV; Variables: AuthVariables }>();
  app.use(developerAuthentication({ policies: authPolicies }));
  app.use(cloudflareAccess({ policies: authPolicies }));
  app.get("/api/me", (c) => c.json({ email: c.get("userEmail"), sub: c.get("userSub") }));
  app.get("/api/version", (c) => c.json({ version: "1.0" }));
  return app;
}

describe("API auth", () => {
  it("returns 401 on a protected route with no token", async () => {
    const app = createApp();
    // redirect: false → developerAuthentication returns 401 JSON (not 302)
    const res = await app.fetch(new Request("http://localhost/api/me"), MOCK_ENV);
    expect(res.status).toBe(401);
  });

  it("returns the authenticated user for a valid token", async () => {
    const app = createApp();
    const token = await signDevJwt("alice@example.com");

    const res = await app.fetch(
      new Request("http://localhost/api/me", {
        headers: { [JWT_HEADER]: token }
      }),
      MOCK_ENV
    );

    expect(res.status).toBe(200);
    const body = (await res.json()) as { email: string; sub: string };
    expect(body.email).toBe("alice@example.com");
    expect(body.sub).toBe("dev-alice@example.com");
  });

  it("allows anonymous access to a public route", async () => {
    const app = createApp();
    const res = await app.fetch(new Request("http://localhost/api/version"), MOCK_ENV);
    expect(res.status).toBe(200);
  });

  it("rejects an expired token", async () => {
    const app = createApp();
    // lifetime: 0 produces a token that is expired the moment it is signed
    const token = await signDevJwt("alice@example.com", { lifetime: 0 });

    const res = await app.fetch(
      new Request("http://localhost/api/me", {
        headers: { [JWT_HEADER]: token }
      }),
      MOCK_ENV
    );

    expect(res.status).toBe(401); // redirect: false → 401 instead of 302
  });

  it("can test different roles or identities in the same suite", async () => {
    const app = createApp();

    for (const email of ["admin@example.com", "viewer@example.com", "guest@example.com"]) {
      const token = await signDevJwt(email);
      const res = await app.fetch(
        new Request("http://localhost/api/me", { headers: { [JWT_HEADER]: token } }),
        MOCK_ENV
      );
      expect(res.status).toBe(200);
      const body = (await res.json()) as { email: string };
      expect(body.email).toBe(email);
    }
  });
});

Playwright E2E test example

For browser-level tests, inject the token as an extra HTTP header on the Playwright request context. Every request the page makes — including fetch() calls from your frontend JavaScript — will carry the header, so the Worker sees an authenticated user from the very first request.

import { test, expect } from "@playwright/test";
import { signDevJwt, JWT_HEADER } from "@adrianhall/cloudflare-auth/testing";

test("authenticated dashboard loads", async ({ browser }) => {
  const token = await signDevJwt("alice@example.com");

  // Create an isolated browser context that sends the JWT on every request.
  const context = await browser.newContext({
    extraHTTPHeaders: { [JWT_HEADER]: token }
  });

  const page = await context.newPage();
  await page.goto("http://localhost:8787/dashboard");

  await expect(page.getByText("alice@example.com")).toBeVisible();
  await context.close();
});

test("admin sees controls that viewer does not", async ({ browser }) => {
  const adminToken = await signDevJwt("admin@example.com");
  const viewerToken = await signDevJwt("viewer@example.com");

  // Admin view
  const adminContext = await browser.newContext({
    extraHTTPHeaders: { [JWT_HEADER]: adminToken }
  });
  const adminPage = await adminContext.newPage();
  await adminPage.goto("http://localhost:8787/dashboard");
  await expect(adminPage.getByRole("button", { name: "Delete" })).toBeVisible();
  await adminContext.close();

  // Viewer view — same test, different identity, no login flow
  const viewerContext = await browser.newContext({
    extraHTTPHeaders: { [JWT_HEADER]: viewerToken }
  });
  const viewerPage = await viewerContext.newPage();
  await viewerPage.goto("http://localhost:8787/dashboard");
  await expect(viewerPage.getByRole("button", { name: "Delete" })).not.toBeVisible();
  await viewerContext.close();
});

Custom secret for test isolation (optional)

By default, signDevJwt and cloudflareAccess both use DEFAULT_DEV_SECRET. This is fine for local development but means a token signed in one test suite is accepted by another app using the default secret.

To isolate test suites, pass a custom devSecret consistently to both:

const TEST_SECRET = "my-test-suite-secret";

const token = await signDevJwt("alice@example.com", { secret: TEST_SECRET });

app.use(cloudflareAccess({ policies, devSecret: TEST_SECRET }));

---

Anti-Patterns

Anti-patternProblemFix
cloudflareAccess registered before developerAuthenticationIn dev, cloudflareAccess sees no JWT and returns 401 before developerAuthentication can inject headersAlways register developerAuthentication first
Different policies arrays for each middlewareAuth behavior is inconsistent between themDefine one PathPolicy[] and pass it to both
Adding { pattern: /^\/_auth\//, authenticate: false } to authPoliciesPolicy check fires before internal login-form handling; next() is called and the login form is never served — browser gets 404Do not add /_auth/* to policies. developerAuthentication owns those paths and requires no policy entry.
Missing run_worker_first: true in wrangler.jsoncPage loads bypass the Worker entirely. developerAuthentication never runs, the cookie is never set, and the React app's API calls fail silently — fetch() follows the 302 redirect into login-page HTMLAlways set "run_worker_first": true in the assets block
Using run_worker_first: ["/api/*", "/_auth/*"] instead of trueThe initial page load (GET /) bypasses the Worker. API calls reach the Worker, but the cookie was never set, so developerAuthentication redirects — and fetch() swallows the redirect silentlyUse run_worker_first: true (not selective patterns)
Using binding: "ASSETS" without run_worker_first: trueThe binding only makes env.ASSETS available to Worker code — it does not change routing. Page loads still bypass the WorkerAdd "run_worker_first": true alongside the binding
Missing binding: "ASSETS"Without the binding, the catch-all route c.env.ASSETS.fetch(c.req.raw) crashes with "Internal Server Error" because ASSETS is undefinedAdd "binding": "ASSETS" to the assets block
Using serveStatic from hono/cloudflare-workersserveStatic reads c.env.__STATIC_CONTENT (legacy Workers Sites KV). With assets.binding, __STATIC_CONTENT is undefined — all asset requests return 404Use app.get("*", (c) => c.env.ASSETS.fetch(c.req.raw))
Assuming Cf-Access-User header is set by Cloudflare AccessCF Access sets Cf-Access-Jwt-Assertion and Cf-Access-Authenticated-User-Email but does not set Cf-Access-User. The sub claim is extracted from the JWT by cloudflareAccess middlewareUse c.get("userSub") from context variables, not the header directly
Not setting CLOUDFLARE_TEAM_DOMAIN in productioncloudflareAccess cannot fetch the JWKS; all real Access JWTs fail verificationSet the var in wrangler.jsonc or via a secret
Not adding AuthVariables to the Hono genericc.get("userEmail") returns unknownnew Hono<{ Bindings: Env; Variables: AuthVariables }>()
Checking for the authenticated user on a authenticate: false pathc.get("userEmail") will be undefined on public paths — the middleware skips auth processing entirelyOnly access context vars on protected routes
Wrapping middleware in arrow functions: (c, next) => middleware()(c, next)Creates a new middleware instance on every request, obscures Hono's type inference, and masks type errors that would catch misconfiguration. Often generated by coding LLMs as a "type fix"Register middleware directly: app.use(developerAuthentication({ ... })). If TypeScript complains, the root cause is likely dual copies of hono (see installation notes) — fix the dependency, not the types
Using v4 Terraform resource names (cloudflare_access_application, cloudflare_access_policy)These resource types do not exist in the v5 provider; terraform apply fails immediately with "resource type not found"Rename to cloudflare_zero_trust_access_application and cloudflare_zero_trust_access_policy
Embedding policy decision/include blocks directly inside cloudflare_zero_trust_access_applicationInline policy blocks are a v4 pattern that is not supported in v5; Terraform will error or silently produce an application with no effective policyCreate a standalone cloudflare_zero_trust_access_policy resource, then reference it via policies = [{ id = <policy>.id, precedence = 1 }]
Omitting CLOUDFLARE_IDP_ID from .env and allowed_idps on the applicationAccess falls back to showing all configured identity providers instead of redirecting directly to the intended IdPAdd CLOUDFLARE_IDP_ID to .env, assign it to local.idp_id, and set both allowed_idps = [local.idp_id] on the application and login_method = { id = local.idp_id } in the policy's include block

---

Testing Utilities (@adrianhall/cloudflare-auth/testing)

Available for integration tests, E2E tests, and advanced flows:

import {
  signDevJwt, // Sign a dev JWT (email, options)
  buildCookieHeader, // Build a Set-Cookie header value for a JWT
  clearCookieHeader, // Build a Set-Cookie header that clears the cookie
  JWT_HEADER, // "cf-access-jwt-assertion"
  COOKIE_NAME // "CF_Authorization"
} from "@adrianhall/cloudflare-auth/testing";

All other internal utilities (matchPolicy, verifyDevJwt, verifyAccessJwt, parseCookie, DEFAULT_DEV_SECRET, EMAIL_HEADER, USER_HEADER) are not part of the public API.

Related skills

Cloud & Infrastructurebackendintegrations

This week in AI coding

Five minutes, every Monday - the tools, releases and tactics for developers.

unsubscribe anytime.