# Authentication for Serverless and Edge Deployments

**How does authentication work for serverless and edge deployments?**

[Authentication](https://clerk.com/glossary/authentication.md) for [serverless](https://clerk.com/glossary/serverless-architecture.md) and edge runtimes uses short-lived, stateless [JWTs](https://clerk.com/glossary/json-web-token.md) 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](https://developers.cloudflare.com/workers/platform/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](https://nextjs.org/docs/app/api-reference/file-conventions/proxy), [Next.js 16 release blog](https://nextjs.org/blog/next-16)).
- **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](https://developers.cloudflare.com/workers/runtime-apis/nodejs/)).

> 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](https://nextjs.org/docs/app/api-reference/file-conventions/proxy)).

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

| Constraint    | Node.js serverless                                                                                                                                                                                                                                                                                                                                                                                                                                    | V8 isolate / runtime edge                                                                                                                                                                                                                                                                                  |
| ------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Cold start    | AWS documents Lambda cold starts spanning "less than 100ms to well over 1 second" ([AWS Lambda cold start remediation](https://aws.amazon.com/blogs/compute/understanding-and-remediating-cold-starts-an-aws-lambda-perspective/)); Node.js 22 arm64 p50 \~294ms optimized in an independent benchmark ([Node.js 22 Lambda benchmarks](https://speedrun.nobackspacecrew.com/blog/2025/07/21/the-fastest-node-22-lambda-coldstart-configuration.html)) | Cloudflare Workers start in `<5ms` ([ByteByteGo Workers cold starts](https://blog.bytebytego.com/p/how-cloudflare-eliminates-cold-starts)); Deno Deploy \~3ms, 10ms p50 ([Deno Deploy](https://deno.com/deploy))                                                                                           |
| Memory        | Lambda 128MB to 10GB ([AWS Lambda quotas](https://aws.amazon.com/blogs/compute/understanding-and-remediating-cold-starts-an-aws-lambda-perspective/))                                                                                                                                                                                                                                                                                                 | Cloudflare Workers 128MB; Netlify Edge 512MB ([CF Workers limits](https://developers.cloudflare.com/workers/platform/limits/), [Netlify Edge limits](https://docs.netlify.com/build/edge-functions/limits/))                                                                                               |
| CPU budget    | Lambda up to 15 min wall time                                                                                                                                                                                                                                                                                                                                                                                                                         | Cloudflare Workers Free 10ms CPU; Paid 5 min CPU; Netlify Edge 50ms CPU per request                                                                                                                                                                                                                        |
| API surface   | Full Node.js stdlib + `crypto` module                                                                                                                                                                                                                                                                                                                                                                                                                 | Web Standards only by default (`globalThis.crypto.subtle`, `fetch`); `nodejs_compat` flag needed for Buffer/Streams on Workers                                                                                                                                                                             |
| Bundle limits | Vercel Node functions 250MB ([Vercel Functions limitations](https://vercel.com/docs/functions/limitations))                                                                                                                                                                                                                                                                                                                                           | Cloudflare Workers 3MB Free / 10MB Paid ([CF Workers limits](https://developers.cloudflare.com/workers/platform/limits/)); Vercel Edge Runtime 1MB Hobby / 2MB Pro / 4MB Enterprise after gzip ([Vercel Edge Runtime docs](https://vercel.com/docs/functions/runtimes/edge)); 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](https://byteiota.com/edge-functions-vs-serverless-the-2025-performance-battle/)). 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](https://aws.amazon.com/blogs/compute/understanding-and-remediating-cold-starts-an-aws-lambda-perspective/)).
- Independent benchmarks place optimized Node.js 22 arm64 Lambda cold starts around p50 \~294ms ([Node.js 22 Lambda benchmarks](https://speedrun.nobackspacecrew.com/blog/2025/07/21/the-fastest-node-22-lambda-coldstart-configuration.html)).
- 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](https://docs.aws.amazon.com/lambda/latest/dg/snapstart.html), [AWS re:Invent 2025 Lambda recap](https://dev.to/kazuya_dev/aws-reinvent-2025-whats-new-with-aws-lambda-cns376-3ojp)).

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](https://github.com/awinogrodzki/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](https://vercel.com/docs/functions/runtimes/edge)). Cloudflare Workers allow 3MB Free / 10MB Paid compressed ([Cloudflare Workers limits](https://developers.cloudflare.com/workers/platform/limits/)). Both numbers will push you toward small, Web-Standards-first SDKs.

> `jose` ([github.com/panva/jose](https://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](https://clerk.com/glossary/claim.md) 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](https://clerk.com/glossary/access-token.md)**: 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](https://clerk.com/glossary/refresh-token.md)**: 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)](https://datatracker.ietf.org/doc/html/rfc7519), [RFC 9068 (JWT Profile for OAuth 2.0 Access Tokens)](https://datatracker.ietf.org/doc/html/rfc9068), and the [OpenID Connect Core 1.0 spec](https://openid.net/specs/openid-connect-core-1_0.html) for ID tokens.

> 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](https://datatracker.ietf.org/doc/html/rfc7517)). 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](https://clerk.com/glossary/discovery-document-oidc.md).

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](https://curity.io/resources/learn/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](https://connect2id.com/blog/nimbus-jose-jwt-6)). 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](https://workos.com/blog/hmac-vs-rsa-vs-ecdsa-which-algorithm-should-you-use-to-sign-jwts) for qualitative comparisons).

A portable, Web-Crypto-native verifier looks like this:

```ts
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](https://github.com/panva/jose)).

### 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](https://ssojet.com/blog/how-to-validate-jwts-efficiently-at-the-edge-with-cloudflare-workers-and-vercel)); 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](https://blog.cloudflare.com/faster-workers-kv/)).
- **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](https://workos.com/blog/developers-guide-jwks), [Zalando automated JWK rotation](https://engineering.zalando.com/posts/2025/01/automated-json-web-key-rotation.html)).

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](https://nearform.com/insights/improve-json-web-tokens-performance-in-node-js/)). Cite this as an independent Node.js benchmark that illustrates the magnitude of the caching win, not a universal figure.

> 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](https://clerk.com/glossary/session.md) 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](https://clerk.com/docs/guides/how-clerk-works/overview.md), [Clerk session tokens reference](https://clerk.com/docs/guides/sessions/session-tokens.md)). 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](https://clerk.com/glossary/client-credentials-flow.md) (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](https://datatracker.ietf.org/doc/html/rfc6749), [RFC 9700 OAuth 2.0 Security BCP](https://datatracker.ietf.org/doc/rfc9700/)).

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](https://vercel.com/docs/routing-middleware)).
- 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.

> 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](https://nvd.nist.gov/vuln/detail/CVE-2025-29927), [Datadog Security Labs](https://securitylabs.datadoghq.com/articles/nextjs-middleware-auth-bypass/)). 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](https://clerk.com/glossary/middleware.md) 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)](https://microservices.io/post/architecture/2025/04/25/microservices-authn-authz-part-1-introduction.html).

### 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](https://microservices.io/post/architecture/2025/07/22/microservices-authn-authz-part-3-jwt-authorization.html), [Frontegg authentication in microservices](https://frontegg.com/blog/authentication-in-microservices), and [Oso microservices authorization patterns](https://www.osohq.com/post/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](https://github.com/panva/jose) 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](https://clerk.com/glossary/multi-factor-authentication-mfa.md), [organizations](https://clerk.com/glossary/organizations.md) and [RBAC](https://clerk.com/glossary/role-based-access-control-rbac.md), 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](https://aws.amazon.com/blogs/networking-and-content-delivery/authorizationedge-how-to-use-lambdaedge-and-json-web-tokens-to-enhance-web-application-security/).

- **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](https://github.com/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](https://auth0.com/blog/upcoming-pricing-changes-for-the-customer-identity-cloud/), [Auth0 pricing](https://auth0.com/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](https://supabase.com/docs/guides/auth/jwts)).

- **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](https://github.com/awinogrodzki/next-firebase-auth-edge)).
- **Strengths**: mobile-first; generous free tier (Spark: 50K MAU free for email/social; confirm current tiers on the [Firebase pricing page](https://firebase.google.com/pricing) 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](https://nextjs.org/docs/app/api-reference/file-conventions/proxy), [Next.js 16 release blog](https://nextjs.org/blog/next-16)).

**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](https://vercel.com/docs/functions/runtimes/edge), [Vercel Edge Functions deprecated](https://vercel.com/docs/functions/runtimes/edge/edge-functions)). "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](https://vercel.com/docs/routing-middleware), [Vercel changelog unification](https://vercel.com/changelog/edge-middleware-and-edge-functions-are-now-powered-by-vercel-functions)).

**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](https://vercel.com/blog/introducing-fluid-compute), [How Fluid Compute Works](https://vercel.com/blog/how-fluid-compute-works-on-vercel), [Vercel Active CPU pricing](https://vercel.com/blog/introducing-active-cpu-pricing-for-fluid-compute)).

Node runtime bundle limit is 250MB; memory up to 4GB; max duration 300–800s ([Vercel Functions limitations](https://vercel.com/docs/functions/limitations)).

> 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](https://developers.cloudflare.com/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](https://blog.bytebytego.com/p/how-cloudflare-eliminates-cold-starts)).
- **KV for shared JWKS/user cache** — hot key reads `<1ms` in-memory; p99 `<5ms` after the October 2025 rearchitecture ([Cloudflare KV performance](https://blog.cloudflare.com/faster-workers-kv/)).
- **Durable Objects** — single-instance pinning for rate limiting or strongly consistent session state ([Durable Objects](https://developers.cloudflare.com/durable-objects/)).
- **`nodejs_compat` flag** — compat date 2024-09-23+ enables Node-style `crypto`, `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](https://developers.cloudflare.com/workers/runtime-apis/nodejs/)).
- **[Hono](https://hono.dev/docs/getting-started/cloudflare-workers)** is a common Workers framework with an official [Clerk middleware](https://github.com/honojs/middleware/tree/main/packages/clerk-auth) 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](https://speedrun.nobackspacecrew.com/blog/2025/07/21/the-fastest-node-22-lambda-coldstart-configuration.html), [edgedelta AWS Lambda cold start costs](https://edgedelta.com/company/knowledge-center/aws-lambda-cold-start-cost)).
- **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](https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/lambda-at-edge-function-restrictions.html)).
- **API Gateway JWT authorizer** (native, no Lambda invocation) vs. **Lambda authorizer** (REQUEST- or TOKEN-based, cacheable, custom logic) ([API Gateway JWT authorizer](https://docs.aws.amazon.com/apigateway/latest/developerguide/http-api-jwt-authorizer.html), [Lambda authorizer](https://docs.aws.amazon.com/apigateway/latest/developerguide/apigateway-use-lambda-authorizer.html)).
- **SnapStart** was expanded to Python and .NET at re:Invent 2025; Node.js is not yet supported ([AWS Lambda SnapStart docs](https://docs.aws.amazon.com/lambda/latest/dg/snapstart.html)).

### Netlify Edge Functions

- Deno-based; 20MB compressed bundle; 512MB memory; 50ms CPU per request; 40s response header timeout ([Netlify Edge Functions overview](https://docs.netlify.com/build/edge-functions/overview/), [Netlify Edge limits](https://docs.netlify.com/build/edge-functions/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](https://www.netlify.com/integrations/clerk/)).

### 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](https://deno.com/deploy), [Deno Deploy as OIDC Provider](https://docs.deno.com/deploy/reference/oidc/)).
- **Deno + JWT via Web Crypto**: `djwt` native library or `jose` npm; both use `SubtleCrypto` for RS256 ([Deno JWT with Web Crypto](https://docs.deno.com/examples/creating_and_verifying_jwt/)).
- **Bun**: not an edge runtime. Public beta as a Vercel Node runtime option ([Vercel Bun runtime docs](https://vercel.com/docs/functions/runtimes/bun)); Vercel reports approximately 28% latency reduction for CPU-bound Next.js rendering vs. Node.js ([Vercel Bun public beta](https://vercel.com/changelog/bun-runtime-now-in-public-beta-for-vercel-functions)).

### 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](https://turborepo.com/docs/crafting-your-repository/using-environment-variables), [Turborepo configuration reference](https://turborepo.com/docs/reference/configuration)).
- **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](https://pnpm.io/catalogs) so every app stays in lockstep on core auth dependencies.

References: [Clerk T3 Turbo blog post](https://clerk.com/blog/clerk-t3-turbo.md), [Clerk T3 Turbo GitHub](https://github.com/clerk/t3-turbo-and-clerk), [Convex + Turborepo + Clerk monorepo](https://github.com/get-convex/turbo-expo-nextjs-clerk-convex-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](https://clerk.com/glossary/bearer-token.md), refresh tokens, device-bound flows.
- [Expo](https://clerk.com/glossary/expo.md) / [React Native](https://clerk.com/glossary/react-native.md): 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](https://clerk.com/glossary/authorization.md) 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](https://microservices.io/patterns/security/access-token.html), [Building Microservices 2nd Edition](https://samnewman.io/books/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](https://opentelemetry.io/docs/concepts/context-propagation/), [OpenTelemetry baggage](https://opentelemetry.io/docs/concepts/signals/baggage/)).
- **Service-mesh variants** — Istio `RequestAuthentication`, Linkerd mTLS, Consul Connect — push JWT validation and workload identity into the data plane ([Istio RequestAuthentication](https://istio.io/latest/docs/reference/config/security/request_authentication/), [Linkerd automatic mTLS](https://linkerd.io/2-edge/features/automatic-mtls/), [Consul Connect data plane](https://developer.hashicorp.com/consul/docs/architecture/data-plane/connect)).
- **Workload identity**, not user identity, is covered by SPIFFE/SPIRE SVIDs and complements JWT user identity in zero-trust meshes ([SPIFFE/SPIRE overview](https://spiffe.io/docs/latest/spiffe-about/overview/)).

Centralized authorization engines (Oso, OPA, Cerbos) vs. per-service authorization logic is a separate design decision. See [Oso microservices authorization patterns](https://www.osohq.com/post/microservices-authorization-patterns) and [Contentstack monolith → microservices auth](https://www.contentstack.com/blog/tech-talk/from-legacy-systems-to-microservices-transforming-auth-architecture) 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](https://clerk.com/changelog/2026-03-03-core-3.md)).

### 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](https://clerk.com/docs/reference/backend/overview.md), [Clerk Backend-Only SDK guide](https://clerk.com/docs/guides/development/sdk-development/backend-only.md)). 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](https://clerk.com/docs/reference/backend/verify-token.md), [Clerk manual JWT verification](https://clerk.com/docs/guides/sessions/manual-jwt-verification.md)).
- **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](https://ssojet.com/blog/how-to-validate-jwts-efficiently-at-the-edge-with-cloudflare-workers-and-vercel)). 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](https://clerk.com/glossary/publishable-key.md) from the Clerk dashboard.
- `CLERK_SECRET_KEY` — the [secret key](https://clerk.com/glossary/secret-key.md) 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:

```bash
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](https://www.npmjs.com/package/@clerk/nextjs), [Clerk Core 3 release](https://clerk.com/changelog/2026-03-03-core-3.md)).

Wrap the app root with `ClerkProvider`:

```tsx
// 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](https://clerk.com/docs/reference/nextjs/clerk-middleware.md)).

```ts
// 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:

```tsx
// 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](https://clerk.com/docs/references/nextjs/current-user.md), [Clerk Backend API rate limits](https://clerk.com/docs/guides/how-clerk-works/system-limits.md)). 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.

```tsx
// 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](https://clerk.com/docs/references/nextjs/auth.md)). Custom session claims can also be added via JWT templates so `auth()` alone carries commonly-read fields without triggering a Backend API call.

> `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](https://nvd.nist.gov/vuln/detail/CVE-2025-29927)). Always re-verify in Server Components, Route Handlers, or Server Actions — never treat middleware as the only check.

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

```ts
// 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()`:

```ts
// 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](https://vercel.com/docs/functions/runtimes/edge/edge-functions)).

### 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](https://clerk.com/docs/guides/development/sdk-development/backend-only.md)). The old `@clerk/edge` package is deprecated.

Two common shapes:

#### Hono + `@hono/clerk-auth`

```ts
// 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](https://github.com/honojs/middleware/tree/main/packages/clerk-auth), [Hono by Example — Clerk](https://honobyexample.com/posts/clerk-backend)).

#### Raw Workers + `@clerk/backend`

```ts
// 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](https://clerk.com/docs/references/backend/authenticate-request.md), [community tRPC + Clerk on Workers](https://dev.to/yinks/implementing-authorization-with-clerk-in-a-trpc-app-running-on-a-cloudflare-worker-4li5)).

Environment bindings are set via `wrangler secret put` or the Cloudflare dashboard ([Cloudflare Workers Secrets](https://developers.cloudflare.com/workers/configuration/secrets/)):

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

> 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](https://clerk.com/docs/reference/backend/verify-token.md)).

### Machine-to-machine and backend-to-backend with Clerk

Clerk's machine-auth model distinguishes three approaches ([Clerk machine-auth overview](https://clerk.com/docs/guides/development/machine-auth/overview.md)):

- **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](https://clerk.com/docs/guides/development/machine-auth/token-formats.md), [Clerk changelog — M2M JWT tokens](https://clerk.com/changelog/2026-02-24-m2m-jwt-tokens.md)):

- **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](https://clerk.com/docs/guides/development/machine-auth/m2m-tokens.md)).

Issuing a token from one service:

```ts
// 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:

```ts
// 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](https://clerk.com/docs/guides/development/verifying-api-keys.md), [Clerk `authenticateRequest()` reference](https://clerk.com/docs/reference/backend/authenticate-request.md)).

```ts
// 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](https://clerk.com/changelog/2026-03-03-core-3.md), [Clerk session tokens reference](https://clerk.com/docs/guides/sessions/session-tokens.md)).

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.

> 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](https://clerk.com/docs/guides/sessions/session-tokens.md)).

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

```ts
// 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:

```json
{
  "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](https://clerk.com/docs/guides/development/making-requests.md), [Clerk cookies guide](https://clerk.com/docs/guides/how-clerk-works/cookies.md)).

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

| Capability                           | Roll-your-own (`jose`) |       AWS Cognito      |       Auth0       | Supabase Auth |        Firebase Auth        |  Clerk  |
| ------------------------------------ | :--------------------: | :--------------------: | :---------------: | :-----------: | :-------------------------: | :-----: |
| Node.js serverless compatible        |           Yes          |           Yes          |        Yes        |      Yes      |             Yes             |   Yes   |
| V8 isolate / edge runtime compatible |           Yes          |   via aws-jwt-verify   | via /edge subpath |      Yes      | via next-firebase-auth-edge |   Yes   |
| Networkless JWT verification         |           Yes          |           No           |         No        |       No      |              No             |   Yes   |
| Built-in UI components               |           No           |        Hosted UI       |        Yes        |   Starter UI  |          FirebaseUI         |   Yes   |
| Organizations / RBAC                 |           No           |       User groups      |        Yes        |     Custom    |        Custom claims        |   Yes   |
| MFA                                  |           No           |           Yes          |        Yes        |      Yes      |             Yes             |   Yes   |
| M2M / service identity               |           DIY          | App client credentials |        Yes        |       No      |              No             |   Yes   |
| Documented monorepo story            |           No           |           No           |     Community     |   Community   |              No             |   Yes   |
| Managed JWKS + key rotation          |           No           |           Yes          |        Yes        |      Yes      |             Yes             |   Yes   |
| Billing model                        |       Self-hosted      |         Per-MAU        |      Per-MAU      |    Per-MAU    |        Per-MAU (+SMS)       | Per-MRU |

> 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](https://clerk.com/pricing)). 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.

- **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](https://auth0.com/blog/upcoming-pricing-changes-for-the-customer-identity-cloud/), [Auth0 pricing](https://auth0.com/pricing)), AWS Cognito ([Cognito pricing](https://aws.amazon.com/cognito/pricing/)), Supabase Auth ([Supabase pricing](https://supabase.com/pricing)), Firebase Authentication ([Firebase pricing](https://firebase.google.com/pricing)).
- **Per-MRU vendor**: Clerk ([Clerk pricing page](https://clerk.com/pricing)).
- **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](https://clerk.com/changelog/2026-02-24-m2m-jwt-tokens.md), [Clerk API Keys GA](https://clerk.com/changelog/2026-04-17-api-keys-ga.md)).
- **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](https://auth0.com/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](https://aws.amazon.com/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](https://supabase.com/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](https://firebase.google.com/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](https://clerk.com/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

- [ ] Algorithm allow-list. Never accept `alg: none` ([aquilax.ai algorithm confusion](https://aquilax.ai/blog/jwt-algorithm-confusion-auth-bypass), [OWASP JWT cheat sheet](https://cheatsheetseries.owasp.org/cheatsheets/JSON_Web_Token_for_Java_Cheat_Sheet.html)).
- [ ] Validate `iss`, `aud`, `exp`, `nbf`, `azp` consistently ([Clerk manual JWT verification](https://clerk.com/docs/guides/sessions/manual-jwt-verification.md)).
- [ ] Rotate JWKS correctly; cap refresh at a 5–10 minute minimum.
- [ ] Prefer asymmetric algorithms (RS256, ES256, EdDSA) for multi-service setups.
- [ ] Allow a small clock skew: 5–30 seconds ([Curity JWT best practices](https://curity.io/resources/learn/jwt-best-practices/)).

> 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](https://owasp.org/www-project-web-security-testing-guide/latest/4-Web_Application_Security_Testing/06-Session_Management_Testing/10-Testing_JSON_Web_Tokens), [aquilax.ai JWT confusion](https://aquilax.ai/blog/jwt-algorithm-confusion-auth-bypass)).

### 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](https://auth0.com/docs/secure/tokens/refresh-tokens/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](https://cloud.google.com/blog/topics/threat-intelligence/data-theft-salesforce-instances-via-salesloft-drift)).
- Store refresh tokens in `HttpOnly`, `Secure`, `SameSite=Lax` cookies 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 `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](https://clerk.com/glossary/httponly-cookies.md) 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.com` → `api.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](https://clerk.com/docs/guides/dashboard/dns-domains/satellite-domains.md)).

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

> 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](https://developers.cloudflare.com/workers/configuration/secrets/)), Vercel Sensitive Env Vars ([docs](https://vercel.com/docs/environment-variables/sensitive-environment-variables)), AWS Secrets Manager + Parameter Store ([Secrets Manager + Lambda](https://docs.aws.amazon.com/secretsmanager/latest/userguide/retrieving-secrets_lambda.html)), 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](https://cheatsheetseries.owasp.org/cheatsheets/Logging_Cheat_Sheet.html)).
- 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](https://owasp.org/Top10/2025/), [OWASP Top 10 2025 A07](https://owasp.org/Top10/2025/A07_2025-Authentication_Failures/)).

### 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](https://developers.cloudflare.com/workers/runtime-apis/bindings/rate-limit/), [CF Hono Rate Limit middleware](https://github.com/elithrar/workers-hono-rate-limit), [Global Rate Limiter with Durable Objects](https://shivekkhurana.com/blog/global-rate-limiter-durable-objects/), [Rules of Durable Objects](https://developers.cloudflare.com/changelog/post/2025-12-15-rules-of-durable-objects/)).
- **Vercel**: Vercel Firewall / WAF supports token-bucket and sliding-window rules for login and token endpoints ([Vercel WAF rate limiting](https://vercel.com/docs/vercel-firewall/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

> 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](https://nvd.nist.gov/vuln/detail/CVE-2025-29927), [Datadog Security Labs](https://securitylabs.datadoghq.com/articles/nextjs-middleware-auth-bypass/), [Next.js `proxy.ts` reference](https://nextjs.org/docs/app/api-reference/file-conventions/proxy)).

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

- [ ] Identify each runtime target in the repo (Node.js serverless, V8 isolate edge, mobile, background).
- [ ] Decide on a single identity provider.
- [ ] Agree on token shape (claims, audience, issuer) and document it in a shared types package.
- [ ] Define scopes for M2M calls.
- [ ] Identify where authorization decisions run (closer to data than authentication).

### During implementation

- [ ] Centralize auth config in a shared package (env vars + types, not SDK wrapper).
- [ ] Add auth at the outermost boundary (proxy / middleware / gateway), but re-verify in server code.
- [ ] Use networkless verification (`jwtKey`) where available.
- [ ] Write integration tests against real tokens from a dev environment, not mocked JWTs.
- [ ] Prefer asymmetric JWT algorithms (RS256, ES256, EdDSA).
- [ ] Validate `iss`, `aud`, `exp`, `nbf`, `azp` claims consistently.

### Production readiness

- [ ] JWKS caching verified under load (cold/warm/rotation).
- [ ] Secrets configured per environment; never baked into bundles.
- [ ] Observability: auth latency (p50/p95/p99), failure rate, token refresh rate.
- [ ] Runbooks for key rotation and auth incident response.
- [ ] Rate limiting at the edge for unauthenticated endpoints.
- [ ] Content-Security-Policy + cookie flags hardened.
- [ ] Known-CVE header stripping at the reverse proxy (e.g., `x-middleware-subrequest`).
- [ ] Document authn vs. authz boundaries explicitly so on-call engineers do not have to guess.

## Frequently Asked Questions

## FAQ

### What is serverless authentication?

Serverless authentication is verifying a user or service in a function that spawns per request. Because compute is ephemeral, the token must carry its own trust (a signed JWT) or be verified against a managed service with a low-latency path. There is no long-lived in-process session store to consult.

### What is edge authentication?

Edge authentication means verifying auth at a point-of-presence close to the user. It has two shapes: "architectural edge" (a proxy/middleware in front of origin, often on Node.js — e.g., Next.js 16 `proxy.ts`) and "runtime edge" (a V8 isolate or WASM runtime that exposes only Web Standards APIs — e.g., Cloudflare Workers, Netlify Edge Functions). Both aim to reject unauthenticated requests before origin compute runs.

### How do you authenticate serverless functions?

Issue a short-lived JWT via a managed provider or a self-hosted IdP. Each function verifies the token using a Web-Crypto-compatible library (e.g., `jose`, `@clerk/backend`, `aws-jwt-verify`), caches the JWKS locally, and validates standard claims (iss, aud, exp, nbf, azp). Networkless verification (using a configured public key) removes the per-request JWKS round-trip entirely.

### Why doesn't traditional session-based authentication work well on the edge?

Stateful session stores assume a long-lived server sitting near the session database. Edge functions are ephemeral and geographically distributed, so every verification becomes a cross-region network round-trip. That defeats the latency win of running at the edge and adds failure modes you do not see on a colocated origin.

### What is stateless authentication and why does it fit serverless?

Stateless authentication uses tokens that are themselves the proof of identity — a signed JWT whose signature and claims can be verified without consulting a server-side session record. It fits serverless because any invocation of any function can verify any token with just the public key; no shared in-memory state is required.

### What is JWT verification at the edge?

JWT verification at the edge is validating the signature, issuer, audience, expiry, and authorized-party claims of a JWT inside a V8 isolate (or equivalent) using Web Crypto. Typical implementations use `jose` or a vendor SDK like `@clerk/backend`. Community benchmarks report warm verification in the low-millisecond range on Cloudflare Workers and Vercel Edge.

### How does JWKS caching improve edge authentication performance?

Fetching the JWKS on every request adds a network round-trip before verification can start — community benchmarks put cold JWKS fetches at 15–25ms on Vercel Edge. Caching per-isolate (LRU) or in shared KV drops repeat verifications to sub-millisecond. Always cap JWKS refresh at a 5–10 minute minimum to avoid thundering-herd behavior during rotation.

### What is the best authentication for serverless and edge functions?

The best fit depends on the combination of criteria below — there is no single best. (1) Pick a vendor or library whose SDK runs natively in your target runtime (V8 isolate for Cloudflare Workers / Netlify Edge / Deno; Node.js for Vercel Functions and Lambda). (2) Prefer networkless JWT verification to remove the per-request JWKS round-trip. (3) Prefer managed JWKS and automatic key rotation over rolling your own. (4) Match the billing model to your usage pattern (per-MAU vs. per-MRU vs. self-hosted infrastructure). (5) Factor in whether you need built-in UI, organizations/RBAC, MFA, and M2M — building these yourself is months of work. Among roll-your-own `jose`, AWS Cognito, Auth0, Supabase Auth, Firebase Auth, and Clerk, each satisfies a different subset of those criteria.

### Does Clerk work with Cloudflare Workers?

Yes. `@clerk/backend` runs in V8 isolates, and `@hono/clerk-auth` provides middleware for Hono-based Workers. Networkless verification via `jwtKey` (Clerk's PEM public key) removes the per-request JWKS round-trip. Set the Clerk secrets via `wrangler secret put`.

### Does Clerk work with Vercel Edge Functions and Next.js 16?

Clerk works with Next.js 16 via `proxy.ts` (Node.js runtime — the runtime cannot be overridden in `proxy.ts`) and `auth()` in Route Handlers / Server Components. Vercel still documents the Edge Runtime for route handlers but recommends migrating new work to Node.js. For new Next.js 16 work with Clerk, use the default Node.js runtime and do not opt into `export const runtime = "edge"`.

### How does Clerk handle JWT verification at the edge?

Via `@clerk/backend`'s `authenticateRequest()` and `verifyToken()`. Configure `jwtKey` with Clerk's PEM public key for networkless verification (no JWKS round-trip). Clerk session tokens have a 60-second TTL and proactive frontend refresh at 50 seconds, so a long-running page never sees an expired token.

### How do you share authentication configuration across a monorepo?

Centralize env vars (e.g., Turborepo `globalEnv`) and TypeScript types in a shared internal package. Do not wrap the SDK itself — each app installs the runtime-appropriate SDK (`@clerk/nextjs`, `@clerk/expo`, `@clerk/backend`). Pin versions across the monorepo with pnpm Catalogs so SDK versions stay in lockstep.

### Can different services in a monorepo use different authentication approaches with the same provider?

Yes — with Clerk, one publishable + secret key pair serves web cookie flows, mobile bearer tokens, Cloudflare Workers JWT verification, and internal M2M tokens. Each service picks the token type it accepts via `auth({ acceptsToken: [...] })` and branches on `tokenType`. Separate machine secret keys (`ak_`) isolate M2M rotation from application secret rotation.

### How do you authenticate service-to-service calls in a serverless architecture?

Issue short-lived, scoped M2M tokens. Clerk's JWT M2M format (released February 2026) allows free networkless verification with no per-request cost; opaque tokens are instantly revocable at $0.00001 per verification. Max 150 scopes per M2M token. Keep expirations short (minutes) and scope to the narrowest set of permissions that still lets the call succeed.

### What auth pattern should microservices use on serverless platforms?

A gateway (edge middleware) verifies the user JWT and forwards identity downstream via headers or a forwarded `Authorization` header; each service re-verifies or trusts the gateway-signed header based on the trust boundary. Centralized authorization engines (OPA, Oso, Cerbos) handle complex policies without coupling every service to the policy store.

### How do you handle session expiry across distributed edge functions?

Short-lived JWTs (Clerk: 60 seconds; industry norms: 15 minutes) with proactive refresh. The frontend SDK refreshes the token before expiry; expired tokens trigger a handshake flow with the provider's Frontend API to mint a new token. There is no shared in-memory state to invalidate.

### What is the difference between edge middleware authentication and JWT verification inside a function?

Middleware verifies once at the boundary and forwards trusted identity to downstream code; per-function verification is done inside each handler. Middleware reduces origin load and rejects invalid requests early; per-function verification adds defense in depth. Use both — middleware for fast rejection, per-function verification for correctness under gateway-bypass bugs (CVE-2025-29927).

### How do you avoid cold-start latency when verifying auth tokens?

Cache JWKS in per-isolate LRU and shared KV; prefer V8 isolate runtimes (Cloudflare Workers start in under 5ms) over Node.js Lambda (p50 \~294ms on optimized Node.js 22 arm64) when every millisecond matters; use networkless verification (`jwtKey` with a PEM public key) to eliminate the JWKS round-trip on first request.

### What security pitfalls are unique to serverless and edge authentication?

Incorrectly configured middleware can leak unauthenticated requests (CVE-2025-29927). Shared JWKS caches without rotation logic fail on key rollover. Secrets can leak into edge bundles if you do not use platform secret stores. Cross-region state drift can affect revocation lists. Rate limiting with in-memory counters fails across isolates — use Durable Objects or a shared rate-limit binding.

### When should you build your own edge auth versus use a managed provider?

Build your own only if you have a narrow, API-only scope with no user UI, and the engineering capacity to own UI, CVE tracking, key rotation, JWKS caching, refresh-token rotation, and compliance. Otherwise, a managed provider (Clerk, Auth0, AWS Cognito, Supabase Auth, Firebase Authentication) saves months of initial build plus ongoing maintenance — and the security surface you inherit is audited by the provider.

## Further Reading

- Clerk docs: [How Clerk works](https://clerk.com/docs/guides/how-clerk-works/overview.md), [Backend SDK overview](https://clerk.com/docs/reference/backend/overview.md), [Machine auth overview](https://clerk.com/docs/guides/development/machine-auth/overview.md), [Session tokens reference](https://clerk.com/docs/guides/sessions/session-tokens.md), [Manual JWT verification](https://clerk.com/docs/guides/sessions/manual-jwt-verification.md).
- Platform docs: [Next.js 16 `proxy.ts`](https://nextjs.org/docs/app/api-reference/file-conventions/proxy), [Cloudflare Workers platform limits](https://developers.cloudflare.com/workers/platform/limits/), [Vercel Functions limitations](https://vercel.com/docs/functions/limitations), [Netlify Edge Functions overview](https://docs.netlify.com/build/edge-functions/overview/), [AWS Lambda cold start remediation](https://aws.amazon.com/blogs/compute/understanding-and-remediating-cold-starts-an-aws-lambda-perspective/).
- Specs and standards: [RFC 7519 (JWT)](https://datatracker.ietf.org/doc/html/rfc7519), [RFC 7517 (JWK)](https://datatracker.ietf.org/doc/html/rfc7517), [RFC 6749 (OAuth 2.0)](https://datatracker.ietf.org/doc/html/rfc6749), [RFC 9068 (JWT Profile for OAuth 2.0 Access Tokens)](https://datatracker.ietf.org/doc/html/rfc9068), [RFC 9700 (OAuth 2.0 Security BCP)](https://datatracker.ietf.org/doc/rfc9700/), [OpenID Connect Core 1.0](https://openid.net/specs/openid-connect-core-1_0.html).
- Security: [OWASP Top 10 2025](https://owasp.org/Top10/2025/), [OWASP JWT cheat sheet](https://cheatsheetseries.owasp.org/cheatsheets/JSON_Web_Token_for_Java_Cheat_Sheet.html), [NIST SP 800-63B-4 (July 2025)](https://csrc.nist.gov/pubs/sp/800/63/b/4/final).
