Verifying signed tokens at the edge with WebCrypto blocks bad traffic early and saves a full origin hop. Here's the pattern we ship, and the traps.
We had an API that spent real money answering requests it should have rejected. Every call hit the origin, the origin decoded a bearer token, saw it was expired, and returned a 401. That's a full round-trip to your compute region to say "no." Under a credential-stuffing burst it's also your origin doing the work an attacker wants it to do.
Moving that check to the edge fixed both problems. A request with a bad or missing token now dies at the nearest POP, tens of milliseconds from the user, and never touches origin. A request with a good token arrives at origin already trusted, so the origin skips its own verification. The math is simple: one fewer hop on the happy path, and zero hops on the reject path.
Edge runtimes (Cloudflare Workers, Vercel Edge, Deno Deploy) don't give you Node's crypto or your favorite JWT library. They give you the WebCrypto SubtleCrypto API, which is enough. A JWT is three base64url segments: header, payload, signature. You verify that the signature covers header.payload using the issuer's public key.
The public keys live at the issuer's JWKS endpoint (/.well-known/jwks.json for most providers). You fetch it once, cache it, and match the token's kid header to the right key.
// jwks.js — fetch + cache the issuer's public keys
const JWKS_URL = "https://issuer.example.com/.well-known/jwks.json";
const cache = { keys: new Map(), expires: 0 };
async function getKey(kid) {
if (Date.now() > cache.expires) {
const res = await fetch(JWKS_URL, { cf: { cacheTtl: 600 } });
const { keys } = await res.json();
cache.keys.clear();
for (const jwk of keys) {
const algo = jwk.kty === "RSA"
? { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" }
: { name: "ECDSA", namedCurve: "P-256", hash: "SHA-256" };
const key = await crypto.subtle.importKey("jwk", jwk, algo, false, ["verify"]);
cache.keys.set(jwk.kid, { key, kty: jwk.kty });
}
cache.expires = Date.now() + 10 * 60 * 1000; // 10 min TTL
}
return cache.keys.get(kid);
}
Now the verify step. Decode, check the signature, then check the claims. Order matters: never trust a claim before you've verified the signature.
// verify.js
function b64urlToBytes(s) {
const pad = "=".repeat((4 - (s.length % 4)) % 4);
const b = atob((s + pad).replace(/-/g, "+").replace(/_/g, "/"));
return Uint8Array.from(b, (c) => c.charCodeAt(0));
}
export async function verifyJwt(token, { aud, iss }) {
const [h, p, s] = token.split(".");
if (!h || !p || !s) throw new Error("malformed");
const header = JSON.parse(new TextDecoder().decode(b64urlToBytes(h)));
const entry = await getKey(header.kid);
if (!entry) throw new Error("unknown kid");
const algo = entry.kty === "RSA"
? { name: "RSASSA-PKCS1-v1_5" }
: { name: "ECDSA", hash: "SHA-256" };
const data = new TextEncoder().encode(`${h}.${p}`);
const ok = await crypto.subtle.verify(algo, entry.key, b64urlToBytes(s), data);
if (!ok) throw new Error("bad signature");
const claims = JSON.parse(new TextDecoder().decode(b64urlToBytes(p)));
const now = Math.floor(Date.now() / 1000);
if (claims.exp && now >= claims.exp) throw new Error("expired");
if (claims.nbf && now < claims.nbf) throw new Error("not yet valid");
if (iss && claims.iss !== iss) throw new Error("bad iss");
if (aud && ![].concat(claims.aud).includes(aud)) throw new Error("bad aud");
return claims;
}
One gotcha for ECDSA: WebCrypto expects the raw r||s signature that JWTs already use, so ES256 works directly. If you ever verify a DER-encoded signature you'll have to convert it. RS256 is the boring, dependable default and what we run in production.
export default {
async fetch(request, env, ctx) {
const auth = request.headers.get("authorization") || "";
const token = auth.startsWith("Bearer ") ? auth.slice(7) : null;
if (!token) return new Response("unauthorized", { status: 401 });
try {
const claims = await verifyJwt(token, {
aud: env.API_AUDIENCE,
iss: env.ISSUER,
});
// Trusted. Pass identity to origin so it skips re-verification.
const req = new Request(request);
req.headers.set("x-user-id", claims.sub);
return fetch(req);
} catch (err) {
return new Response("unauthorized", { status: 401 });
}
},
};
Browser traffic usually carries a session cookie, not an Authorization header. If your session token is itself a signed JWT (a stateless session), the exact same verify function works — read it from request.headers.get("cookie"), parse out your cookie name, and hand the value to verifyJwt. This is the pattern we prefer for first-party web apps: a short-lived signed cookie the edge can check offline, refreshed against your auth server only when it nears expiry.
Issuers rotate signing keys. The kid in the token header is how you survive it: during rotation the JWKS endpoint publishes both the old and new keys, so tokens signed with either still verify. Your only job is to not cache the JWKS forever. A 10-minute TTL means a rotated key is picked up within 10 minutes, which is fine because the old key stays published far longer than that. If verification fails on an unknown kid, force one JWKS refresh before giving up — that covers the rare case where rotation outpaced your cache.
Don't call your auth server on every request. If the edge phones home to validate each token, you've reintroduced the round-trip you came to delete, plus a new dependency in the hot path. The whole point of a signed token is offline verification.
Don't do full session lookups against a single-region store from the edge. A Worker in Singapore hitting a Postgres session table in Virginia is slower than just going to origin. If you need server-side session state, either replicate it to the edge (Cloudflare KV, Durable Objects, a replicated store) or keep sessions stateless with short-lived tokens and accept that revocation lags by the token lifetime. We keep access tokens at 5–15 minutes so a compromised token expires fast without a revocation list.
This pairs naturally with keyless authentication — no long-lived secret sits at the edge, only public keys. For where else this kind of check belongs, see our edge computing playbook.
Verify signed JWTs at the edge with WebCrypto, cache the JWKS on a short TTL, and reject bad traffic before it costs you an origin hop. Keep tokens short-lived and stateless so the edge never has to phone home. Reach for a replicated session store only when you genuinely need instant revocation — and know you're paying for it. For most APIs, offline signature verification at the POP is the cheapest security win on the table.
Get the latest tutorials, guides, and insights on AI, DevOps, Cloud, and Infrastructure delivered directly to your inbox.
A request leaving a laptop somehow lands on a server 20ms away. Here is what actually decides which point of presence answers.
GitHub Actions OIDC gets all the attention, but GitLab, Buildkite, and CircleCI issue the same signed tokens. Here's how to trust them without opening a hole.
Explore more articles in this category
The edge is fast because it's constrained. This is the decision map for what belongs at the edge, what belongs at origin, and how compute, data, caching, and auth fit together.
Static keys leak. The question isn't if but how fast you notice and how clean your response runbook is when the pager goes off.
Edge code runs in hundreds of PoPs, lives for milliseconds, and gives you no shell. Here's how we get logs, traces, and metrics out of it anyway.
Evergreen posts worth revisiting.