JWT Validation at the Edge with Cloudflare Workers
Validating a JSON Web Token in a Cloudflare Worker lets you reject unauthenticated and tampered requests in the same datacenter that received them, shaving a full origin round trip off every rejected call and keeping your application servers from ever seeing a forged token. By the end of this guide you will have a production TypeScript Worker that reads the Authorization bearer token, verifies an RS256 signature against a cached JWKS (or an HS256 signature against a shared secret), enforces exp, iss, and aud, and returns a clean 401 on every failure path.
Key objectives:
- Parse the bearer token and split the JWT into header, payload, and signature segments.
- Verify the signature with WebCrypto
importKey+verify, never trusting the token’s ownalgfield. - Enforce the standard claims (
exp,nbf,iss,aud) with a small clock-skew tolerance. - Cache the JWKS in memory and the Cache API so key fetches do not run on every request.
Prerequisites and environment setup
You need Node.js 18+, a Cloudflare account, and Wrangler v3 or newer. Confirm your toolchain before writing any code:
node --version # v18.x or higher
npx wrangler --version # ⛅️ wrangler 3.x or 4.x
npx wrangler login
You also need an identity provider that exposes a JWKS (JSON Web Key Set) endpoint — Auth0, Okta, AWS Cognito, Clerk, Keycloak, and most OIDC providers publish one at https://ISSUER/.well-known/jwks.json. RS256 (asymmetric) is strongly preferred over HS256 because the Worker only ever holds the public key; a leaked edge secret cannot mint tokens. Use HS256 only when you control both the signer and verifier and can keep the shared secret out of source control.
Store configuration as Worker vars and the HS256 secret (if used) as an encrypted secret, never in wrangler.toml:
npx wrangler secret put JWT_SHARED_SECRET # only for HS256
This Worker sits in front of your API the same way any routing Worker does. If you are new to wiring a Worker to a route, start with deploying Cloudflare Workers for dynamic request routing, then layer this validation on top.
The wrangler.toml
name = "jwt-gateway"
main = "src/index.ts"
compatibility_date = "2024-09-23"
# Public, non-secret configuration
[vars]
JWT_ISSUER = "https://your-tenant.us.auth0.com/"
JWT_AUDIENCE = "https://api.example.com"
JWKS_URI = "https://your-tenant.us.auth0.com/.well-known/jwks.json"
# Route the Worker in front of your API hostname
[[routes]]
pattern = "api.example.com/*"
zone_name = "example.com"
[observability]
enabled = true
The [observability] block turns on logs so wrangler tail shows your console output without extra flags.
Step-by-step procedure
Step 1: Read and pre-validate the bearer token
Pull the token out of the Authorization header and reject obviously malformed requests before doing any cryptography. Splitting on the dot must yield exactly three segments.
function getBearer(req: Request): string | null {
const h = req.headers.get("Authorization") ?? "";
const [scheme, token] = h.split(" ");
if (scheme?.toLowerCase() !== "bearer" || !token) return null;
return token.trim();
}
function decodeSegment(seg: string): any {
// base64url -> JSON
const b64 = seg.replace(/-/g, "+").replace(/_/g, "/");
const json = atob(b64.padEnd(b64.length + (4 - (b64.length % 4)) % 4, "="));
return JSON.parse(json);
}
Expected side effect: a request with no Authorization header returns null and is short-circuited to a 401 before the Worker spends any CPU on verification.
Step 2: Pin the algorithm and resolve the signing key
This is the security-critical step. Decode the header only to read kid, but force the algorithm yourself. Never branch on the token’s alg to choose the verification method — that is the classic algorithm-confusion attack, and accepting alg: none would let anyone forge a token.
interface Env {
JWT_ISSUER: string;
JWT_AUDIENCE: string;
JWKS_URI: string;
JWT_SHARED_SECRET?: string;
}
const ALLOWED_ALG = "RS256"; // pin it; do not read from the token
// In-memory cache survives within a single isolate
let jwksMemo: { keys: any[]; fetchedAt: number } | null = null;
const JWKS_TTL_MS = 10 * 60 * 1000; // 10 minutes
async function getSigningKey(kid: string, env: Env): Promise<CryptoKey> {
const jwk = await resolveJwk(kid, env, false);
return crypto.subtle.importKey(
"jwk",
jwk,
{ name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" },
false,
["verify"],
);
}
Step 3: Cache the JWKS in memory and the Cache API
A JWKS fetch on every request adds latency and can rate-limit your IdP. Cache it two ways: a module-global for the lifetime of the isolate, and the Cache API so a cold isolate in the same datacenter reuses a warm copy. On a kid miss, refetch once — that handles key rotation without a deploy.
async function fetchJwks(env: Env): Promise<any[]> {
const cache = caches.default;
const cacheKey = new Request(env.JWKS_URI);
let res = await cache.match(cacheKey);
if (!res) {
res = await fetch(env.JWKS_URI, { cf: { cacheTtl: 600 } });
if (!res.ok) throw new Error(`JWKS fetch ${res.status}`);
const copy = new Response(res.body, res);
copy.headers.set("Cache-Control", "max-age=600");
await cache.put(cacheKey, copy.clone());
res = copy;
}
const { keys } = await res.json();
jwksMemo = { keys, fetchedAt: Date.now() };
return keys;
}
async function resolveJwk(kid: string, env: Env, isRetry: boolean): Promise<any> {
if (!jwksMemo || Date.now() - jwksMemo.fetchedAt > JWKS_TTL_MS) {
await fetchJwks(env);
}
const jwk = jwksMemo!.keys.find((k) => k.kid === kid);
if (!jwk) {
if (isRetry) throw new Error(`No JWK for kid ${kid}`);
jwksMemo = null; // force refetch on rotation
return resolveJwk(kid, env, true);
}
return jwk;
}
Step 4: Verify the signature with WebCrypto
WebCrypto verifies over the raw header.payload ASCII bytes. Decode the base64url signature to bytes and call crypto.subtle.verify. For HS256, import the shared secret as an HMAC key instead.
function b64urlToBytes(s: string): Uint8Array {
const b64 = s.replace(/-/g, "+").replace(/_/g, "/");
const pad = b64.padEnd(b64.length + (4 - (b64.length % 4)) % 4, "=");
const bin = atob(pad);
return Uint8Array.from(bin, (c) => c.charCodeAt(0));
}
async function verifySignature(token: string, env: Env): Promise<any> {
const [h, p, s] = token.split(".");
if (!h || !p || !s) throw new Error("malformed");
const header = decodeSegment(h);
if (header.alg !== ALLOWED_ALG) throw new Error("alg not allowed");
const data = new TextEncoder().encode(`${h}.${p}`);
const sig = b64urlToBytes(s);
const key = await getSigningKey(header.kid, env);
const ok = await crypto.subtle.verify(
{ name: "RSASSA-PKCS1-v1_5" },
key,
sig,
data,
);
if (!ok) throw new Error("bad signature");
return decodeSegment(p);
}
Step 5: Enforce the claims
A valid signature is necessary but not sufficient. Check expiry, not-before, issuer, and audience. Allow a small clock-skew window (60 seconds) so a slightly fast or slow edge node does not reject fresh tokens.
const SKEW = 60; // seconds
function checkClaims(claims: any, env: Env): void {
const now = Math.floor(Date.now() / 1000);
if (typeof claims.exp !== "number" || now > claims.exp + SKEW)
throw new Error("expired");
if (typeof claims.nbf === "number" && now + SKEW < claims.nbf)
throw new Error("not yet valid");
if (claims.iss !== env.JWT_ISSUER) throw new Error("bad issuer");
const aud = Array.isArray(claims.aud) ? claims.aud : [claims.aud];
if (!aud.includes(env.JWT_AUDIENCE)) throw new Error("bad audience");
}
Step 6: Wire it into the fetch handler
The handler ties everything together: reject early, verify, then forward the request to origin. Passing the validated subject downstream as a header lets origin skip re-parsing the token — see modifying request headers at the CDN edge layer for safe header injection patterns.
export default {
async fetch(req: Request, env: Env): Promise<Response> {
const token = getBearer(req);
if (!token) return unauthorized("missing bearer token");
try {
const claims = await verifySignature(token, env);
checkClaims(claims, env);
// Forward to origin with the verified subject attached
const fwd = new Request(req);
fwd.headers.set("X-Auth-Sub", String(claims.sub));
fwd.headers.set("X-Auth-Scope", String(claims.scope ?? ""));
return fetch(fwd);
} catch (err) {
console.log("jwt reject:", (err as Error).message);
return unauthorized((err as Error).message);
}
},
};
function unauthorized(reason: string): Response {
return new Response(JSON.stringify({ error: "unauthorized" }), {
status: 401,
headers: {
"Content-Type": "application/json",
"WWW-Authenticate": `Bearer error="invalid_token", error_description="${reason}"`,
},
});
}
Deploy with npx wrangler deploy. Expected output ends with a line confirming the route binding, e.g. Uploaded jwt-gateway and api.example.com/*.
Verification
Generate a valid token from your IdP (or a test signer) and run three checks. A good token should pass; an expired token and a tampered token should both return 401.
# 1. Valid token -> 200 from origin
curl -s -o /dev/null -w "%{http_code}\n" \
-H "Authorization: Bearer $VALID_JWT" https://api.example.com/me
# expected: 200
# 2. Expired token -> 401
curl -s -w "\n%{http_code}\n" \
-H "Authorization: Bearer $EXPIRED_JWT" https://api.example.com/me
# expected body: {"error":"unauthorized"} status: 401
# 3. Tampered payload (flip a byte) -> 401 bad signature
curl -s -w "\n%{http_code}\n" \
-H "Authorization: Bearer ${VALID_JWT}x" https://api.example.com/me
# expected: 401
Watch the rejections live while you run the curls:
npx wrangler tail --format pretty
# jwt reject: expired
# jwt reject: bad signature
Confirm the WWW-Authenticate header is present so compliant clients can react:
curl -I -H "Authorization: Bearer bad" https://api.example.com/me | grep -i www-authenticate
# WWW-Authenticate: Bearer error="invalid_token", error_description="malformed"
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
Valid fresh token returns 401 expired |
Clock skew between IdP and edge | Confirm SKEW; check exp with jwt decoder against date +%s |
Intermittent No JWK for kid after rotation |
Stale cached JWKS | Ensure the kid-miss refetch path runs; lower JWKS_TTL_MS |
All tokens rejected bad audience |
aud mismatch |
Print claims.aud in wrangler tail; match JWT_AUDIENCE exactly |
bad signature on every token |
Wrong key import params or HS256 token hitting RS256 path | Verify alg in the header equals ALLOWED_ALG |
Clock skew
If valid tokens with a short lifetime are rejected as expired, the edge node’s clock and the IdP’s clock disagree, or the token’s iat/exp window is tighter than your SKEW. Decode the token and compare:
echo "$VALID_JWT" | cut -d. -f2 | base64 -d 2>/dev/null | python3 -m json.tool
# {"exp": 1718900000, "iat": 1718896400, ...}
date +%s # compare against exp
Keep SKEW at 60 seconds — large windows weaken expiry enforcement.
Key rotation and JWKS caching
When the IdP rotates signing keys, new tokens carry a new kid that is not in your cached set. The resolveJwk retry forces exactly one refetch on a miss, so rotation is handled without a deploy. If you see a burst of No JWK for kid errors during a known rotation, your cache TTL is masking the new key — confirm the second-attempt refetch path is reachable and that cache.put is not pinning a stale response longer than max-age.
Algorithm confusion (never accept alg=none)
The most damaging JWT bug is trusting the token’s own alg. An attacker sets alg: none and strips the signature, or sets alg: HS256 and signs with your public RSA key as the HMAC secret. Both are blocked here because ALLOWED_ALG is pinned and the verification path is fixed to RSASSA-PKCS1-v1_5. Reject any header whose alg is not your single expected value before importing a key.
Combine with rate limiting
Signature verification stops forged tokens but not floods of valid ones. Pair this Worker with rate limiting API requests at the edge keyed on the verified sub claim so a single compromised account cannot exhaust your origin. Run the rate-limit check after claim validation so the key is trustworthy.
Frequently Asked Questions
Should I use RS256 or HS256 at the edge? Use RS256 whenever an OIDC provider issues your tokens, because the Worker only holds the public key and a leaked edge secret cannot mint tokens. Reserve HS256 for cases where you fully control both signer and verifier and can keep the shared secret out of source control.
How do I handle JWKS key rotation without redeploying?
Cache the JWKS with a short TTL and trigger a single refetch when a token’s kid is missing from the cached set. The retry path in resolveJwk does this, so newly rotated keys are picked up automatically within one request of the cache being cleared.
Why must I pin the algorithm instead of reading it from the token?
Trusting the token’s alg enables algorithm-confusion attacks: an attacker can set alg: none to drop the signature or alg: HS256 to abuse your public key as an HMAC secret. Pinning to one expected value and a fixed verification routine closes both holes.
Where should I put the validated identity for the origin?
Strip any incoming auth headers, then set a trusted internal header such as X-Auth-Sub from the verified sub claim. Origin can trust it because only the Worker can write it on requests arriving over Cloudflare’s network.
Does verification add noticeable latency?
A cache-hit JWKS plus an in-isolate WebCrypto verify typically costs single-digit milliseconds of CPU, far less than the origin round trip you avoid on every rejected request. The first request after a cold isolate pays the one-time JWKS fetch.
Related
- Rate Limiting API Requests at the Edge
- Deploying Cloudflare Workers for Dynamic Request Routing
- Modifying Request Headers at the CDN Edge Layer
Back to API Gateway at the Edge