# Edge Auth — Validating JWTs Without Origin Round-Trips

URL: https://www.devopsness.com/blog/edge-auth-validating-jwts-without-origin-round-trips
Author: Kiril Urbonas
Published: 2026-07-12
Updated: 2026-07-12
Categories: Cloud
Tags: Security, Cloud, Performance, Identity, Edge

Summary: 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.

---
# Edge Auth — Validating JWTs Without Origin Round-Trips

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.

## Verifying a JWT at the edge with WebCrypto

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.

```js
// 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.

```js
// 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.

## The Worker middleware

```js
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 });
    }
  },
};
```

## Session cookies instead of bearer tokens

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.

## Key rotation without breaking

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.

## What not to do at the edge

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](/blog/keyless-cloud-authentication-ending-static-credentials) — no long-lived secret sits at the edge, only public keys. For where else this kind of check belongs, see our [edge computing](/blog/edge-computing-playbook-what-to-run-at-the-edge) playbook.

## The call we'd make

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.

