Every admin request to the photography portfolio goes through three checks before it can touch a D1 row or a Cloudflare Images API call: the Cloudflare Access edge proves identity, the Pages Function verifies the JWT Access minted, and the verifier rejects anything whose audience claim does not match this specific application.
This post is how that pipeline is built, why each layer is there, and the operational decisions around it.
Why the Worker verifies the JWT itself ¶
The instinct when you put Cloudflare Access in front of an origin is to trust the headers it forwards. Access mints a JWT, attaches it as Cf-Access-Jwt-Assertion, attaches the authenticated email as Cf-Access-Authenticated-User-Email, and forwards the request. If every request to your origin necessarily passes through Access, you can read the email header and call it a day.
The problem is the “if”. A Cloudflare Pages
project has more than one front door to its Pages Function. The custom domain is one. The canonical <project>.pages.dev is another. Per-deployment URLs like <commit-hash>.<project>.pages.dev are more. Each of those is its own hostname, and a Cloudflare Access application protects only the hostnames you have explicitly bound it to.
If the Function trusts the email header, anything that reaches it on an unprotected hostname can spoof identity. The Function has to do its own verification, on every request, regardless of which hostname the request arrived on.
That is what the verifier in functions/lib/access.ts
does.
The verifier ¶
jose
handles the cryptography. The Cloudflare Access docs on validating the JWT
describe the contract: every team has a JWKS at https://<team>.cloudflareaccess.com/cdn-cgi/access/certs, every JWT carries a kid referring to a key in that JWKS, every JWT must be RS256, and every JWT carries iss, aud, exp, and the application-specific claims (notably email).
The whole module is one file:
// functions/lib/access.ts
import {
createRemoteJWKSet,
jwtVerify,
type JWTVerifyGetKey,
} from "jose";
let testResolver: JWTVerifyGetKey | null = null;
const remoteJwksCache = new Map<string, JWTVerifyGetKey>();
let warnedMissingConfig = false;
export function __setJwksResolverForTests(fn: JWTVerifyGetKey | null): void {
testResolver = fn;
remoteJwksCache.clear();
}
function getConfig(env: Env): { teamDomain: string; aud: string } | null {
const teamDomain = (env.CF_ACCESS_TEAM_DOMAIN ?? "").trim();
const aud = (env.CF_ACCESS_AUD ?? "").trim();
if (!teamDomain || !aud) {
if (!warnedMissingConfig) {
console.warn(
"Access verifier: CF_ACCESS_TEAM_DOMAIN or CF_ACCESS_AUD not set; all admin auth will fail",
);
warnedMissingConfig = true;
}
return null;
}
return { teamDomain, aud };
}
function extractToken(request: Request): string | null {
const header = request.headers.get("Cf-Access-Jwt-Assertion");
if (header) return header;
const cookie = request.headers.get("Cookie");
if (!cookie) return null;
for (const part of cookie.split(/;\s*/)) {
const eq = part.indexOf("=");
if (eq === -1) continue;
if (part.slice(0, eq) === "CF_Authorization") {
return part.slice(eq + 1) || null;
}
}
return null;
}
function getResolver(teamDomain: string): JWTVerifyGetKey {
if (testResolver) return testResolver;
const cached = remoteJwksCache.get(teamDomain);
if (cached) return cached;
const url = new URL(`https://${teamDomain}/cdn-cgi/access/certs`);
const resolver = createRemoteJWKSet(url);
remoteJwksCache.set(teamDomain, resolver);
return resolver;
}
export async function verifyAccessJwt(
request: Request,
env: Env,
): Promise<string | null> {
const cfg = getConfig(env);
if (!cfg) return null;
const token = extractToken(request);
if (!token) return null;
try {
const { payload } = await jwtVerify(token, getResolver(cfg.teamDomain), {
issuer: `https://${cfg.teamDomain}`,
audience: cfg.aud,
});
const email = payload.email;
if (typeof email !== "string" || email.length === 0) return null;
return email;
} catch {
return null;
}
}
A few design choices in there are worth pulling out.
Read the JWT from header or cookie. Browser requests through Access carry the JWT in the CF_Authorization cookie. Server-to-server calls through Access carry it in the Cf-Access-Jwt-Assertion header. Supporting both means the same verifier works for the admin SPA’s fetch calls and for service-token clients.
Cache the JWKS resolver per team domain. jose.createRemoteJWKSet handles kid-keyed caching and refresh on miss internally. The Map around it just avoids constructing a new resolver per request. The cache lives at module scope, so it survives across requests within a single Worker isolate.
Fail closed on missing config. If either CF_ACCESS_TEAM_DOMAIN or CF_ACCESS_AUD is unset, the verifier returns null and every admin request 401s. A misconfigured deploy refuses traffic instead of silently downgrading. The one-time console.warn shows up in wrangler tail so the cause is obvious without leaking anything into the response.
Opaque failures. The catch-all returns null for every failure mode: bad signature, wrong issuer, wrong audience, expired, malformed, missing email, JWKS fetch error. From outside, every bypass attempt is indistinguishable from a normal unauthenticated request. No oracle.
The auth façade callers use is four lines on top of this:
// functions/lib/auth.ts
import { verifyAccessJwt } from "./access";
export async function requireUser(
request: Request,
env: Env,
): Promise<string | Response> {
const u = await verifyAccessJwt(request, env);
if (!u) return Response.json({ error: "Unauthorized" }, { status: 401 });
return u;
}
Every admin handler calls requireUser first. If the return is a Response, the handler returns it immediately; otherwise the string is the verified email and the handler proceeds.
The AUD claim is the bit that matters ¶
Every Access application has its own AUD tag
: a long hex string visible in the application’s Overview tab. The JWT issued by app A has aud: <A's tag>. The JWT issued by app B has aud: <B's tag>. Both are signed by the same team domain. Both pass signature verification. The audience option on jose.jwtVerify is what makes a token issued for app A unable to authenticate against app B.
For a single-domain project this seems redundant. For a multi-domain project it is the load-bearing check. The photography portfolio runs alex.edestudio.us
and jamie.edestudio.us
from the same codebase (previous post
), each behind its own Access application with its own AUD. Each Pages project sets its own CF_ACCESS_AUD secret. A token minted for alex cannot grant admin to jamie, because the verifier on jamie pinned to jamie’s AUD rejects it.
Without audience pinning, any Access application on the same team becomes a confused deputy
for every other one.
The edge layer: a second Access app for *.pages.dev ¶
JWT validation in the Worker is the durable layer. It survives a future code regression or a forgotten Access app. It also runs on every request, which means cheap requests still pay the verification cost, and the AI binding, D1 binding, and Images API still sit one missing requireUser call away in any future handler.
So the second layer is at the edge. The custom domain is behind an Access app already. The Pages project’s auto-generated *.pages.dev hostname (and its per-deployment preview URLs) is behind its own Access app, with the same identity policy. Two options work:
- Disable the pages.dev subdomain entirely in the Pages project settings. Hardest to undo by accident.
- Add a second Cloudflare Access application covering
*.<project>.pages.devwith the same policy as the custom domain.
I picked option 2 because preview deploys should be reachable for testing, just not reachable to the internet. The second app catches both the canonical pages.dev URL and per-deployment previews in one rule.
This is defense in depth, not redundancy. If someone disables one Access app in the dashboard, the JWT verifier in the Worker still rejects spoofed traffic. If the Worker code regresses on a future change, the edge Access app still rejects unauthenticated traffic. Either layer alone closes the gate; both layers together survive misconfiguration drift.
Testing the verifier ¶
The verifier is the kind of code where “I think it works” is not enough. The vitest suite mints real RS256 JWTs with a local keypair and feeds them through the verifier with a createLocalJWKSet injected via the test seam, so every test exercises real cryptography without touching the network.
The cases that matter:
config and extraction:
- returns null when CF_ACCESS_TEAM_DOMAIN is missing
- returns null when CF_ACCESS_AUD is missing
- returns null when neither header nor cookie is present
- returns null when only a non-Access cookie is present
JWT validation:
- returns null when JWKS does not have a matching kid
- returns null when audience is wrong
- returns null when issuer is wrong
- returns null when token is expired
- returns null when email claim is missing
- returns null when email claim is not a string
- returns null when email claim is empty string
- returns null when the token signature is tampered
- returns null for an unsigned token with alg 'none'
- returns the email for a fully valid token in the header
- accepts the token via the CF_Authorization cookie when header absent
The two positive tests (header path, cookie path) are the strongest signal that the verifier actually works. The negatives are the regression net: if a future change accidentally relaxes the audience check or stops rejecting alg: none, one of those tests goes red.
Operational notes ¶
Secrets, not vars. CF_ACCESS_TEAM_DOMAIN and CF_ACCESS_AUD go in via npx wrangler pages secret put, not wrangler.jsonc.vars. The AUD tag is not really a secret, but treating both as secrets keeps the production values out of the repo and lets each Pages project (alex, jamie) have its own AUD without per-environment config branches.
Local dev. With the JWT verifier in the loop, wrangler pages dev cannot reach admin endpoints without a real Access JWT. The trade-off is between adding a local bypass (which is the exact pattern the verifier exists to prevent) or doing admin work against a deployed preview. I went with the preview path.
JWKS cache lifetime. jose.createRemoteJWKSet refreshes on a kid miss, which is the right behaviour for Access key rotation. Workers isolates are short-lived, so in practice each new isolate fetches once and reuses the keys for the life of the isolate. No need to wire up a TTL.
In the photography series ¶
- Building a photography portfolio on Cloudflare’s full stack. The stack overview.
- Two sites, one codebase. Same repo, two Pages projects, two D1s.
- Cloudflare Images flexible delivery and retina srcSet. Variants, srcSet, downloads.
- Direct Creator Upload from the browser. Image bytes that never touch your code.
- Stripping EXIF before upload and backfilling existing photos. Privacy on the upload path.
- AI-assisted captions, alt text, and tags. The Workers AI vision dispatcher.
- Hardened admin auth on Cloudflare Pages.
JWT validation, AUD pinning, and the second Access app for
*.pages.dev. (this post)
Try it ¶
The reason this design has all the parts it does, rather than a simpler header check, is that running a security review on the portfolio turned up a bypass through the unprotected pages.dev hostname. The verifier and the second Access app close that bypass independently of each other, which is the property worth having.
If you run a Cloudflare Pages project with an admin area behind Access, two checks worth running today: does your Worker call jwtVerify on every admin request with audience pinned to your app’s AUD tag, and is your project’s *.pages.dev hostname behind its own Access application?
The Cloudflare Access JWT validation docs and the jose library are the two references that mattered. The dashboard step for the second Access app is in the Pages known issues page .
If you spot another gap in this design, or have a cleaner local-dev pattern, I would like to hear it. Reach out on LinkedIn .