Vercel Edge vs Cloudflare Workers for Authentication
You need to verify a signed token or resolve a session on every request, before any origin work happens, and you have to pick between Vercel Edge Runtime and Cloudflare Workers to do it. The decision looks cosmetic — both run JavaScript in a V8 isolate — but the two platforms differ in exactly the places authentication is sensitive: where the signing secret lives, how crypto.subtle is reached, and what backing store holds server-side sessions. Get the wrong assumption and you ship a worker that throws crypto is not defined in CI, or a middleware that silently reads an empty secret in production.
This guide is part of Vercel Edge Runtime vs Cloudflare Workers. It walks the full path for both platforms: stateless JWT verification with jose, raw crypto.subtle when you cannot ship a library, and stateful sessions backed by KV.
Root cause: the same WebCrypto, two different secret models
Neither platform gives you Node’s crypto module. Both expose the WHATWG crypto.subtle interface, and both run jose, which is built on it. So the verification math is identical. What is not identical is how the runtime hands you the signing secret and the execution context.
- Vercel Edge injects secrets as
process.env.*, available synchronously at module scope. There is noExecutionContextargument; deferred work usesctx.waitUntilonly insidewaitUntil-aware APIs, andprocess.envis the canonical secret source. - Cloudflare Workers injects secrets as bindings on an
envobject passed intofetch(request, env, ctx). There is noprocess.envunless you enablenodejs_compatand set compatibility flags. Reading a secret before you haveenvis impossible — this is the single most common porting bug.
That difference dictates the function signature, where you build the JWKS, and how you reach KV. Everything below respects it.
Step 1: Verify a JWT on Vercel Edge with jose
jose is tree-shakeable, has zero Node dependencies, and is the default choice on both platforms. On Vercel the secret is read from process.env. Use a symmetric HS256 secret here; the asymmetric path is identical except you pass a JWKS.
// middleware.ts — Vercel Edge Middleware
import { NextRequest, NextResponse } from 'next/server';
import { jwtVerify } from 'jose';
const encoder = new TextEncoder();
export async function middleware(req: NextRequest) {
const token = req.cookies.get('session')?.value;
if (!token) return NextResponse.redirect(new URL('/login', req.url));
try {
const secret = encoder.encode(process.env.JWT_SECRET); // synchronous at the edge
const { payload } = await jwtVerify(token, secret, { algorithms: ['HS256'] });
const headers = new Headers(req.headers);
headers.set('x-user-id', String(payload.sub));
return NextResponse.next({ request: { headers } });
} catch {
return NextResponse.redirect(new URL('/login', req.url));
}
}
export const config = { matcher: ['/dashboard/:path*', '/account/:path*'] };
The TextEncoder is hoisted to module scope so it is created once per isolate, not once per request. Never embed the secret in the bundle — read it from the environment.
Step 2: Verify the same JWT on Cloudflare Workers
The verification call is byte-for-byte identical. Only the entry signature changes: the secret comes from env, not process.env, so it cannot be read until the request handler runs.
// src/worker.ts — Cloudflare Workers
import { jwtVerify } from 'jose';
interface Env {
JWT_SECRET: string; // wrangler secret put JWT_SECRET
}
const encoder = new TextEncoder();
export default {
async fetch(req: Request, env: Env): Promise<Response> {
const cookie = req.headers.get('cookie') ?? '';
const token = /(?:^|;\s*)session=([^;]+)/.exec(cookie)?.[1];
if (!token) return Response.redirect(new URL('/login', req.url).toString(), 302);
try {
const secret = encoder.encode(env.JWT_SECRET); // only available inside fetch()
const { payload } = await jwtVerify(token, secret, { algorithms: ['HS256'] });
const headers = new Headers(req.headers);
headers.set('x-user-id', String(payload.sub));
return fetch(req, { headers }); // forward to origin with identity attached
} catch {
return Response.redirect(new URL('/login', req.url).toString(), 302);
}
},
};
Step 3: Use raw crypto.subtle when you cannot ship a library
If your bundle budget is tight (Cloudflare’s free tier caps at 1 MB gzip) you can verify an HS256 signature with crypto.subtle directly and drop the dependency entirely. This works unchanged on both platforms.
// verify.ts — runtime-agnostic HS256 verification
function b64urlToBytes(s: string): Uint8Array {
const pad = s.replace(/-/g, '+').replace(/_/g, '/').padEnd(Math.ceil(s.length / 4) * 4, '=');
return Uint8Array.from(atob(pad), (c) => c.charCodeAt(0));
}
export async function verifyHS256(token: string, secret: string): Promise<Record<string, unknown> | null> {
const [h, p, sig] = token.split('.');
if (!h || !p || !sig) return null;
const key = await crypto.subtle.importKey(
'raw',
new TextEncoder().encode(secret),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['verify'],
);
const ok = await crypto.subtle.verify(
'HMAC',
key,
b64urlToBytes(sig),
new TextEncoder().encode(`${h}.${p}`),
);
if (!ok) return null;
const claims = JSON.parse(new TextDecoder().decode(b64urlToBytes(p)));
if (typeof claims.exp === 'number' && claims.exp < Date.now() / 1000) return null;
return claims;
}
Always check exp yourself when you verify manually — crypto.subtle.verify only proves the signature is intact, not that the token is still valid.
Step 4: Back server-side sessions with KV
Stateless JWTs cannot be revoked before they expire. For logout-on-demand you need a session store. On Cloudflare that is Workers KV; on Vercel it is Vercel KV (Upstash Redis under the hood). Both share the eventually-consistent read model described in the KV and Durable Object caching overview — a write is not guaranteed visible at every PoP for a few seconds.
// session.ts — Cloudflare Workers KV-backed session lookup
interface Env { SESSIONS: KVNamespace }
export async function resolveSession(req: Request, env: Env) {
const sid = /(?:^|;\s*)sid=([^;]+)/.exec(req.headers.get('cookie') ?? '')?.[1];
if (!sid) return null;
// cacheTtl lets the PoP serve a hot session without a round-trip to KV origin
const raw = await env.SESSIONS.get(`sess:${sid}`, { type: 'json', cacheTtl: 60 });
return raw as { userId: string; exp: number } | null;
}
To revoke, delete the key: await env.SESSIONS.delete('sess:' + sid). Account for propagation delay — a revoked session can survive up to the cacheTtl you set.
Configuration
Set secrets through the platform secret store, never in source. Cloudflare needs a wrangler.jsonc declaring the KV binding; Vercel reads dashboard environment variables.
// wrangler.jsonc — Cloudflare Workers
{
"name": "auth-edge",
"main": "src/worker.ts",
"compatibility_date": "2026-01-01",
"kv_namespaces": [
{ "binding": "SESSIONS", "id": "" }
]
// run: wrangler secret put JWT_SECRET
}
# Vercel — set secrets per environment (not committed)
vercel env add JWT_SECRET production
Local vs production divergence
| Concern | Local dev | Production |
|---|---|---|
| Secret source (Vercel) | .env.local via process.env |
Dashboard env var, injected at deploy |
| Secret source (Cloudflare) | .dev.vars file, wrangler dev |
wrangler secret put, on env binding |
| KV reads | wrangler dev simulates KV in-memory / Miniflare |
Eventually consistent across PoPs |
process.env on Cloudflare |
works only with nodejs_compat flag |
undefined without the flag — use env |
Clock for exp |
machine clock, often correct | PoP clock; small skew possible |
Cookie Secure flag |
ignored over http://localhost |
enforced; cookie dropped over HTTP |
The trap: a Cloudflare worker that reads process.env.JWT_SECRET may appear to work in wrangler dev with nodejs_compat enabled, then verify against undefined in production where the flag is off. Always read from env.
Validation with Vitest
Test verification logic as a pure function so you never need a live runtime. This covers a valid token, an expired token, and a tampered signature.
// verify.test.ts
import { describe, it, expect, beforeAll } from 'vitest';
import { SignJWT } from 'jose';
import { verifyHS256 } from './verify';
const SECRET = 'test-secret-value';
const enc = new TextEncoder();
let validToken: string;
let expiredToken: string;
beforeAll(async () => {
validToken = await new SignJWT({ sub: 'u_1' })
.setProtectedHeader({ alg: 'HS256' })
.setExpirationTime('1h')
.sign(enc.encode(SECRET));
expiredToken = await new SignJWT({ sub: 'u_1' })
.setProtectedHeader({ alg: 'HS256' })
.setExpirationTime(Math.floor(Date.now() / 1000) - 10)
.sign(enc.encode(SECRET));
});
describe('verifyHS256', () => {
it('accepts a valid token', async () => {
expect(await verifyHS256(validToken, SECRET)).toMatchObject({ sub: 'u_1' });
});
it('rejects an expired token', async () => {
expect(await verifyHS256(expiredToken, SECRET)).toBeNull();
});
it('rejects a tampered signature', async () => {
const tampered = validToken.slice(0, -2) + 'xx';
expect(await verifyHS256(tampered, SECRET)).toBeNull();
});
});
Named pitfalls
- Reading
process.envon Cloudflare. It is undefined withoutnodejs_compat. Fix: read the secret from theenvbinding insidefetch. - Forgetting to check
expafter a manualcrypto.subtle.verify. A valid signature does not mean a live token. Fix: compareexpagainstDate.now() / 1000yourself. - Embedding the signing secret in the bundle. It ends up shipped to every PoP and visible in source maps. Fix: inject via secret store only.
- Assuming KV revocation is instant. A deleted session can survive
cacheTtlseconds. Fix: keepcacheTtlshort for security-critical sessions, or use a Durable Object for strong consistency. - Verifying without pinning
algorithms. Omitting it lets an attacker downgrade tonone. Fix: always pass{ algorithms: ['HS256'] }(or your real alg).
Production deployment checklist
- Cloudflare worker reads secrets from
env, never -
algorithmsexplicitly pinned in everyjwtVerify -
exp(andnbf - Session cookie set
HttpOnly,Secure,SameSite=Lax - KV
cacheTtl -
wrangler.jsoncdeclares the KV binding;compatibility_date
Frequently Asked Questions
Does jose work the same on Vercel Edge and Cloudflare Workers?
Yes. jose is built entirely on WebCrypto (crypto.subtle), which both runtimes expose. The verification call is identical; only how you obtain the signing secret differs — process.env on Vercel, the env binding on Cloudflare.
Where should my JWT signing secret live?
In the platform secret store: Vercel environment variables, or wrangler secret put on Cloudflare. Never embed it in the bundle, where it would be shipped to every PoP and leak through source maps.
Can I revoke a stateless JWT before it expires?
Not directly — a stateless JWT is valid until its exp. To revoke on demand, back sessions with a KV store and delete the key on logout. Remember KV is eventually consistent, so a revoked session can survive your configured cacheTtl.
Why is process.env undefined in my Cloudflare Worker?
Cloudflare Workers do not expose process.env unless you enable the nodejs_compat compatibility flag. The canonical secret source is the env argument passed into fetch(request, env, ctx). Read secrets from there.