
Authentication for Serverless and Edge Deployments
How does authentication work for serverless and edge deployments?
Authentication for serverless and edge runtimes uses short-lived, stateless JWTs verified against a JWKS endpoint with local caching, because ephemeral compute cannot rely on long-lived session stores or in-memory caches. Managed providers (Clerk, Auth0, AWS Cognito, Supabase Auth, Firebase Authentication) handle key issuance, rotation, and edge-compatible SDKs; self-built options typically reach for jose with crypto.subtle. The walkthrough below splits "architectural edge" (Node.js proxies like Next.js 16 proxy.ts) from "runtime edge" (V8 isolates and WASM with Web Standards APIs only), then covers the five placement patterns, platform notes for Vercel, Cloudflare Workers, AWS Lambda, Netlify Edge, and Deno Deploy, and a hands-on Clerk implementation across Next.js 16, Cloudflare Workers, Expo, and a background worker.
Most auth libraries were designed for long-lived origin servers, so cold starts, no persistent memory, short CPU budgets, and V8-isolate runtime constraints (Cloudflare Workers limits) all change the design. Warm-path latency targets stay in single-digit milliseconds — community benchmarks land at ~1.8ms for jose JWT verification on Cloudflare Workers and ~2.3ms on Vercel Edge (§8.1).
What this article covers
This article is for developers who already have some serverless experience and are implementing authentication for the first time in edge or serverless environments, or who are trying to share auth across services in a monorepo. It covers:
- Core runtime differences between Node.js serverless and V8-isolate edge, and why they change auth design
- JWT and JWKS mechanics, including caching strategies that actually move the needle at the edge
- Five architectural patterns for placing auth in a serverless system
- Platform-specific notes for Vercel (Next.js 16), Cloudflare Workers, AWS Lambda, Netlify Edge, and Deno Deploy
- How to share auth configuration across apps in a monorepo without collapsing each service's runtime fit
- A hands-on walkthrough of implementing auth with Clerk across Next.js 16, Cloudflare Workers, Expo, and a background worker
- Objective comparison against Auth0, AWS Cognito, Supabase Auth, Firebase Authentication, and rolling your own with
jose - Security best practices, including the Next.js middleware CVE that shipped in 2025
- An implementation checklist, FAQ, and a statistics table with every quantitative claim traced to its source
Why Authentication Is Different at the Edge and in Serverless
The serverless and edge runtime model
Serverless means ephemeral compute — your function spins up per invocation, runs for a short, capped window, and disappears. Examples include AWS Lambda, Vercel Functions (Node.js), Netlify Functions, Azure Functions, and Google Cloud Run. The platform handles scaling; you pay for what runs.
"Edge" has two distinct meanings in this space, and mixing them up is the most common source of broken auth code.
- Architectural edge: compute placed geographically close to the user, but still running a familiar Node.js-style runtime. Next.js 16
proxy.tsis the canonical example — it sits in front of origin requests, but it runs on Node.js, not a V8 isolate (Next.jsproxy.tsAPI reference, Next.js 16 release blog). - Runtime edge: code that executes inside a V8 isolate or WASM runtime with Web Standards APIs only. Examples include Cloudflare Workers, Netlify Edge Functions (Deno), Deno Deploy, and Fastly Compute (WASM).
fs,net, and most of the Node standard library are either absent or gated behind compatibility flags (Cloudflare Workers Node.js compatibility).
A side-by-side of the two runtime families makes the constraints concrete:
The cold-start and bundle differences both point at the same conclusion: auth code you ship to a V8-isolate runtime must be small, Web-Standards-native, and fast to start. Code that ships to Node.js serverless has room for larger dependencies but pays a cold-start tax whenever the instance turns over.
Why traditional session auth breaks down
A classical session model assumes a long-lived server holding an in-memory cache backed by a nearby session database. Three assumptions break in serverless or edge environments.
- Stateful session stores assume proximity. Each edge point-of-presence (PoP) is physically far from any central session DB, so every verification becomes a cross-region round-trip. That defeats the latency advantage of serving from the edge.
- In-memory caches do not persist. Serverless functions tear down after their invocation window. V8 isolates may be evicted at any time. Per-isolate caches exist, but a cold isolate starts with an empty one.
- Database round-trips per request are expensive at scale. A community benchmark measured warm execution at ~167ms on an edge runtime versus ~287ms on a serverless backend when the serverless tier hit a session database (ByteIota edge vs serverless). Treat this as illustrative of the shape, not a universal constant — methodologies vary.
Cookie-based sessions still work, but only when the verification path is edge-compatible: a signed cookie verified with Web Crypto, or a JWT cookie parsed without pulling a Node-only library into the bundle.
Cold starts, latency, and geographic distribution
Cold starts amplify auth latency because the first request through a new instance pays for everything at once: container spin-up, module evaluation, JWKS fetch, JWT parse, and any database lookups. A sub-millisecond warm auth check can turn into a multi-hundred-millisecond first-byte delay.
- AWS describes Lambda cold starts as spanning "less than 100ms to well over 1 second" depending on runtime, package size, and VPC attachment (AWS Lambda cold start remediation).
- Independent benchmarks place optimized Node.js 22 arm64 Lambda cold starts around p50 ~294ms (Node.js 22 Lambda benchmarks).
- AWS SnapStart expanded to Python 3.12+ and .NET 8+ at re:Invent 2025 but is not yet available for Node.js (AWS Lambda SnapStart docs, AWS re:Invent 2025 Lambda recap).
The practical principle is: verify close to the user, authorize close to the data. A signed JWT lets the edge answer "is this a valid token for user U?" with no database call. Authorization decisions ("can U perform action A on resource R?") usually still need state and run closer to the data.
Runtime constraints that trip up auth libraries
Many "Node-first" auth libraries fail on V8-isolate runtimes because they depend on APIs that do not exist in Web Standards environments.
jsonwebtokenuses Node'scryptomodule; CF Workers require Web Crypto (crypto.subtle) unlessnodejs_compatis enabled.bcryptships a native binary; it will not run in a V8 isolate.firebase-adminuses TCP sockets and Node.js-only APIs; it is not edge-compatible. Community librarynext-firebase-auth-edgefills the gap by reimplementing token verification with Web Crypto (next-firebase-auth-edge).pg(node-postgres) opens raw TCP connections; not available on CF Workers or Netlify Edge.- Most Passport strategies expect Express-shaped middleware and the Node
httpmodule; porting to Web Fetch handlers is non-trivial.
Bundle limits also matter. A Vercel Edge Runtime function is capped at 1MB gzipped on Hobby, 4MB on Enterprise (Vercel Edge Runtime). Cloudflare Workers allow 3MB Free / 10MB Paid compressed (Cloudflare Workers limits). Both numbers will push you toward small, Web-Standards-first SDKs.
Core Concepts for Serverless and Edge Authentication
Stateless authentication with JWTs
A JWT is three base64url-encoded segments joined by dots: header.payload.signature. The header declares the signing algorithm and key ID (kid); the payload carries the claims about the subject and the token itself; the signature is computed over base64url(header).base64url(payload) using the algorithm from the header.
Stateless tokens fit ephemeral compute because verification only needs the issuer's public key — no server-side session record is required. Any invocation of any function can verify any token without sharing memory.
The tokens you usually encounter split into three roles:
- Access tokens: short-lived (15–60 minutes typical; Clerk session tokens are 60 seconds), sent on every API request, proving the caller's identity and scopes.
- Refresh tokens: long-lived, used to mint new access tokens without re-authentication.
- Session tokens: in some providers, the same short-lived JWT is used both as an access token and to drive session refresh (Clerk's
__sessioncookie is this shape).
The relevant specs are RFC 7519 (JWT), RFC 9068 (JWT Profile for OAuth 2.0 Access Tokens), and the OpenID Connect Core 1.0 spec for ID tokens.
JWT verification with JWKS
A JSON Web Key Set (JWKS) is a JSON document of the shape { "keys": [...] } where each entry is a public key with fields like kty (key type), kid (key ID), use, alg, and the key material itself (RFC 7517). Providers publish JWKS at a well-known URL — conventionally the issuer URL followed by the path /.well-known/jwks.json — discoverable via the OIDC Discovery Document.
Verifying a JWT against a JWKS follows this flow:
- Parse the header and read the
kid. - Fetch the JWKS (or read it from cache) and find the key with the matching
kid. - Verify the signature using the algorithm in the header, checked against your allow-list.
- Validate the required claims:
iss(issuer),aud(audience),exp(expiry),nbf(not-before),azp(authorized party, when applicable), and any custom claims the app cares about. - Allow a small clock skew — 5 to 30 seconds is typical (Curity JWT best practices).
Asymmetric algorithms (RS256, ES256, EdDSA) beat HS256 for multi-service verification because every service can have the public key without sharing a secret. EdDSA and ES256 produce smaller signatures than RSA-2048, and EdDSA signing is dramatically faster — Connect2id's Nimbus JOSE+JWT 6.0 benchmark measured Ed25519 signing at roughly 62x RSA-2048 throughput (14,635 vs. 236 ops/sec), though RSA-2048 verification is comparable or slightly faster than Ed25519 on the same benchmark (Connect2id Nimbus JOSE+JWT 6.0 benchmark). For edge and serverless systems, verification is the hot path — the signing advantage matters mainly when a worker mints its own tokens (WorkOS HMAC vs RSA vs ECDSA for qualitative comparisons).
A portable, Web-Crypto-native verifier looks like this:
import { createRemoteJWKSet, jwtVerify } from 'jose'
const JWKS = createRemoteJWKSet(new URL('https://your-issuer.example.com/.well-known/jwks.json'))
export async function verify(token: string) {
const { payload } = await jwtVerify(token, JWKS, {
issuer: 'https://your-issuer.example.com',
audience: 'your-app-audience',
algorithms: ['RS256', 'ES256'],
clockTolerance: '30s',
})
return payload
}That block runs unchanged in Node.js, Bun, Deno, and Cloudflare Workers. createRemoteJWKSet() caches the JWKS automatically and refreshes it on kid miss (jose GitHub).
JWKS caching at the edge
Fetching the JWKS on every request adds a network round-trip before verification can even start. A community benchmark places cold JWKS fetches at 15–25ms on Vercel Edge (SSOJet JWT at the edge); treat that as illustrative of the shape, not a universal number. The fix is layered caching.
- Per-isolate in-memory (LRU). Free, fastest, warm-only.
jose'screateRemoteJWKSet()does this by default. - Shared KV. Cloudflare KV, Upstash, or Redis shared across isolates. Cloudflare's post-October-2025 rearchitecture documents hot-key KV reads
<1msand p99<5ms(Cloudflare KV performance). - Background refresh. Refresh the cache before the TTL expires to avoid a thundering herd at rollover.
Handling JWKS rotation gracefully means publishing the new key before using it: pre-publish → wait at least the cache TTL → switch signing to the new key → retire the old key (WorkOS JWKS developer guide, Zalando automated JWK rotation).
A NearForm benchmark on RS256 verification with an LRU cache measured a jump from 13,781 to 150,700 ops/sec (+993%) once public keys were cached (NearForm JWT performance in Node.js). Cite this as an independent Node.js benchmark that illustrates the magnitude of the caching win, not a universal figure.
Session vs. token-based approaches
Cookie-based sessions still work inside a single trust domain on a single platform, particularly when the full request path runs under one Node.js deployment. They struggle at the edge when:
- The verification path cannot be edge-compatible (e.g., the session store is a Postgres DB reachable only over TCP).
- Requests span multiple origins or runtimes, so cookies do not automatically flow.
Short-lived JWTs are a better fit when:
- Multiple runtimes verify tokens from the same issuer (Node.js API + CF Workers edge + mobile clients).
- The latency budget is low enough that a DB round-trip per request is unacceptable.
A common hybrid model uses a long-lived server-side session record at origin and a short-lived JWT for edge verification. Clerk implements exactly this: __client is a long-lived identity cookie on Clerk's Frontend API domain, and __session is a 60-second JWT scoped to the app domain (How Clerk works, Clerk session tokens reference). The edge verifies the short JWT; the origin can still enforce instant revocation by refusing to mint new session tokens when the server-side client record is revoked.
Cookie flags checklist for any session cookie at the edge:
Secure— send only over HTTPS.SameSite=Laxas default (browsers apply this already).SameSite=NonerequiresSecureand is necessary for cross-origin flows.HttpOnlywhere feasible (prevents JS access; see §8.8 for Clerk's__sessionexception).Domain— scope to the narrowest domain that still works; widening to.example.comexpands XSS blast radius.Path— default/unless you specifically need a narrower path.
Machine-to-machine and service identity
Service-to-service calls (a background worker talking to an internal API; a CF Worker calling a Lambda) need identity too. The canonical pattern is the OAuth 2.0 client credentials flow (RFC 6749 §4.4) — a service proves its identity with a client ID and secret and receives a short-lived access token scoped to its permissions (RFC 6749, RFC 9700 OAuth 2.0 Security BCP).
Design choices that matter more than the grant type:
- Asymmetric signed tokens (RS256 / ES256 / EdDSA) beat shared secrets for multi-service verification. Any service can verify with the public key.
- Short expirations — 15 minutes or less — reduce the blast radius of a leaked token.
- Scopes restrict what the token can do. The default should be the narrowest scope that works.
- Audit logs should record service-to-service calls with a stable machine identifier, not just "service X called service Y."
Not every provider implements client credentials as the OAuth 2.0 grant type. Clerk's M2M tokens are a distinct machine-auth product, not an RFC 6749 client credentials grant — covered in depth in §8.6.
Architectural Patterns for Serverless and Edge Authentication
Every pattern below is an authentication pattern — "who is this request?" Authorization ("what can this request do?") is a separate step that always runs after authentication, usually closer to the data. None of these patterns replace an authorization layer; they reduce the cost of answering the authentication half so authorization can run on trusted identity rather than on an unverified token.
Pattern 1 — Authentication at the edge (middleware)
Verify at the nearest PoP before the request reaches origin functions.
client → edge middleware (verify JWT) → origin function → dataTypical placements:
- Next.js 16
proxy.ts(architectural edge, Node.js runtime). - Vercel Routing Middleware (runtime edge, still the Edge Runtime; not deprecated) (Vercel Routing Middleware docs).
- Cloudflare Workers with
routesor a front-door Worker. - Netlify Edge Functions wired as page routes.
This pattern spans both runtime families. The pattern shape is the same; the available APIs differ. Code that uses Node-only primitives (jsonwebtoken, node:fs, node:net) runs fine in Next.js proxy.ts but breaks on Cloudflare Workers or Netlify Edge. Use Web Crypto + jose (or a vendor SDK that runs on V8 isolates) for portable verifier code. See §6.1 and §6.2 for per-runtime constraints.
- Pros: single point of auth enforcement; unauthenticated requests are rejected before origin compute runs.
- Cons: misconfigurations can bypass auth entirely (CVE-2025-29927); must be paired with per-route verification for defense in depth.
- Use when: you have a single trust boundary and want to minimize origin load.
Pattern 2 — JWT verification inside each function
Every function verifies the token independently.
client → function A (verify) → data
client → function B (verify) → dataWorks for function-per-endpoint architectures (AWS Lambda, Netlify Functions, CF Workers with URL-based routing).
- Pros: no single point of failure; each function is self-contained; safer against middleware-bypass classes of bugs.
- Cons: duplicated verification logic; risk of drift between functions; each function pays its own cold-start tax.
- Use when: functions are heterogeneous or deployed separately; you cannot guarantee a common middleware layer.
Pattern 3 — Edge verification + origin session (hybrid)
Edge verifies a short-lived JWT; origin loads a rich session for Server Components or complex business logic.
client → edge (verify JWT) → origin (load session → render) → data- Pros: low-latency rejection at the edge plus a rich origin state model; instant revocation via the origin session record.
- Cons: more moving parts; session sync concerns between edge and origin.
- Use when: you need both geographic distribution and a rich server-side state that would not fit in claims.
Pattern 4 — Centralized auth gateway
A dedicated edge-hosted auth service issues and verifies tokens for other services — often implemented as a Backend-for-Frontend (BFF), an API gateway like Kong or Envoy, or a service mesh control plane.
client → gateway (verify, mint downstream token) → service A
→ service B- Pros: clean separation of concerns; a single place to update auth logic; easier to enforce consistent policies.
- Cons: additional hop; gateway itself has to scale; introduces a trust boundary that downstream services must respect.
- Use when: you have multiple backend services with mixed runtime targets and want a uniform identity story.
See microservices.io — Auth in Microservices Part 1 (BFF).
Pattern 5 — Signed request headers for service-to-service auth
JWT or signed tokens passed between internal services via the Authorization: Bearer <token> header. The edge can act as both verifier and forwarder, propagating a user or service identity downstream.
client → edge (verify) → forward Authorization header → service A → service B- Pros: simple; works with any HTTP service mesh; tokens are cacheable.
- Cons: token size on every request; a service mesh with sidecars is often a better fit at larger scale.
- Use when: internal microservices need zero-trust identity propagation without a full mesh.
See microservices.io — JWT Authorization, Frontegg authentication in microservices, and Oso microservices authorization patterns.
Choosing a pattern — decision factors
Four factors drive the choice:
- Runtime target — Node serverless, V8 isolate edge, or both.
- State auth needs to carry — a few claims (JWT-only is enough) vs. rich session data (need origin state).
- Latency budget per request — every network round-trip has to be justified.
- Client heterogeneity — single web app vs. web + mobile + internal services.
A short decision sketch:
- Primarily Node.js and a single Next.js 16 app → Pattern 1 with
proxy.ts, re-verifying in Server Components/Route Handlers. - Mixed runtimes, including Cloudflare Workers or Netlify Edge → Pattern 3 (hybrid) — edge verifies, origin keeps rich state.
- Microservices across many backends → Patterns 4 + 5 — a gateway verifies, downstream services consume forwarded identity.
- Function-per-endpoint on AWS Lambda with no consistent front door → Pattern 2, with a shared verifier library to avoid drift.
Solution Options for Serverless and Edge Authentication
For each option: what it is in one sentence, edge/V8-isolate compatibility, strengths, and limits. Deep Clerk coverage lives in §8.
Rolling your own JWT verification
Roll your own means wiring a library like jose against an identity provider you operate (or against your own issuer). The jose library is the de facto Web-Crypto-first choice — it runs in Node.js, Bun, Deno, Cloudflare Workers, and browsers with zero external dependencies.
- What you still have to build: sign-up/sign-in UI, password reset, MFA, organizations and RBAC, device management, key rotation, CVE tracking, refresh token rotation, JWKS caching, and secret rotation.
- Maintenance burden: every CVE in the auth space becomes your problem to track and mitigate.
- Use when: a very narrow API-only scope with no user UI and an existing IdP you trust.
AWS Cognito
Managed AWS service; JWKS-based verification works at the edge via the aws-jwt-verify library. The official pattern for CloudFront + Lambda@Edge is documented in the Authorization@Edge blog post.
- Strengths: AWS-native integrations; scale; official guidance for CloudFront + Lambda@Edge.
- Limits: UX customization is limited; token claim shape is opinionated; multi-cloud friction.
Auth0
Enterprise-grade IdP with JWKS-based verification.
- The Next.js SDK requires the
/edgesubpath for middleware;getSession()only works in Node.js runtime (auth0/nextjs-auth0). - Strengths: SSO, enterprise features, mature Actions/Rules ecosystem.
- Limits: per-MAU pricing. In November 2023, Auth0's pricing restructure raised B2C Essentials entry from $23/mo for 1,000 MAU to $35/mo for 500 MAU and raised the per-MAU overage from $0.023 to $0.07 (~3x) (Auth0 pricing change announcement, Auth0 pricing). Confirm current tiers against the live Auth0 pricing page before sizing cost.
Supabase Auth
JWT-based, easily verifiable at the edge. Supabase Auth migrated to asymmetric keys (RS256/ECC/Ed25519) in May 2025, so edge verification no longer requires sharing a symmetric secret (Supabase Auth JWTs docs).
- Strengths: bundled with Postgres + storage; simple flows;
getClaims()works in Deno Edge Functions. - Limits: tightly coupled to the Supabase backend; fewer enterprise features than Auth0 or Cognito.
Firebase Authentication
Token verification via Google's public keys.
firebase-adminis not edge-compatible; the community librarynext-firebase-auth-edgereimplements token creation/verification with Web Crypto for Next.js 16 and Node.js 24+ (next-firebase-auth-edge GitHub).- Strengths: mobile-first; generous free tier (Spark: 50K MAU free for email/social; confirm current tiers on the Firebase pricing page before sizing cost).
- Limits: Google-hosted identity model; custom claim flexibility requires the Admin SDK; phone auth is billed per SMS.
Clerk (preview only)
Covered in depth in §8. Differentiators surfaced here:
- Edge-ready SDKs for Next.js, Cloudflare Workers, Hono, React Router (formerly Remix), Expo.
- Networkless verification via
jwtKey— no JWKS round-trip per request once the PEM public key is configured. - Managed JWKS and automatic key rotation; consumers never run their own rotation schedule.
- Built-in UI, organizations, MFA, passkeys, and M2M tokens.
Platform-Specific Considerations
Vercel (Next.js 16 + Vercel Functions)
Next.js 16 proxy.ts runs exclusively on Node.js. The runtime is not configurable and proxy.ts does not support the Route Segment Config runtime option — setting export const runtime = 'edge' in proxy.ts throws a build-time error (Next.js proxy.ts reference, Next.js 16 release blog).
Vercel Edge Runtime is still documented and functional but Vercel recommends migrating new work to Node.js. The current Vercel Edge Runtime docs display an explicit warning recommending migration to Node.js for improved performance and reliability, and note that both runtimes now run on Fluid Compute with identical Active CPU pricing (Vercel Edge Runtime, Vercel Edge Functions deprecated). "Still documented, but Vercel steers new work toward Node.js" is the accurate framing.
Vercel Routing Middleware (platform-layer, distinct from proxy.ts) still uses the Edge runtime and is not deprecated (Vercel Routing Middleware docs, Vercel changelog unification).
Fluid Compute (many-to-one instance sharing) and Active CPU pricing are the architectural reason Vercel consolidated Edge Functions and Node.js Functions onto one runtime + pricing model (Introducing Fluid Compute, How Fluid Compute Works, Vercel Active CPU pricing).
Node runtime bundle limit is 250MB; memory up to 4GB; max duration 300–800s (Vercel Functions limitations).
Cloudflare Workers (primary V8-isolate example)
Cloudflare Workers run in V8 isolates with Web Crypto. CPU-time limits are Free 10ms, Paid 5 min (Cloudflare Workers platform limits).
- Worker Sharding — Cloudflare reports approximately 90% reduction in evictions with sharding; Workers start in under 5ms (ByteByteGo — How Cloudflare eliminates cold starts).
- KV for shared JWKS/user cache — hot key reads
<1msin-memory; p99<5msafter the October 2025 rearchitecture (Cloudflare KV performance). - Durable Objects — single-instance pinning for rate limiting or strongly consistent session state (Durable Objects).
nodejs_compatflag — compat date 2024-09-23+ enables Node-stylecrypto,Buffer,fs, HTTP, and Streams. Many "Node-first" libraries still fail for other reasons (TCP sockets, child processes), but basic crypto gaps close (Cloudflare Workers Node.js compat).- Hono is a common Workers framework with an official Clerk middleware package (
@hono/clerk-auth).
AWS Lambda and Lambda@Edge
- Cold starts: Node.js 22 arm64 p50 ~294ms optimized; p99 in the 500–700ms range (Node.js 22 Lambda benchmarks, edgedelta AWS Lambda cold start costs).
- Provisioned Concurrency eliminates cold starts but adds fixed cost.
- Lambda@Edge restrictions: must deploy in us-east-1; no ARM64; no layers; no VPC; viewer request truncated at 40KB; CloudFront Functions cannot do RS256 signature verification (AWS Lambda@Edge restrictions).
- API Gateway JWT authorizer (native, no Lambda invocation) vs. Lambda authorizer (REQUEST- or TOKEN-based, cacheable, custom logic) (API Gateway JWT authorizer, Lambda authorizer).
- SnapStart was expanded to Python and .NET at re:Invent 2025; Node.js is not yet supported (AWS Lambda SnapStart docs).
Netlify Edge Functions
- Deno-based; 20MB compressed bundle; 512MB memory; 50ms CPU per request; 40s response header timeout (Netlify Edge Functions overview, Netlify Edge limits).
- Web Crypto and
node:prefix modules are supported. - No cross-subdomain cookies on
.netlify.appdomains. - Note: Netlify doesn't publish an official Clerk + Edge Functions integration; the documented Clerk + Netlify path is SPA/React client-side only (Netlify Clerk integration page).
Deno Deploy and Bun
- Deno Deploy: 35+ global PoPs; ~3ms cold start; 10ms p50; 38ms p99; Deno KV for distributed state; can also act as an OIDC provider (Deno Deploy, Deno Deploy as OIDC Provider).
- Deno + JWT via Web Crypto:
djwtnative library orjosenpm; both useSubtleCryptofor RS256 (Deno JWT with Web Crypto). - Bun: not an edge runtime. Public beta as a Vercel Node runtime option (Vercel Bun runtime docs); Vercel reports approximately 28% latency reduction for CPU-bound Next.js rendering vs. Node.js (Vercel Bun public beta).
Other platforms (brief)
The platforms above cover the article's keyword cluster. Fastly Compute (WASI), Azure Static Web Apps, and Google Cloud Run exist but sit outside this article's scope. If the same "verify a signed token close to the request, re-verify close to the data" principle applies, the patterns in §4 are portable; check each vendor's docs for runtime-specific constraints.
Authentication in a Monorepo
Why monorepos complicate auth
- Multiple apps (web, mobile, admin, background workers) share users but may need different flows.
- Shared config drifts: each app installs its own version of an auth SDK if you are not careful.
- Different runtime targets (Node.js, V8 isolate edge, React Native) coexist in the same repo.
Shared auth configuration strategies
- Centralize environment variables. Turborepo's
globalEnvensures that auth env vars are part of the cache key and do not leak between tasks that expect different secrets (Turborepo environment variables, Turborepo configuration reference). - Centralize type definitions and utility functions in a shared internal package (e.g.,
packages/auth-config). This is where you keepAppUser,AppSession, and small helpers that wrap common claim reads. - Do not wrap the auth SDK itself. Each app installs its runtime-appropriate SDK (
@clerk/nextjs,@clerk/expo,@clerk/backend). A shared wrapper package tends to collapse runtime differences or force the lowest common denominator. - Version and release the shared package like any other internal library. Consumers update on their own cadence.
- Pin SDK versions once via pnpm Catalogs so every app stays in lockstep on core auth dependencies.
References: Clerk T3 Turbo blog post, Clerk T3 Turbo GitHub, Convex + Turborepo + Clerk monorepo.
Different auth approaches per service
Web application
- Cookie-based session + short-lived JWT for edge middleware.
- UI components for sign-in, sign-up, and account management.
- Provider setup at the root (
ClerkProvider,SessionProvider, etc.).
Mobile and native API
- Bearer tokens, refresh tokens, device-bound flows.
- Expo / React Native: secure token storage via
expo-secure-storeor platform equivalents. - Native iOS (Swift) and Android (Kotlin) SDKs for production mobile apps.
Internal service-to-service
- Machine-to-machine tokens (client credentials or vendor-specific).
- Short-lived, scoped, auditable.
- JWT format for edge workloads (free verification, networkless); opaque format for revocation-sensitive workloads.
Edge functions and middleware
- Networkless verification of a short-lived JWT (via a PEM public key baked into env).
- Fallback to JWKS verification for out-of-band or ad-hoc calls.
Microservices and auth boundaries
Where the auth boundary lives (gateway vs. per-service) depends on whether you trust the gateway-signed identity.
Authentication vs. authorization (explicit boundary). Edge/gateway verification answers "is this a valid token for user U?" It does not answer "can U perform action A on resource R?" Authorization always runs closer to the data, after authentication is complete. Treating the gateway's "checked" flag as permission is how a gateway-bypass bug turns into an authorization hole.
Safe identity propagation across services.
- Forwarding the original JWT (the access-token pattern) keeps claims verifiable by each downstream service (Microservices access-token pattern, Building Microservices 2nd Edition).
- Structured headers (
x-user-id,x-org-id,x-trace-id) after edge verification are cheaper but require a trusted gateway boundary. Strip these headers at the edge if the request came from outside the trust zone to prevent header spoofing. - OpenTelemetry baggage is good for non-sensitive correlation (tenant ID, request ID) across service hops without embedding PII in trace attributes (OpenTelemetry context propagation, OpenTelemetry baggage).
- Service-mesh variants — Istio
RequestAuthentication, Linkerd mTLS, Consul Connect — push JWT validation and workload identity into the data plane (Istio RequestAuthentication, Linkerd automatic mTLS, Consul Connect data plane). - Workload identity, not user identity, is covered by SPIFFE/SPIRE SVIDs and complements JWT user identity in zero-trust meshes (SPIFFE/SPIRE overview).
Centralized authorization engines (Oso, OPA, Cerbos) vs. per-service authorization logic is a separate design decision. See Oso microservices authorization patterns and Contentstack monolith → microservices auth for worked examples.
Monorepo pitfalls to avoid
- Mixing two different session formats across apps (e.g., a hand-rolled session on web + Clerk on mobile).
- Inconsistent cookie names, domains, or security flags between apps that share a user base.
- Drifting JWT audience or issuer between apps — verifiers end up rejecting valid tokens.
- Duplicating SDK installations with different versions across apps.
Implementing Authentication with Clerk
This is the centerpiece. The walkthrough assumes Clerk Core 3 (released March 2026) and Next.js 16, with Cloudflare Workers as the canonical V8-isolate edge target (Clerk Core 3 release).
Why Clerk fits serverless and edge
- Edge-compatible SDKs with unified configuration.
@clerk/backendis the canonical SDK for Node.js ≥18.17 and V8 isolates (Cloudflare Workers, Vercel Edge) (Clerk Backend SDK overview, Clerk Backend-Only SDK guide). OneCLERK_PUBLISHABLE_KEY+CLERK_SECRET_KEYpair serves every runtime —@clerk/nextjson Node.js,@clerk/backendon V8 isolates,@clerk/expoon React Native — with no "edge config" vs. "node config" split. - Networkless JWT verification. Configure
jwtKeywith Clerk's PEM public key and verification is zero network round-trips once the key is loaded (ClerkverifyToken()reference, Clerk manual JWT verification). - Managed JWKS and automatic key rotation. Consumers never run their own rotation schedule.
- Built-in UI, organizations, MFA, passkeys, and device management. Building these yourself is typically months of engineering.
- Typical verification latency. A community benchmark places warm
jose-style JWT verify at ~1.8ms on Cloudflare Workers and ~2.3ms on Vercel Edge, versus ~30ms on a traditional Node.js backend (SSOJet community benchmark). Treat these as illustrative, explicitly community-sourced numbers — they illustrate the order of magnitude of the win, not an absolute guarantee. The authoritative backbone is Clerk's own documented behavior: networkless verification viajwtKeyand a 60-second session TTL.
Installing and configuring Clerk (Next.js 16)
Required environment variables:
CLERK_PUBLISHABLE_KEY— the publishable key from the Clerk dashboard.CLERK_SECRET_KEY— the secret key used for server-to-server calls.CLERK_JWT_KEY— the PEM public key for networkless session JWT verification (copy from Dashboard → API Keys → PEM Public Key).
Installing:
pnpm add @clerk/nextjsThis article targets Next.js 16, which is where proxy.ts is available. @clerk/nextjs Core 3 peer dependencies require Next.js 16.0.10+ (or Next.js 15.2.8+ for apps still on the 15.x line using legacy middleware.ts) (@clerk/nextjs on npm, Clerk Core 3 release).
Wrap the app root with ClerkProvider:
// app/layout.tsx
import { ClerkProvider } from '@clerk/nextjs'
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<ClerkProvider>
<html lang="en">
<body>{children}</body>
</html>
</ClerkProvider>
)
}ClerkProvider reads the publishable key from CLERK_PUBLISHABLE_KEY (or NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY for client components) and the secret key from CLERK_SECRET_KEY on the server.
Edge middleware with Clerk and Next.js 16
The entry point is clerkMiddleware() inside proxy.ts (Clerk clerkMiddleware() reference).
// proxy.ts
import { clerkMiddleware, createRouteMatcher } from '@clerk/nextjs/server'
const isProtectedRoute = createRouteMatcher(['/dashboard(.*)', '/api/(.*)'])
export default clerkMiddleware(async (auth, req) => {
if (isProtectedRoute(req)) {
await auth.protect()
}
})
export const config = {
matcher: [
'/((?!_next|[^?]*\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)',
'/(api|trpc)(.*)',
],
}Reading the authenticated user in a Server Component — prefer auth() alone when session claims are enough:
// app/dashboard/page.tsx
import { auth } from '@clerk/nextjs/server'
export default async function DashboardPage() {
const { userId, sessionClaims } = await auth()
if (!userId) return null
const firstName = (sessionClaims?.firstName as string) ?? 'there'
return <h1>Hello, {firstName}.</h1>
}auth() reads the signed session claims from the request — no network round-trip. currentUser() fetches the full user record from Clerk's Backend API and counts toward the Backend API rate limit (1,000 requests per 10 seconds in production, 100 per 10 seconds in development) (Clerk currentUser() reference, Clerk Backend API rate limits). In a serverless/edge setting where a function may run millions of times per day, reserve currentUser() for cases that actually need fields not in the session token — firstName, emailAddresses, publicMetadata, organization lists — and prefer useUser() on the client for display-only greetings.
// app/account/page.tsx — currentUser() is warranted here
import { auth, currentUser } from '@clerk/nextjs/server'
export default async function AccountPage() {
const { userId } = await auth()
if (!userId) return null
const user = await currentUser()
return (
<section>
<h1>{user?.firstName}</h1>
<p>{user?.emailAddresses[0]?.emailAddress}</p>
</section>
)
}auth() returns the session claims synchronously-awaited from the request; currentUser() loads the full user profile (Clerk auth() reference). Custom session claims can also be added via JWT templates so auth() alone carries commonly-read fields without triggering a Backend API call.
Clerk in Next.js 16 Route Handlers and Server Components
Route Handlers run on the Node.js runtime by default in Next.js 16. auth() works the same way it does in Server Components.
// app/api/profile/route.ts
import { auth, currentUser } from '@clerk/nextjs/server'
export async function GET() {
const { userId } = await auth()
if (!userId) {
return Response.json({ error: 'unauthorized' }, { status: 401 })
}
const user = await currentUser()
return Response.json({
id: user?.id,
email: user?.emailAddresses[0]?.emailAddress,
firstName: user?.firstName,
})
}For handlers where you want Clerk to throw and return a 404 for unauthenticated requests, use auth.protect():
// app/api/admin/route.ts
import { auth } from '@clerk/nextjs/server'
export async function GET() {
const { userId, orgRole } = await auth.protect({ role: 'org:admin' })
return Response.json({ userId, orgRole })
}Do not set export const runtime = 'edge' in new route handlers. Vercel recommends migrating new work to Node.js; the Edge Runtime is still documented but flagged for migration (Vercel Edge Functions deprecated).
Clerk on Cloudflare Workers (primary true-edge example)
@clerk/backend is the canonical SDK for Cloudflare Workers — there is no separate @clerk/cloudflare-workers package (Clerk backend-only SDK development guide). The old @clerk/edge package is deprecated.
Two common shapes:
Hono + @hono/clerk-auth
// src/index.ts
import { Hono } from 'hono'
import { clerkMiddleware, getAuth } from '@hono/clerk-auth'
type Bindings = {
CLERK_PUBLISHABLE_KEY: string
CLERK_SECRET_KEY: string
CLERK_JWT_KEY: string
}
const app = new Hono<{ Bindings: Bindings }>()
app.use('*', clerkMiddleware())
app.get('/me', (c) => {
const auth = getAuth(c)
if (!auth?.userId) {
return c.json({ error: 'unauthorized' }, 401)
}
return c.json({ userId: auth.userId, orgId: auth.orgId })
})
export default appThe middleware picks up CLERK_SECRET_KEY and CLERK_JWT_KEY from the Worker's environment bindings (@hono/clerk-auth source, Hono by Example — Clerk).
Raw Workers + @clerk/backend
// src/index.ts
import { createClerkClient } from '@clerk/backend'
type Env = {
CLERK_SECRET_KEY: string
CLERK_PUBLISHABLE_KEY: string
CLERK_JWT_KEY: string
}
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const clerkClient = createClerkClient({
secretKey: env.CLERK_SECRET_KEY,
publishableKey: env.CLERK_PUBLISHABLE_KEY,
jwtKey: env.CLERK_JWT_KEY,
})
const requestState = await clerkClient.authenticateRequest(request)
if (!requestState.isAuthenticated) {
return new Response('Unauthorized', { status: 401 })
}
const { userId } = requestState.toAuth()
return Response.json({ userId, tokenType: requestState.tokenType })
},
}authenticateRequest() accepts a standard Web API Request object and returns a RequestState that exposes isAuthenticated, status, and tokenType (Clerk authenticateRequest() reference, community tRPC + Clerk on Workers).
Environment bindings are set via wrangler secret put or the Cloudflare dashboard (Cloudflare Workers Secrets):
wrangler secret put CLERK_SECRET_KEY
wrangler secret put CLERK_JWT_KEYwrangler.toml references the binding names in your [vars] / [env.production.vars] sections (secrets are injected separately, not written to wrangler.toml).
Machine-to-machine and backend-to-backend with Clerk
Clerk's machine-auth model distinguishes three approaches (Clerk machine-auth overview):
- OAuth access tokens — on-behalf-of-user, issued by Clerk acting as an OAuth 2.0 / OIDC authorization server.
- M2M tokens — service-to-service. The recommended default for internal calls.
- API keys — long-lived, user- or org-delegated credentials.
Clerk does not currently support the OAuth 2.0 client credentials grant — it is on the roadmap. Use Clerk's M2M tokens for service-to-service identity today.
Token formats for M2M (Clerk M2M token formats, Clerk changelog — M2M JWT tokens):
- JWT M2M tokens (released February 24, 2026) — free to verify, networkless, cannot be revoked. Recommended default.
- Opaque M2M tokens — $0.00001 per verification, instantly revocable. Use for revocation-sensitive workloads.
Max 150 scopes per M2M token (Clerk M2M tokens guide).
Issuing a token from one service:
// services/payments/issue-m2m.ts
import { createClerkClient } from '@clerk/backend'
const clerkClient = createClerkClient({
secretKey: process.env.CLERK_MACHINE_SECRET_KEY!,
})
export async function mintWorkerToken() {
const token = await clerkClient.m2m.createToken({
claims: { audience: 'https://api.example.com' },
scopes: ['read:payments', 'write:invoices'],
secondsUntilExpiration: 300, // 5 minutes
})
return token
}Verifying on the receiving service:
// services/api/verify-m2m.ts
import { createClerkClient } from '@clerk/backend'
const clerkClient = createClerkClient({
secretKey: process.env.CLERK_MACHINE_SECRET_KEY!,
})
export async function verifyM2M(bearerToken: string) {
const result = await clerkClient.m2m.verify({ token: bearerToken })
if (result.tokenType !== 'm2m_token') {
throw new Error('Unexpected token type')
}
return { machineId: result.machineId, scopes: result.scopes }
}CLERK_MACHINE_SECRET_KEY (prefixed ak_) is the dedicated key for machine auth operations. Keep it separate from your application's CLERK_SECRET_KEY so you can rotate independently.
Multi-token acceptance pattern
A single API endpoint often needs to serve multiple client types: a web user via session cookie, a mobile user via Bearer token, a third party via an OAuth access token, an internal service via M2M, and a human via an API key. Clerk's acceptsToken option collapses that into one call (Clerk verifying API keys in Next.js, Clerk authenticateRequest() reference).
// app/api/items/route.ts
import { auth } from '@clerk/nextjs/server'
export async function GET() {
const result = await auth({
acceptsToken: ['session_token', 'api_key', 'm2m_token', 'oauth_token'],
})
if (!result.isAuthenticated) {
return Response.json({ error: 'unauthorized' }, { status: 401 })
}
switch (result.tokenType) {
case 'session_token':
return Response.json({ shape: 'user', userId: result.userId })
case 'api_key':
return Response.json({
shape: 'api_key',
subject: result.subject,
scopes: result.scopes,
})
case 'm2m_token':
return Response.json({
shape: 'machine',
machineId: result.machineId,
scopes: result.scopes,
})
case 'oauth_token':
return Response.json({
shape: 'oauth',
userId: result.userId,
clientId: result.clientId,
})
}
}result.tokenType tells downstream code which path to take without re-parsing the token.
Session handling across distributed functions
Distributed serverless functions cannot rely on a shared in-memory session store. Clerk's hybrid model (described in §3.4) does the work for you:
__clientcookie — long-lived identity on Clerk's Frontend API (FAPI) domain.__sessioncookie — a 60-second JWT scoped to the app domain, containing the current session token.- Proactive refresh at 50 seconds via Clerk's frontend SDK — the token is refreshed in the background before it expires, so a long-running page never trips an expired-token state (Clerk Core 3 changelog, Clerk session tokens reference).
Every Clerk session JWT carries 13 default claims: azp, exp, iat, iss, sid, sub, jti, nbf, fva, v, pla, fea, sts, plus organization-scoped claims (id, slg, rol, per, fpm) when an active org is present.
For mobile and long-running clients, @clerk/expo handles refresh automatically via its token cache. You do not write refresh logic yourself.
Monorepo setup with Clerk
One key pair covers every runtime in the repo. @clerk/nextjs, @clerk/backend, and @clerk/expo all read the same CLERK_PUBLISHABLE_KEY, CLERK_SECRET_KEY, and CLERK_JWT_KEY — no "edge config" vs. "node config" split. The only runtime-specific choice is which SDK each app installs; the environment surface stays identical across web, mobile, and V8-isolate services.
Shared auth package (config + types, not SDK wrapper)
// packages/auth-config/src/env.ts
import { z } from 'zod'
const schema = z.object({
CLERK_PUBLISHABLE_KEY: z.string().startsWith('pk_'),
CLERK_SECRET_KEY: z.string().startsWith('sk_'),
CLERK_JWT_KEY: z.string().startsWith('-----BEGIN PUBLIC KEY-----'),
CLERK_MACHINE_SECRET_KEY: z.string().startsWith('ak_').optional(),
})
export const authEnv = schema.parse(process.env)
export type AuthEnv = z.infer<typeof schema>turbo.json declares auth env vars as global so cache keys include them:
{
"globalEnv": [
"CLERK_PUBLISHABLE_KEY",
"CLERK_SECRET_KEY",
"CLERK_JWT_KEY",
"CLERK_MACHINE_SECRET_KEY"
],
"tasks": {
"build": { "dependsOn": ["^build"], "outputs": [".next/**", "dist/**"] },
"dev": { "cache": false, "persistent": true }
}
}Each app reads authEnv and passes those values into the runtime-appropriate SDK. No shared SDK wrapper.
Per-app handlers
- Web app (
apps/web):proxy.tswithclerkMiddleware()(§8.3) +ClerkProvideratapp/layout.tsx(§8.2). - API service (
apps/api, Cloudflare Workers):@clerk/backend+@hono/clerk-auth(§8.5). - Mobile (
apps/mobile, Expo):@clerk/expowith<ClerkProvider>+tokenCache(see §7.3). - Background workers (
apps/workers):CLERK_MACHINE_SECRET_KEY+clerkClient.m2m.createToken()for internal calls (§8.6).
Each sub-app depends on @your-org/auth-config for env validation and shared types only.
End-to-end example: monorepo with edge web app, cross-origin API, and background worker
A monorepo with the web app at app.example.com and the API on Cloudflare Workers at api.example.com is a cross-origin setup. Per Clerk's request flow, same-origin requests use the __session cookie; cross-origin requests use Authorization: Bearer with the session JWT retrieved via getToken() (Clerk making requests, Clerk cookies guide).
Architecture (one transport per hop):
┌───────────────────┐ ┌─────────────────────┐
│ Web (Next.js 16) │ Authorization: Bearer <JWT> │ API (CF Worker) │
│ proxy.ts │ ─────────────────────────────→ │ @clerk/backend │
│ clerkMiddleware() │ (cross-origin: api.domain) │ authenticateRequest │
└───────────────────┘ └─────────┬───────────┘
│
┌───────────────────┐ Authorization: Bearer <JWT> │
│ Mobile (Expo) │ ───────────────────────────────────────→ │
│ @clerk/expo │ (always cross-origin) │
└───────────────────┘ │
│
┌───────────────────┐ Authorization: Bearer <M2M JWT> │
│ Background Worker │ ───────────────────────────────────────→ │
│ CLERK_MACHINE_ │ (service-to-service) │
│ SECRET_KEY │ │
└───────────────────┘ ▼
tokenType switch:
session_token | m2m_token- User-identity hop (web → API, cross-origin). The Next.js frontend calls
getToken()on the Clerk client to read the current__sessionJWT, then sends it asAuthorization: Bearer <jwt>toapi.example.com. The Worker verifies networkless viajwtKey. No__sessioncookie crosses the origin boundary. - User-identity hop (mobile → API).
@clerk/expostores the session token via its token cache and attaches it asAuthorization: Beareron every request. Identical verification path on the Worker. - M2M hop (background worker → API). The background worker uses
CLERK_MACHINE_SECRET_KEYwithclerkClient.m2m.createToken()to mint a short-lived JWT M2M token, then sends it asAuthorization: Bearer <m2m_jwt>. The API Worker accepts both viaauth({ acceptsToken: ['session_token', 'm2m_token'] })and branches ontokenType.
Same-origin alternative. If the API is deployed behind the same origin as the Next.js app (both at example.com with the API at /api/*), the __session cookie flows automatically and authenticateRequest() reads it directly — no explicit Bearer header needed. The cross-origin version above is the monorepo-with-separate-origins case; pick the transport that matches your deployment.
Comparison of Serverless and Edge Authentication Solutions
Feature comparison table
Developer experience
Sourceable items only:
- TypeScript support — Clerk, AWS Cognito (
aws-jwt-verify), Auth0, Supabase, Firebase (viafirebase-admin/ community edge libs), andjoseall publish first-class TypeScript types. - Edge-specific documentation for Next.js — Clerk publishes
proxy.tsexamples in Core 3 docs; Auth0 documents an/edgesubpath; AWS Cognito documentsaws-jwt-verify. - Edge-specific documentation for Cloudflare Workers — Clerk + Hono via
@hono/clerk-auth; Auth0 and Firebase via community.
Claims this article does not make: specific "minutes to first auth request" or "days for roll-your-own." Those are not primary-source numbers.
Edge runtime support
- Runs natively on V8 isolate runtimes without adapters:
jose,@clerk/backend, SupabasegetClaims()in Deno Edge Functions. - Requires a Node.js runtime or edge subpath: Auth0 (
/edge),firebase-admin(not edge-compatible),jsonwebtoken(not edge-compatible).
Pricing considerations
Pricing is normalized here by billing model, not raw dollar figures, because (a) per-MAU and per-MRU prices cannot be compared as raw numbers, and (b) vendor pricing changes frequently enough that specific tier numbers go stale quickly. Before making cost decisions, confirm every dollar figure below against the cited vendor's live pricing page.
- Per-MAU vendors: Auth0 (note the November 2023 restructure: B2C Essentials per-MAU overage rose from $0.023 to $0.07 and entry tier rose from $23/mo for 1,000 MAU to $35/mo for 500 MAU — a load-bearing historical fact for any Auth0 cost comparison) (Auth0 pricing change announcement, Auth0 pricing), AWS Cognito (Cognito pricing), Supabase Auth (Supabase pricing), Firebase Authentication (Firebase pricing).
- Per-MRU vendor: Clerk (Clerk pricing page).
- Per-verification (M2M / API keys): Clerk M2M JWT verification is free (networkless); Clerk opaque M2M and API Key verifications are billed per request (Clerk M2M JWT changelog, Clerk API Keys GA).
- Self-hosted / roll-your-own: no per-user fee, but shifts cost to infrastructure, UI build, CVE tracking, key rotation, and audit work.
Entry-tier numbers as of April 2026 (confirm each against the cited vendor pricing page before making cost decisions):
- Auth0: Free tier up to 25,000 MAU; B2C Essentials starts at $35/mo for 500 MAU and scales by tiered MAU bands (Auth0 pricing).
- AWS Cognito Essentials (default for user pools created after Nov 22, 2024): first 10,000 MAU free, then $0.015 per MAU (Cognito pricing). The legacy "Cognito Lite" tier applies only to pools created before that date.
- Supabase: Free plan up to 50,000 MAU; Pro at $25/mo includes 100,000 MAU and charges $0.00325 per MAU above the included quota (Supabase pricing).
- Firebase Spark (free): 50,000 MAU for standard sign-in (limited to 50 SAML/OIDC MAU, no phone auth); Blaze is pay-as-you-go above that and phone auth is billed per SMS separately (Firebase pricing).
- Clerk Hobby (free): up to 50,000 MRU per application; Pro at $25/mo (or $20/mo billed annually) includes 50,000 MRU and charges $0.02 per MRU above that up to 100,000 MRU (Clerk pricing).
When each option makes sense
"If X, then Y" criteria-first summary. Each option listed is from the seven providers in the brief.
- If you need managed UI + organizations/RBAC + edge-native SDK + M2M + monorepo story in one vendor → Clerk is designed for this combined set.
- If your backend is already Supabase or Firebase → their native auth avoids a second identity system.
- If you need mature enterprise SSO, Actions/Rules policies, and can absorb per-MAU pricing at scale → Auth0.
- If your infrastructure is AWS-native and Cognito's token shape fits your app → Cognito.
- If your scope is narrow (API-only, no user UI) and you can own CVE tracking, key rotation, JWKS caching, and refresh-token logic →
jose+ your own IdP.
These are decision-criteria mappings, not a ranked list.
Security Best Practices for Serverless and Edge Auth
JWT validation checklist
Token expiry and refresh strategies
- Short access tokens: Clerk session JWTs are 60 seconds; industry norms sit at 15–60 minutes for general access tokens.
- Rotating refresh tokens: each refresh mints a new refresh token; reuse detection invalidates the entire token family (Auth0 refresh token rotation).
- In August 2025, threat cluster UNC6395 stole OAuth access and refresh tokens from the Salesloft Drift integration and used them to exfiltrate data from hundreds of Salesforce instances, including Cloudflare, Google, Palo Alto Networks, and Proofpoint — a practical reminder that long-lived OAuth credentials are high-value (Google Cloud Threat Intelligence on UNC6395).
- Store refresh tokens in
HttpOnly,Secure,SameSite=Laxcookies where possible.
Cookie and CSRF considerations
Vendor-agnostic cookie guidance (first).
SameSite,Secure,HttpOnly,Domain,Path— set each intentionally for any session or auth cookie.- Widening a cookie's
Domainattribute (e.g.,Domain=.example.com) shares it across all subdomains. That works for simple single-vendor setups but expands the XSS attack surface to every subdomain and assumes every subdomain is equally trusted. For most monorepos with a mix of marketing, app, and API subdomains, prefer a tight per-origin cookie and propagate identity to other origins viaAuthorization: Bearer <token>. - CSRF: enforce a same-site cookie policy (
SameSite=Laxdefault,Strictwhere possible), checkOrigin/Refereron state-changing requests, and validate theazp(authorized party) claim on any JWT that crosses origins.
Clerk-specific architecture (separate, do not blur with the generic guidance above).
- Clerk deliberately does not widen cookies across subdomains. Cross-subdomain cookie sharing is not Clerk's recommended pattern and the
__sessioncookie is scoped to the app domain. __clientis an HttpOnly cookie on Clerk's Frontend API (FAPI) domain (e.g.,clerk.yourdomain.com). It holds the long-lived client identity and is never readable from the app.__sessionis an app-domain-scoped JWT cookie (notHttpOnlyby design — see §8.8 — with a 60-second TTL). Do not setDomain=.example.comon this cookie to share it across subdomains.- For cross-subdomain or cross-origin requests (e.g.,
app.example.com→api.example.com), callgetToken()from the frontend SDK and send the session JWT asAuthorization: Bearer <token>. The receiving service verifies withauthenticateRequest()orverifyToken()— no cookie crosses the origin boundary. - For genuinely separate domains (not subdomains), Clerk provides a satellite domains feature that uses a handshake flow between a primary and satellite domain. This is the supported Clerk path for multi-domain auth; it is not a "widen the cookie" shortcut (Clerk satellite domains).
Secrets management at the edge
- Platform secret stores: Cloudflare Workers Secrets (docs), Vercel Sensitive Env Vars (docs), AWS Secrets Manager + Parameter Store (Secrets Manager + Lambda), Netlify env vars.
wrangler secret putfor Cloudflare Workers. Secrets are encrypted at rest and injected as env bindings.- Avoid baked-in secrets in bundles. Inspect
pnpm buildoutput for accidental string literals. - Rotate
CLERK_SECRET_KEY,CLERK_MACHINE_SECRET_KEY, andCLERK_ENCRYPTION_KEYon a schedule. Out-of-band auditing of who rotated, when, and via which mechanism is part of a mature posture.
Logging, auditing, and incident response
- Log auth events with: timestamp,
jti(token ID),userIdormachineId, request ID, IP (or hashed IP), outcome (success/failure). Never log the full token. - Tie auth events to request IDs and traces via OpenTelemetry (OWASP logging cheat sheet).
- Runbooks for key rotation and auth incidents — documented, tested, and owned before they are needed.
- OWASP Top 10 2025 relevant items: A01 Broken Access Control, A07 Authentication Failures, A03 Supply Chain Failures (newly elevated) (OWASP Top 10 2025, OWASP Top 10 2025 A07).
Rate limiting auth attempts at the edge
Sign-in, token exchange, and M2M token endpoints are the highest-value credential-stuffing targets in any serverless system. The per-isolate nature of V8 runtimes defeats naive in-memory counters, so edge rate limiting needs a coordination primitive.
- Cloudflare Workers: the platform's Rate Limiting binding for sliding-window enforcement, or a Durable Object per user/IP for strongly consistent global counters (KV is eventually consistent and not safe for rate limiting) (CF Rate Limit binding, CF Hono Rate Limit middleware, Global Rate Limiter with Durable Objects, Rules of Durable Objects).
- Vercel: Vercel Firewall / WAF supports token-bucket and sliding-window rules for login and token endpoints (Vercel WAF rate limiting).
- AWS: API Gateway throttling + AWS Shield; Cognito has built-in brute-force protection on sign-in endpoints.
- Pattern: rate limit unauthenticated endpoints on IP + hashed email; rate limit authenticated M2M endpoints on the machine ID; pair with short-lived tokens so even a successful brute force hits a moving target.
Self-hosted Next.js CVE-2025-29927 deep dive
The deeper lesson is not about this specific CVE — it is about never relying on a single middleware-layer check as the full auth boundary. Defense in depth means middleware (fast reject) + re-verification in the handler that actually does work (correctness guarantee).
Implementation Checklist
Before you start
Further Reading
- Clerk docs: How Clerk works, Backend SDK overview, Machine auth overview, Session tokens reference, Manual JWT verification.
- Platform docs: Next.js 16
proxy.ts, Cloudflare Workers platform limits, Vercel Functions limitations, Netlify Edge Functions overview, AWS Lambda cold start remediation. - Specs and standards: RFC 7519 (JWT), RFC 7517 (JWK), RFC 6749 (OAuth 2.0), RFC 9068 (JWT Profile for OAuth 2.0 Access Tokens), RFC 9700 (OAuth 2.0 Security BCP), OpenID Connect Core 1.0.
- Security: OWASP Top 10 2025, OWASP JWT cheat sheet, NIST SP 800-63B-4 (July 2025).