Skip to main content
Articles

Authentication for Serverless and Edge Deployments

Author: Roy Anger
Published:

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.ts is the canonical example — it sits in front of origin requests, but it runs on Node.js, not a V8 isolate (Next.js proxy.ts API 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).

Note

Next.js 16 proxy.ts sits in an "architectural edge" position but runs exclusively on the Node.js runtime. The runtime Route Segment Config option is not supported inside proxy.ts and setting it throws a build-time error (Next.js proxy.ts reference).

A side-by-side of the two runtime families makes the constraints concrete:

ConstraintNode.js serverlessV8 isolate / runtime edge
Cold startAWS documents Lambda cold starts spanning "less than 100ms to well over 1 second" (AWS Lambda cold start remediation); Node.js 22 arm64 p50 ~294ms optimized in an independent benchmark (Node.js 22 Lambda benchmarks)Cloudflare Workers start in <5ms (ByteByteGo Workers cold starts); Deno Deploy ~3ms, 10ms p50 (Deno Deploy)
MemoryLambda 128MB to 10GB (AWS Lambda quotas)Cloudflare Workers 128MB; Netlify Edge 512MB (CF Workers limits, Netlify Edge limits)
CPU budgetLambda up to 15 min wall timeCloudflare Workers Free 10ms CPU; Paid 5 min CPU; Netlify Edge 50ms CPU per request
API surfaceFull Node.js stdlib + crypto moduleWeb Standards only by default (globalThis.crypto.subtle, fetch); nodejs_compat flag needed for Buffer/Streams on Workers
Bundle limitsVercel Node functions 250MB (Vercel Functions limitations)Cloudflare Workers 3MB Free / 10MB Paid (CF Workers limits); Vercel Edge Runtime 1MB Hobby / 2MB Pro / 4MB Enterprise after gzip (Vercel Edge Runtime docs); Netlify Edge 20MB compressed

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.

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.

  • jsonwebtoken uses Node's crypto module; CF Workers require Web Crypto (crypto.subtle) unless nodejs_compat is enabled.
  • bcrypt ships a native binary; it will not run in a V8 isolate.
  • firebase-admin uses TCP sockets and Node.js-only APIs; it is not edge-compatible. Community library next-firebase-auth-edge fills 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 http module; 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.

Tip

jose (github.com/panva/jose) is the universal Web-Crypto-first fallback — it runs in Node.js, Bun, Deno, Cloudflare Workers, and browsers with zero external dependencies and a tree-shakeable ESM build. If a vendor SDK will not run in your runtime, jose can verify the same JWTs as long as you know the issuer's JWKS URL.

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 __session cookie 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.

Caution

The alg field in the JWT header tells the verifier which algorithm to use. Accepting alg: none or accepting whatever the header claims without an explicit allow-list is the source of multiple historical CVEs (CVE-2015-9235 jsonwebtoken algorithm confusion; CVE-2022-23529). Always verify with an explicit algorithm allow-list.

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:

  1. Parse the header and read the kid.
  2. Fetch the JWKS (or read it from cache) and find the key with the matching kid.
  3. Verify the signature using the algorithm in the header, checked against your allow-list.
  4. 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.
  5. 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's createRemoteJWKSet() 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 <1ms and 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.

Tip

Cap JWKS refresh at a 5–10 minute minimum even on cache misses. A naive "refetch whenever a kid is missing" strategy turns a key-rotation event into an accidental DoS on the issuer.

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=Lax as default (browsers apply this already). SameSite=None requires Secure and is necessary for cross-origin flows.
  • HttpOnly where feasible (prevents JS access; see §8.8 for Clerk's __session exception).
  • Domain — scope to the narrowest domain that still works; widening to .example.com expands 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 → data

Typical 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 routes or 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.

Warning

CVE-2025-29927 (CVSS 9.1 Critical) allowed self-hosted Next.js apps to skip middleware.ts auth checks via the x-middleware-subrequest header (NVD CVE-2025-29927, Datadog Security Labs). Fixed in 12.3.5, 13.5.9, 14.2.25, 15.2.3. Vercel- and Netlify-hosted apps were not affected because the platforms stripped the header. The mitigation everywhere is: never rely on middleware as the sole auth gate — always re-verify in Server Components, Route Handlers, or backend code.

Pattern 2 — JWT verification inside each function

Every function verifies the token independently.

client → function A (verify) → data
client → function B (verify) → data

Works 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 /edge subpath 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-admin is not edge-compatible; the community library next-firebase-auth-edge reimplements 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).

Important

For new Next.js 16 route handlers, omit the runtime config (defaults to Node.js) or set it explicitly to 'nodejs'. Vercel still documents the Edge runtime but recommends migrating new work to Node.js. There is no benefit to opting into the Edge runtime for a Next.js 16 route handler that also has to run alongside a Node.js-only proxy.ts.

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).

AWS Lambda and Lambda@Edge

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.app domains.
  • 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

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 globalEnv ensures 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 keep AppUser, 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-store or 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.

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/backend is the canonical SDK for Node.js ≥18.17 and V8 isolates (Cloudflare Workers, Vercel Edge) (Clerk Backend SDK overview, Clerk Backend-Only SDK guide). One CLERK_PUBLISHABLE_KEY + CLERK_SECRET_KEY pair serves every runtime — @clerk/nextjs on Node.js, @clerk/backend on V8 isolates, @clerk/expo on React Native — with no "edge config" vs. "node config" split.
  • Networkless JWT verification. Configure jwtKey with Clerk's PEM public key and verification is zero network round-trips once the key is loaded (Clerk verifyToken() 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 via jwtKey and 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/nextjs

This 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.

Warning

proxy.ts must not be the sole auth gate. CVE-2025-29927 allowed self-hosted Next.js deployments to skip middleware.ts entirely via a forwarded header (NVD CVE-2025-29927). Always re-verify in Server Components, Route Handlers, or Server Actions — never treat middleware as the only check.

Note

In Next.js 16, proxy.ts runs exclusively on the Node.js runtime. The runtime Route Segment Config option is not supported in proxy.ts and setting it throws a build-time error. This is the "architectural edge" pattern (Node.js runtime sitting in front of origin). For true V8-isolate edge auth, see §8.5 (Cloudflare Workers).

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 app

The 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_KEY

wrangler.toml references the binding names in your [vars] / [env.production.vars] sections (secrets are injected separately, not written to wrangler.toml).

Tip

Set CLERK_JWT_KEY to Clerk's PEM public key (Dashboard → API Keys → PEM Public Key) for zero-network-roundtrip session verification in Cloudflare Workers (Clerk verifyToken() reference).

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:

  • __client cookie — long-lived identity on Clerk's Frontend API (FAPI) domain.
  • __session cookie — 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.

Note

Clerk's __session cookie is intentionally not HttpOnly. The frontend SDK needs to read it to drive proactive refresh. Exposure is mitigated by the 60-second TTL — XSS exposure is under a minute before the token is rotated (Clerk session tokens reference).

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.ts with clerkMiddleware() (§8.3) + ClerkProvider at app/layout.tsx (§8.2).
  • API service (apps/api, Cloudflare Workers): @clerk/backend + @hono/clerk-auth (§8.5).
  • Mobile (apps/mobile, Expo): @clerk/expo with <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 __session JWT, then sends it as Authorization: Bearer <jwt> to api.example.com. The Worker verifies networkless via jwtKey. No __session cookie crosses the origin boundary.
  • User-identity hop (mobile → API). @clerk/expo stores the session token via its token cache and attaches it as Authorization: Bearer on every request. Identical verification path on the Worker.
  • M2M hop (background worker → API). The background worker uses CLERK_MACHINE_SECRET_KEY with clerkClient.m2m.createToken() to mint a short-lived JWT M2M token, then sends it as Authorization: Bearer <m2m_jwt>. The API Worker accepts both via auth({ acceptsToken: ['session_token', 'm2m_token'] }) and branches on tokenType.

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

CapabilityRoll-your-own (jose)AWS CognitoAuth0Supabase AuthFirebase AuthClerk
Node.js serverless compatible
V8 isolate / edge runtime compatiblevia aws-jwt-verifyvia /edge subpathvia next-firebase-auth-edge
Networkless JWT verification
Built-in UI componentsHosted UIStarter UIFirebaseUI
Organizations / RBACUser groupsCustomCustom claims
MFA
M2M / service identityDIYApp client credentials
Documented monorepo storyCommunityCommunity
Managed JWKS + key rotation
Billing modelSelf-hostedPer-MAUPer-MAUPer-MAUPer-MAU (+SMS)Per-MRU

Note

MRU (Monthly Retained User) and MAU (Monthly Active User) are not interchangeable. MAU counts any user who signed in during the month; MRU counts users who return 24+ hours after sign-up (Clerk's definition) (Clerk pricing page). A given Clerk MRU count is typically lower than the MAU count for the same userbase, so MAU × per-MAU-price and MRU × per-MRU-price cannot be compared directly.

Developer experience

Sourceable items only:

  • TypeScript support — Clerk, AWS Cognito (aws-jwt-verify), Auth0, Supabase, Firebase (via firebase-admin / community edge libs), and jose all publish first-class TypeScript types.
  • Edge-specific documentation for Next.js — Clerk publishes proxy.ts examples in Core 3 docs; Auth0 documents an /edge subpath; AWS Cognito documents aws-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, Supabase getClaims() 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.

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 logicjose + your own IdP.

These are decision-criteria mappings, not a ranked list.

Security Best Practices for Serverless and Edge Auth

JWT validation checklist

Checklist

Caution

Historical JWT CVEs to test against: CVE-2015-9235 (jsonwebtoken algorithm confusion — RS256→HS256 swap), CVE-2022-21449 (ECDSA psychic signatures in Java), CVE-2022-23529 (jsonwebtoken key-type confusion) (OWASP JWT testing guide, aquilax.ai JWT confusion).

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=Lax cookies where possible.

Vendor-agnostic cookie guidance (first).

  • SameSite, Secure, HttpOnly, Domain, Path — set each intentionally for any session or auth cookie.
  • Widening a cookie's Domain attribute (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 via Authorization: Bearer <token>.
  • CSRF: enforce a same-site cookie policy (SameSite=Lax default, Strict where possible), check Origin/Referer on state-changing requests, and validate the azp (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 __session cookie is scoped to the app domain.
  • __client is 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.
  • __session is an app-domain-scoped JWT cookie (not HttpOnly by design — see §8.8 — with a 60-second TTL). Do not set Domain=.example.com on this cookie to share it across subdomains.
  • For cross-subdomain or cross-origin requests (e.g., app.example.comapi.example.com), call getToken() from the frontend SDK and send the session JWT as Authorization: Bearer <token>. The receiving service verifies with authenticateRequest() or verifyToken() — 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).

Note

Clerk's __session cookie is intentionally not HttpOnly (see §8.8) — the 60-second TTL and proactive refresh are what keep XSS exposure tight, not the cookie flag.

Warning

Do not set Domain=.example.com on Clerk cookies to share them across subdomains. Clerk does not support this pattern; use Authorization: Bearer headers (same-TLD subdomains) or satellite domains (separate TLDs) instead.

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 put for Cloudflare Workers. Secrets are encrypted at rest and injected as env bindings.
  • Avoid baked-in secrets in bundles. Inspect pnpm build output for accidental string literals.
  • Rotate CLERK_SECRET_KEY, CLERK_MACHINE_SECRET_KEY, and CLERK_ENCRYPTION_KEY on 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), userId or machineId, 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

Warning

CVE-2025-29927 (CVSS 9.1 Critical): the x-middleware-subrequest header allowed skipping middleware.ts auth checks on self-hosted Next.js. Fixed in 12.3.5, 13.5.9, 14.2.25, 15.2.3. Vercel- and Netlify-hosted apps were not affected because those platforms stripped the header before requests reached middleware. Mitigation for self-hosted: strip the x-middleware-subrequest header at your reverse proxy. Always verify auth in Server Components / Route Handlers, not only in proxy.ts (NVD CVE-2025-29927, Datadog Security Labs, Next.js proxy.ts reference).

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

Checklist
Checklist
Checklist