Edge Auth
Authentication and authorization at the edge for securing requests before they reach the origin
You are an expert in authentication at the edge for building edge-first applications that validate identity and enforce access control close to users. ## Key Points - **JWT validation** — verify token signatures and claims without calling the origin - **API key lookup** — check keys against an edge KV store - **Session tokens** — validate session IDs against an edge-replicated store - **OAuth/OIDC integration** — handle the redirect flow at the edge, then set cookies - **mTLS** — mutual TLS certificate validation at the CDN level - **Use asymmetric keys (RS256, ES256) for JWT verification** — the edge only needs the public key, reducing the blast radius if an edge node is compromised. - **Cache parsed/imported crypto keys** — importing a JWK on every request adds latency. Store the `CryptoKey` in a module-level variable (it persists within an isolate). - **Set short token expiry and use refresh tokens** — edge-verified JWTs should have 5-15 minute lifetimes. Refresh tokens are exchanged at the origin. - **Hash API keys before storage** — store SHA-256 hashes in KV, never plaintext keys. - **Forward validated identity to the origin** — set `X-User-Id` or similar trusted headers so the origin does not re-verify the token. - **Use `HttpOnly`, `Secure`, and `SameSite` cookie flags** — always set all three for session cookies to prevent XSS and CSRF attacks. - **Rotate secrets gracefully** — support verifying against both old and new keys during rotation windows.
skilldb get edge-computing-skills/Edge AuthFull skill: 525 linesEdge Authentication — Edge Computing
You are an expert in authentication at the edge for building edge-first applications that validate identity and enforce access control close to users.
Overview
Edge authentication moves identity verification from the origin server to CDN edge nodes. By validating tokens, sessions, and API keys at the edge, you reject unauthorized requests before they consume origin resources and reduce latency for legitimate users. Edge auth must be stateless or use edge-local data stores, since traditional session databases are not accessible at sub-millisecond latency from every PoP.
Common edge auth approaches:
- JWT validation — verify token signatures and claims without calling the origin
- API key lookup — check keys against an edge KV store
- Session tokens — validate session IDs against an edge-replicated store
- OAuth/OIDC integration — handle the redirect flow at the edge, then set cookies
- mTLS — mutual TLS certificate validation at the CDN level
Core Concepts
JWT Verification at the Edge
Using the Web Crypto API (available on all edge platforms):
async function importPublicKey(jwk: JsonWebKey): Promise<CryptoKey> {
return crypto.subtle.importKey(
"jwk",
jwk,
{ name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" },
false,
["verify"]
);
}
function base64UrlDecode(str: string): Uint8Array {
const base64 = str.replace(/-/g, "+").replace(/_/g, "/");
const pad = base64.length % 4;
const padded = pad ? base64 + "=".repeat(4 - pad) : base64;
const binary = atob(padded);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
return bytes;
}
interface JWTPayload {
sub: string;
exp: number;
iat: number;
iss: string;
aud: string;
[key: string]: unknown;
}
async function verifyJWT(token: string, publicKey: CryptoKey, expectedIssuer: string, expectedAudience: string): Promise<JWTPayload> {
const parts = token.split(".");
if (parts.length !== 3) {
throw new Error("Invalid token format");
}
const [headerB64, payloadB64, signatureB64] = parts;
// Verify signature
const data = new TextEncoder().encode(`${headerB64}.${payloadB64}`);
const signature = base64UrlDecode(signatureB64);
const valid = await crypto.subtle.verify(
"RSASSA-PKCS1-v1_5",
publicKey,
signature,
data
);
if (!valid) {
throw new Error("Invalid signature");
}
// Decode and validate payload
const payload: JWTPayload = JSON.parse(new TextDecoder().decode(base64UrlDecode(payloadB64)));
if (payload.exp && payload.exp < Math.floor(Date.now() / 1000)) {
throw new Error("Token expired");
}
if (payload.iss !== expectedIssuer) {
throw new Error("Invalid issuer");
}
if (payload.aud !== expectedAudience) {
throw new Error("Invalid audience");
}
return payload;
}
HMAC-Based JWT (HS256)
Simpler alternative when both issuer and verifier share a secret:
async function verifyHS256(token: string, secret: string): Promise<JWTPayload> {
const [headerB64, payloadB64, signatureB64] = token.split(".");
const key = await crypto.subtle.importKey(
"raw",
new TextEncoder().encode(secret),
{ name: "HMAC", hash: "SHA-256" },
false,
["verify"]
);
const data = new TextEncoder().encode(`${headerB64}.${payloadB64}`);
const signature = base64UrlDecode(signatureB64);
const valid = await crypto.subtle.verify("HMAC", key, signature, data);
if (!valid) {
throw new Error("Invalid signature");
}
const payload: JWTPayload = JSON.parse(
new TextDecoder().decode(base64UrlDecode(payloadB64))
);
if (payload.exp && payload.exp < Math.floor(Date.now() / 1000)) {
throw new Error("Token expired");
}
return payload;
}
Extracting Tokens from Requests
function extractBearerToken(request: Request): string | null {
const auth = request.headers.get("Authorization");
if (auth?.startsWith("Bearer ")) {
return auth.slice(7);
}
return null;
}
function extractTokenFromCookie(request: Request, cookieName: string): string | null {
const cookies = request.headers.get("Cookie") ?? "";
const match = cookies.match(new RegExp(`(?:^|;\\s*)${cookieName}=([^;]+)`));
return match ? match[1] : null;
}
Implementation Patterns
Full Auth Middleware for Cloudflare Workers
interface Env {
JWT_PUBLIC_KEY: string; // JWK stored as a secret
JWT_ISSUER: string;
JWT_AUDIENCE: string;
}
interface AuthenticatedRequest extends Request {
user?: JWTPayload;
}
const PUBLIC_PATHS = new Set(["/", "/health", "/login", "/public"]);
async function authMiddleware(
request: Request,
env: Env
): Promise<{ user: JWTPayload } | Response> {
const url = new URL(request.url);
// Skip auth for public paths
if (PUBLIC_PATHS.has(url.pathname)) {
return { user: { sub: "anonymous", exp: 0, iat: 0, iss: "", aud: "" } };
}
// Extract token
const token = extractBearerToken(request) ?? extractTokenFromCookie(request, "auth_token");
if (!token) {
return new Response(JSON.stringify({ error: "Authentication required" }), {
status: 401,
headers: {
"Content-Type": "application/json",
"WWW-Authenticate": "Bearer",
},
});
}
try {
const jwk = JSON.parse(env.JWT_PUBLIC_KEY);
const publicKey = await importPublicKey(jwk);
const user = await verifyJWT(token, publicKey, env.JWT_ISSUER, env.JWT_AUDIENCE);
return { user };
} catch (err: any) {
return new Response(JSON.stringify({ error: err.message }), {
status: 401,
headers: { "Content-Type": "application/json" },
});
}
}
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const authResult = await authMiddleware(request, env);
if (authResult instanceof Response) {
return authResult;
}
// Forward authenticated request to origin with user info
const headers = new Headers(request.headers);
headers.set("X-User-Id", authResult.user.sub);
headers.set("X-User-Claims", JSON.stringify(authResult.user));
return fetch(request.url, {
method: request.method,
headers,
body: request.body,
});
},
};
API Key Authentication with KV
interface Env {
API_KEYS: KVNamespace;
}
interface ApiKeyData {
owner: string;
scopes: string[];
rateLimit: number;
active: boolean;
}
async function validateApiKey(
request: Request,
env: Env
): Promise<{ keyData: ApiKeyData } | Response> {
const apiKey = request.headers.get("X-API-Key");
if (!apiKey) {
return new Response(JSON.stringify({ error: "API key required" }), {
status: 401,
headers: { "Content-Type": "application/json" },
});
}
// Hash the key for lookup (never store raw API keys)
const keyHash = await hashApiKey(apiKey);
const keyData = await env.API_KEYS.get<ApiKeyData>(`key:${keyHash}`, "json");
if (!keyData || !keyData.active) {
return new Response(JSON.stringify({ error: "Invalid or inactive API key" }), {
status: 403,
headers: { "Content-Type": "application/json" },
});
}
return { keyData };
}
async function hashApiKey(key: string): Promise<string> {
const data = new TextEncoder().encode(key);
const hashBuffer = await crypto.subtle.digest("SHA-256", data);
const hashArray = Array.from(new Uint8Array(hashBuffer));
return hashArray.map((b) => b.toString(16).padStart(2, "0")).join("");
}
OAuth2 Authorization Code Flow at the Edge
interface Env {
OAUTH_CLIENT_ID: string;
OAUTH_CLIENT_SECRET: string;
OAUTH_REDIRECT_URI: string;
SESSION_KV: KVNamespace;
}
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url);
// Step 1: Initiate login — redirect to the identity provider
if (url.pathname === "/auth/login") {
const state = crypto.randomUUID();
await env.SESSION_KV.put(`oauth-state:${state}`, "pending", { expirationTtl: 300 });
const authUrl = new URL("https://accounts.google.com/o/oauth2/v2/auth");
authUrl.searchParams.set("client_id", env.OAUTH_CLIENT_ID);
authUrl.searchParams.set("redirect_uri", env.OAUTH_REDIRECT_URI);
authUrl.searchParams.set("response_type", "code");
authUrl.searchParams.set("scope", "openid email profile");
authUrl.searchParams.set("state", state);
return Response.redirect(authUrl.toString(), 302);
}
// Step 2: Handle callback — exchange code for tokens
if (url.pathname === "/auth/callback") {
const code = url.searchParams.get("code");
const state = url.searchParams.get("state");
if (!code || !state) {
return new Response("Missing code or state", { status: 400 });
}
// Validate state to prevent CSRF
const storedState = await env.SESSION_KV.get(`oauth-state:${state}`);
if (!storedState) {
return new Response("Invalid state", { status: 400 });
}
await env.SESSION_KV.delete(`oauth-state:${state}`);
// Exchange code for tokens
const tokenResponse = await fetch("https://oauth2.googleapis.com/token", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
code,
client_id: env.OAUTH_CLIENT_ID,
client_secret: env.OAUTH_CLIENT_SECRET,
redirect_uri: env.OAUTH_REDIRECT_URI,
grant_type: "authorization_code",
}),
});
const tokens = await tokenResponse.json<{ id_token: string; access_token: string }>();
// Create a session
const sessionId = crypto.randomUUID();
await env.SESSION_KV.put(
`session:${sessionId}`,
JSON.stringify({ idToken: tokens.id_token, createdAt: Date.now() }),
{ expirationTtl: 86400 }
);
return new Response(null, {
status: 302,
headers: {
Location: "/",
"Set-Cookie": `session=${sessionId}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=86400`,
},
});
}
// Step 3: Validate session on subsequent requests
const sessionId = extractTokenFromCookie(request, "session");
if (!sessionId) {
return Response.redirect(`${url.origin}/auth/login`, 302);
}
const session = await env.SESSION_KV.get(`session:${sessionId}`, "json");
if (!session) {
return Response.redirect(`${url.origin}/auth/login`, 302);
}
return fetch(request);
},
};
Rate Limiting by Identity
interface Env {
RATE_LIMIT_KV: KVNamespace;
}
interface RateLimitResult {
allowed: boolean;
remaining: number;
resetAt: number;
}
async function checkRateLimit(
kv: KVNamespace,
identifier: string,
limit: number,
windowSeconds: number
): Promise<RateLimitResult> {
const key = `rl:${identifier}`;
const now = Math.floor(Date.now() / 1000);
const entry = await kv.get<{ count: number; windowStart: number }>(key, "json");
if (!entry || now - entry.windowStart >= windowSeconds) {
await kv.put(key, JSON.stringify({ count: 1, windowStart: now }), {
expirationTtl: windowSeconds,
});
return { allowed: true, remaining: limit - 1, resetAt: now + windowSeconds };
}
if (entry.count >= limit) {
return { allowed: false, remaining: 0, resetAt: entry.windowStart + windowSeconds };
}
await kv.put(key, JSON.stringify({ count: entry.count + 1, windowStart: entry.windowStart }), {
expirationTtl: windowSeconds,
});
return {
allowed: true,
remaining: limit - entry.count - 1,
resetAt: entry.windowStart + windowSeconds,
};
}
function addRateLimitHeaders(response: Response, result: RateLimitResult): Response {
const newResponse = new Response(response.body, response);
newResponse.headers.set("X-RateLimit-Remaining", result.remaining.toString());
newResponse.headers.set("X-RateLimit-Reset", result.resetAt.toString());
return newResponse;
}
Signed URL Verification
Protect resources with time-limited signed URLs:
async function verifySignedUrl(url: URL, secret: string): Promise<boolean> {
const signature = url.searchParams.get("sig");
const expires = url.searchParams.get("exp");
if (!signature || !expires) return false;
const expiresAt = parseInt(expires, 10);
if (Date.now() / 1000 > expiresAt) return false;
// Reconstruct the signing input
const signingUrl = new URL(url.toString());
signingUrl.searchParams.delete("sig");
const message = signingUrl.toString();
const key = await crypto.subtle.importKey(
"raw",
new TextEncoder().encode(secret),
{ name: "HMAC", hash: "SHA-256" },
false,
["verify"]
);
const expectedSig = base64UrlDecode(signature);
const data = new TextEncoder().encode(message);
return crypto.subtle.verify("HMAC", key, expectedSig, data);
}
async function generateSignedUrl(baseUrl: string, secret: string, ttlSeconds: number): Promise<string> {
const url = new URL(baseUrl);
const expires = Math.floor(Date.now() / 1000) + ttlSeconds;
url.searchParams.set("exp", expires.toString());
const key = await crypto.subtle.importKey(
"raw",
new TextEncoder().encode(secret),
{ name: "HMAC", hash: "SHA-256" },
false,
["sign"]
);
const data = new TextEncoder().encode(url.toString());
const sigBuffer = await crypto.subtle.sign("HMAC", key, data);
const sigArray = new Uint8Array(sigBuffer);
const sig = btoa(String.fromCharCode(...sigArray))
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/, "");
url.searchParams.set("sig", sig);
return url.toString();
}
Best Practices
- Use asymmetric keys (RS256, ES256) for JWT verification — the edge only needs the public key, reducing the blast radius if an edge node is compromised.
- Cache parsed/imported crypto keys — importing a JWK on every request adds latency. Store the
CryptoKeyin a module-level variable (it persists within an isolate). - Set short token expiry and use refresh tokens — edge-verified JWTs should have 5-15 minute lifetimes. Refresh tokens are exchanged at the origin.
- Hash API keys before storage — store SHA-256 hashes in KV, never plaintext keys.
- Forward validated identity to the origin — set
X-User-Idor similar trusted headers so the origin does not re-verify the token. - Use
HttpOnly,Secure, andSameSitecookie flags — always set all three for session cookies to prevent XSS and CSRF attacks. - Rotate secrets gracefully — support verifying against both old and new keys during rotation windows.
Common Pitfalls
- Not validating
exp,iss, andaudclaims — signature verification alone is insufficient. Always check expiration, issuer, and audience. - Leaking secrets in environment variables — use
wrangler secret putfor Cloudflare Workers or environment variable encryption on other platforms. Never commit secrets to source code orwrangler.tomlvars. - Clock skew issues — edge nodes may have slightly different clocks. Allow a small grace period (e.g., 30 seconds) when checking
expclaims. - Race conditions in KV-based rate limiting — KV is eventually consistent. Two concurrent requests may both read the same counter value and both increment from it, effectively allowing double the rate. For strict rate limiting, use Durable Objects.
- Blocking all requests on auth failure during outages — if the JWKS endpoint or KV is temporarily unavailable, decide whether to fail open (allow requests through) or fail closed (block all). For most applications, failing closed is safer.
- Storing session data in JWTs — JWTs are sent with every request and increase payload size. Keep claims minimal; store session data in KV and reference it by session ID.
Core Philosophy
Edge authentication is about rejecting unauthorized requests before they consume origin resources. By validating tokens at the edge — within milliseconds of the user — you simultaneously improve security (bad requests never reach your backend) and performance (legitimate requests skip an extra round-trip for auth). The edge is the ideal location for stateless authentication because JWTs and API keys can be verified with nothing more than a public key or a KV lookup.
Use asymmetric keys for JWT verification at the edge. The edge only needs the public key, which means a compromised edge node cannot forge tokens. Keep the private key on your auth server, and distribute the public key to all edge locations via environment variables or KV. This separation of concerns is the foundation of zero-trust architecture.
Security at the edge must be stateless or use edge-local data stores. Traditional session databases with single-digit millisecond latency are not accessible from every PoP. Design around JWTs for stateless verification, KV for session lookups, and short-lived tokens with refresh flows for ongoing sessions. The constraint of edge execution forces a cleaner security architecture.
Anti-Patterns
-
Not validating
exp,iss, andaudclaims — signature verification alone is insufficient; a valid signature on an expired token or a token issued for a different audience is still an invalid authentication. -
Using symmetric keys (HS256) at the edge — sharing the signing secret with every edge node means a compromise of any single node allows token forgery; use asymmetric algorithms (RS256, ES256) where the edge only holds the public key.
-
Storing raw API keys in KV — plaintext API keys in the data store mean a KV breach exposes every customer's credentials; always store SHA-256 hashes and compare hashes at lookup time.
-
Importing crypto keys on every request —
crypto.subtle.importKeyis expensive; cache theCryptoKeyobject in a module-level variable so it persists within the isolate across requests. -
Failing closed during outages without consideration — if the JWKS endpoint or KV store is temporarily unavailable, blocking all requests may be worse than allowing them through with degraded authorization; choose the failure mode deliberately based on your risk tolerance.
Install this skill directly: skilldb add edge-computing-skills
Related Skills
Cloudflare D1
Cloudflare D1 for running SQLite databases at the edge with SQL query support
Cloudflare Kv
Cloudflare Workers KV for globally distributed key-value storage at the edge
Cloudflare Workers
Cloudflare Workers for serverless edge compute using the V8 isolate model
Deno Deploy
Deno Deploy for globally distributed edge applications using the Deno runtime
Edge Caching
Edge caching strategies for optimizing content delivery and reducing origin load
Geolocation Routing
Geo-based routing and personalization for delivering localized content at the edge