Skip to main content
Articles

Authentication for Serverless and Edge Deployments - Part 2

Author: Roy Anger
Published: (last updated )

Welcome to Part 2 of our series on Authentication for Serverless and Edge Deployments. In Part 1, we explored the fundamentals of serverless architecture, the core challenges of distributed authentication, and standard strategies like stateless JWTs.

In this installment, we dive into platform-specific constraints and considerations—covering Vercel, Cloudflare, AWS, and more. We will also examine the complexities of managing authentication across a monorepo, where multiple apps, platforms, and services share a unified user base. Finally, we provide a comprehensive, end-to-end guide to implementing seamless, low-latency authentication at the edge using Clerk Core 3.

Platform-Specific Considerations

Vercel (Next.js 16 + Vercel Functions)

Next.js 16 proxy.ts runs exclusively on Node.js. The runtime is not configurable and proxy.ts does not support the Route Segment Config runtime option — setting export const runtime = 'edge' in proxy.ts throws a build-time error (Next.js proxy.ts reference, Next.js 16 release blog).

Vercel Edge Runtime is still documented and functional but Vercel recommends migrating new work to Node.js. The current Vercel Edge Runtime docs display an explicit warning recommending migration to Node.js for improved performance and reliability, and note that both runtimes now run on Fluid Compute with identical Active CPU pricing (Vercel Edge Runtime, Vercel Edge Functions deprecated).

Vercel Routing Middleware (platform-layer, distinct from proxy.ts) still uses the Edge runtime and is not deprecated (Vercel Routing Middleware docs, Vercel changelog unification).

Fluid Compute (many-to-one instance sharing) and Active CPU pricing are the architectural reason Vercel consolidated Edge Functions and Node.js Functions onto one runtime + pricing model (Introducing Fluid Compute, How Fluid Compute Works, Vercel Active CPU pricing).

Node runtime bundle limit is 250MB; memory up to 4GB; max duration 300–800s (Vercel Functions limitations).

Important

For Next.js 16 route handlers, omit the runtime config or explicitly set 'nodejs'. Opting into the Edge runtime offers no benefit alongside a Node.js-only proxy.ts.

Cloudflare Workers (primary V8-isolate example)

Cloudflare Workers run in V8 isolates with Web Crypto. CPU-time limits are Free 10ms, Paid 5 min (Cloudflare Workers platform limits).

AWS Lambda and Lambda@Edge

Netlify Edge Functions

Deno Deploy and Bun

Other platforms (brief)

The platforms above cover the article's keyword cluster. Fastly Compute (WASI), Azure Static Web Apps, and Google Cloud Run exist but sit outside this article's scope. If the same "verify a signed token close to the request, re-verify close to the data" principle applies, the architectural patterns from Part 1 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 require different flows.
  • Shared config drift: apps may independently install different auth SDK versions.
  • Different runtime targets (Node.js, V8 isolates, React Native) coexist.

Shared auth configuration strategies

  • Centralize environment variables. Turborepo's globalEnv ensures that auth env vars are part of the cache key and do not leak between tasks that expect different secrets (Turborepo environment variables, Turborepo configuration reference).
  • Centralize type definitions and utility functions in a shared internal package (e.g., packages/auth-config). This houses AppUser, AppSession, and helpers wrapping common claim reads.
  • Do not wrap the auth SDK itself. Each app installs its runtime-appropriate SDK (@clerk/nextjs, @clerk/expo, @clerk/backend). Shared SDK wrappers often collapse runtime differences or force lowest-common-denominator features.
  • Version and release the shared package like any other internal library. Consumers update independently.
  • Pin SDK versions once via pnpm Catalogs so every app stays in lockstep on core auth dependencies.

References: Clerk T3 Turbo blog post, Clerk T3 Turbo GitHub, Convex + Turborepo + Clerk monorepo.

Different auth approaches per service

Web application

  • Cookie sessions + short-lived JWTs for edge middleware.
  • UI components for sign-in, sign-up, and account management.
  • Root provider setup (ClerkProvider, etc.).

Mobile and native API

  • Bearer tokens, refresh tokens, device-bound flows.
  • Expo / React Native: secure token storage via expo-secure-store or platform equivalents.
  • Native iOS (Swift) and Android (Kotlin) SDKs for production mobile apps.

Internal service-to-service

  • Machine-to-machine tokens (client credentials or vendor-specific).
  • Short-lived, scoped, and auditable.
  • JWT format for edge workloads (free verification, networkless); opaque format for revocation-sensitive workloads.

Edge functions and middleware

  • Networkless verification of short-lived JWTs (via environment PEM public key).
  • Fallback to JWKS verification for out-of-band or ad-hoc calls.

Microservices and auth boundaries

Auth boundaries (gateway vs. per-service) depend on whether downstream services trust gateway-signed identities.

Authentication vs. authorization (explicit boundary). Edge/gateway verification answers "is this a valid token for user U?" It does not answer "can U perform action A on resource R?" Authorization always runs closer to the data, after authentication is complete. Treating the gateway's "checked" flag as permission is how a gateway-bypass bug turns into an authorization hole.

Safe identity propagation across services.

Choosing centralized authorization engines (Oso, OPA, Cerbos) vs. per-service logic is a separate decision. See Oso microservices authorization patterns and Contentstack monolith → microservices auth for worked examples.

Monorepo pitfalls to avoid

  • Mixing session formats across apps (e.g., custom web sessions vs. Clerk on mobile).
  • Inconsistent cookie names, domains, or security flags.
  • Drifting JWT audiences or issuers, causing verifiers to reject valid tokens.
  • Duplicating SDK installations with different versions across apps.

Implementing Authentication with Clerk

This walkthrough assumes Clerk Core 3 (March 2026) and Next.js 16, with Cloudflare Workers as the canonical V8-isolate edge target (Clerk Core 3 release).

Why Clerk fits serverless and edge

  • Edge-compatible SDKs with unified configuration. @clerk/backend is the canonical SDK for Node.js ≥20.9.0 and V8 isolates (Cloudflare Workers, Vercel Edge) (Clerk Backend SDK overview, Clerk Backend-Only SDK guide). One CLERK_PUBLISHABLE_KEY and CLERK_SECRET_KEY pair serves every runtime (@clerk/nextjs, @clerk/backend, @clerk/expo) without separate edge/node configs.
  • Networkless JWT verification. Configure jwtKey with Clerk's PEM public key for zero-network-round-trip verification. (Clerk verifyToken() reference, Clerk manual JWT verification).
  • Managed JWKS and automatic key rotation. Consumers never manage rotation schedules.
  • Built-in UI, organizations, MFA, passkeys, and device management. Building these yourself requires 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). These community-sourced numbers illustrate order-of-magnitude improvements. Clerk's authoritative backbone remains networkless jwtKey verification and a 60-second session TTL.

Installing and configuring Clerk (Next.js 16)

Required environment variables:

  • CLERK_PUBLISHABLE_KEY — the publishable key from the Clerk dashboard.
  • CLERK_SECRET_KEY — the secret key used for server-to-server calls.
  • CLERK_JWT_KEY — the PEM public key for networkless session JWT verification (copy from Dashboard → API Keys → PEM Public Key).

Installing:

pnpm add @clerk/nextjs

This article targets Next.js 16, which is where proxy.ts is available. @clerk/nextjs Core 3 peer dependencies require Next.js 16.0.10+ (or Next.js 15.2.8+ for apps still on the 15.x line using legacy middleware.ts) (@clerk/nextjs on npm, Clerk Core 3 release).

Wrap the app root with ClerkProvider:

// app/layout.tsx
import { ClerkProvider } from '@clerk/nextjs'

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <ClerkProvider>
      <html lang="en">
        <body>{children}</body>
      </html>
    </ClerkProvider>
  )
}

ClerkProvider automatically reads CLERK_PUBLISHABLE_KEY (or NEXT_PUBLIC_...).

Edge middleware with Clerk and Next.js 16

The entry point is clerkMiddleware() inside proxy.ts (Clerk clerkMiddleware() reference).

// proxy.ts
import { clerkMiddleware, createRouteMatcher } from '@clerk/nextjs/server'

const isProtectedRoute = createRouteMatcher(['/dashboard(.*)', '/api/(.*)'])

export default clerkMiddleware(async (auth, req) => {
  if (isProtectedRoute(req)) {
    await auth.protect()
  }
})

export const config = {
  matcher: [
    '/((?!_next|[^?]*\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)',
    '/(api|trpc)(.*)',
  ],
}

Reading the authenticated user in a Server Component — prefer auth() alone when session claims are enough:

// app/dashboard/page.tsx
import { auth } from '@clerk/nextjs/server'

export default async function DashboardPage() {
  const { userId, sessionClaims } = await auth()
  if (!userId) return null

  const firstName = (sessionClaims?.firstName as string) ?? 'there'
  return <h1>Hello, {firstName}.</h1>
}

auth() reads the signed session claims from the request — no network round-trip. currentUser() fetches the full user record from Clerk's Backend API and counts toward the Backend API rate limit (1,000 requests per 10 seconds in production, 100 per 10 seconds in development) (Clerk currentUser() reference, Clerk Backend API rate limits). In high-volume edge settings, reserve currentUser() for necessary profile fields (emailAddresses, publicMetadata) and prefer useUser() on the client for display.

// 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). Add custom session claims via JWT templates so auth() carries commonly-read fields without Backend API calls.

Warning

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

Note

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

Clerk in Next.js 16 Route Handlers and Server Components

Next.js 16 Route Handlers default to Node.js. auth() functions identically to Server Components.

// app/api/profile/route.ts
import { auth, currentUser } from '@clerk/nextjs/server'

export async function GET() {
  const { userId } = await auth()
  if (!userId) {
    return Response.json({ error: 'unauthorized' }, { status: 401 })
  }

  const user = await currentUser()
  return Response.json({
    id: user?.id,
    email: user?.emailAddresses[0]?.emailAddress,
    firstName: user?.firstName,
  })
}

For handlers where you want Clerk to throw and return a 404 for unauthenticated requests, use auth.protect():

// app/api/admin/route.ts
import { auth } from '@clerk/nextjs/server'

export async function GET() {
  const { userId, orgRole } = await auth.protect({ role: 'org:admin' })
  return Response.json({ userId, orgRole })
}

Do not set export const runtime = 'edge' in new route handlers. Vercel recommends migrating new work to Node.js; the Edge Runtime is still documented but flagged for migration (Vercel Edge Functions deprecated).

Clerk on Cloudflare Workers (primary true-edge example)

@clerk/backend is the canonical SDK for Cloudflare Workers — there is no separate @clerk/cloudflare-workers package (Clerk backend-only SDK development guide). The old @clerk/edge package is deprecated.

Two common shapes:

Hono + @hono/clerk-auth

// src/index.ts
import { Hono } from 'hono'
import { clerkMiddleware, getAuth } from '@hono/clerk-auth'

type Bindings = {
  CLERK_PUBLISHABLE_KEY: string
  CLERK_SECRET_KEY: string
  CLERK_JWT_KEY: string
}

const app = new Hono<{ Bindings: Bindings }>()

app.use('*', clerkMiddleware())

app.get('/me', (c) => {
  const auth = getAuth(c)
  if (!auth?.userId) {
    return c.json({ error: 'unauthorized' }, 401)
  }
  return c.json({ userId: auth.userId, orgId: auth.orgId })
})

export default app

The middleware picks up CLERK_SECRET_KEY and CLERK_PUBLISHABLE_KEY from the Worker's environment bindings (@hono/clerk-auth source).

Raw Workers + @clerk/backend

// src/index.ts
import { createClerkClient } from '@clerk/backend'

type Env = {
  CLERK_SECRET_KEY: string
  CLERK_PUBLISHABLE_KEY: string
  CLERK_JWT_KEY: string
}

export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    const clerkClient = createClerkClient({
      secretKey: env.CLERK_SECRET_KEY,
      publishableKey: env.CLERK_PUBLISHABLE_KEY,
      jwtKey: env.CLERK_JWT_KEY,
    })

    const requestState = await clerkClient.authenticateRequest(request)

    if (!requestState.isAuthenticated) {
      return new Response('Unauthorized', { status: 401 })
    }

    const { userId } = requestState.toAuth()
    return Response.json({ userId, tokenType: requestState.tokenType })
  },
}

authenticateRequest() accepts a standard Web API Request object and returns a RequestState that exposes isAuthenticated, status, and tokenType (Clerk authenticateRequest() reference, community tRPC + Clerk on Workers).

Environment bindings are set via wrangler secret put or the Cloudflare dashboard (Cloudflare Workers Secrets):

wrangler secret put CLERK_SECRET_KEY
wrangler secret put CLERK_JWT_KEY

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

Tip

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

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

Clerk's machine-auth model distinguishes three approaches (Clerk machine-auth overview):

  • OAuth access tokens — on-behalf-of-user, issued by Clerk acting as an OAuth 2.0 / OIDC authorization server.
  • M2M tokens — service-to-service. The recommended default for internal calls.
  • API keys — long-lived, user- or org-delegated credentials.

While OAuth 2.0 client credentials grant is on Clerk's roadmap, use M2M tokens for service-to-service identity today.

Token formats for M2M (Clerk M2M token formats, Clerk changelog — M2M JWT tokens):

  • JWT M2M tokens (released February 24, 2026) — free to verify, networkless, cannot be revoked. Recommended default.
  • Opaque M2M tokens — $0.00001 per verification, instantly revocable. Use for revocation-sensitive workloads.

Max 150 scopes per M2M token (Clerk M2M tokens guide).

Issuing a token from one service:

// services/payments/issue-m2m.ts
import { createClerkClient } from '@clerk/backend'

const clerkClient = createClerkClient({
  secretKey: process.env.CLERK_MACHINE_SECRET_KEY!,
})

export async function mintWorkerToken() {
  const token = await clerkClient.m2m.createToken({
    claims: { audience: 'https://api.example.com' },
    scopes: ['read:payments', 'write:invoices'],
    secondsUntilExpiration: 300, // 5 minutes
  })
  return token
}

Verifying on the receiving service:

// services/api/verify-m2m.ts
import { createClerkClient } from '@clerk/backend'

const clerkClient = createClerkClient({
  secretKey: process.env.CLERK_MACHINE_SECRET_KEY!,
})

export async function verifyM2M(bearerToken: string) {
  const result = await clerkClient.m2m.verify({ token: bearerToken })
  if (result.tokenType !== 'm2m_token') {
    throw new Error('Unexpected token type')
  }
  return { machineId: result.machineId, scopes: result.scopes }
}

CLERK_MACHINE_SECRET_KEY (prefixed ak_) is the dedicated key for machine auth operations. Keep it separate from your application's CLERK_SECRET_KEY so you can rotate independently.

Multi-token acceptance pattern

Endpoints often serve multiple clients (web cookies, mobile Bearer tokens, third-party OAuth, internal M2M, API keys). Clerk's acceptsToken option collapses this into one call (Clerk verifying API keys in Next.js, Clerk authenticateRequest() reference).

// app/api/items/route.ts
import { auth } from '@clerk/nextjs/server'

export async function GET() {
  const result = await auth({
    acceptsToken: ['session_token', 'api_key', 'm2m_token', 'oauth_token'],
  })

  if (!result.isAuthenticated) {
    return Response.json({ error: 'unauthorized' }, { status: 401 })
  }

  switch (result.tokenType) {
    case 'session_token':
      return Response.json({ shape: 'user', userId: result.userId })
    case 'api_key':
      return Response.json({
        shape: 'api_key',
        subject: result.subject,
        scopes: result.scopes,
      })
    case 'm2m_token':
      return Response.json({
        shape: 'machine',
        machineId: result.machineId,
        scopes: result.scopes,
      })
    case 'oauth_token':
      return Response.json({
        shape: 'oauth',
        userId: result.userId,
        clientId: result.clientId,
      })
  }
}

result.tokenType tells downstream code which path to take without re-parsing the token.

Session handling across distributed functions

Serverless functions cannot rely on shared in-memory sessions. Clerk's hybrid model handles this:

  • __client cookie — long-lived identity on Clerk's Frontend API (FAPI) domain.
  • __session cookie — a 60-second JWT scoped to the app domain, containing the current session token.
  • Proactive refresh at 50 seconds via Clerk's frontend SDK — the token is refreshed in the background before it expires, so a long-running page never trips an expired-token state (Clerk Core 3 changelog, Clerk session tokens reference).

Session JWTs carry standard claims (exp, sub, iss, etc.) plus organization-scoped claims when active.

Long-running clients like @clerk/expo automatically handle refreshes via token caches.

Note

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

Monorepo setup with Clerk

The publishable key is the common thread, but env names and secrets differ by runtime. Server runtimes (@clerk/nextjs server, @clerk/backend) read CLERK_SECRET_KEY and CLERK_JWT_KEY alongside the publishable key; client runtimes take only the publishable key under a framework-specific prefix and never receive the secret or JWT key — NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY for @clerk/nextjs and EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY for @clerk/expo. Split the schema so each runtime validates only what belongs to it.

Shared auth package (config + types, not SDK wrapper)

// packages/auth-config/src/env.ts
import { z } from 'zod'

// Server runtimes: @clerk/nextjs (server) and @clerk/backend.
const serverSchema = 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(),
})

// Expo client: publishable key only, under the EXPO_PUBLIC_ prefix.
const expoSchema = z.object({
  EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY: z.string().startsWith('pk_'),
})

export const serverAuthEnv = serverSchema.parse(process.env)
export const expoAuthEnv = expoSchema.parse(process.env)
export type ServerAuthEnv = z.infer<typeof serverSchema>
export type ExpoAuthEnv = z.infer<typeof expoSchema>

turbo.json declares auth env vars as global so cache keys include them:

{
  "globalEnv": [
    "CLERK_PUBLISHABLE_KEY",
    "CLERK_SECRET_KEY",
    "CLERK_JWT_KEY",
    "CLERK_MACHINE_SECRET_KEY"
  ],
  "tasks": {
    "build": { "dependsOn": ["^build"], "outputs": [".next/**", "dist/**"] },
    "dev": { "cache": false, "persistent": true }
  }
}

Apps read authEnv and pass values to runtime-specific SDKs without shared wrappers.

Per-app handlers

  • Web app (apps/web): proxy.ts with clerkMiddleware() + ClerkProvider at app/layout.tsx (see "Edge middleware with Clerk and Next.js 16" and "Installing and configuring Clerk" above).
  • API service (apps/api, Cloudflare Workers): @clerk/backend + @hono/clerk-auth (see "Clerk on Cloudflare Workers" above).
  • Mobile (apps/mobile, Expo): @clerk/expo with <ClerkProvider> + tokenCache.
  • Background workers (apps/workers): CLERK_MACHINE_SECRET_KEY + clerkClient.m2m.createToken() for internal calls (see "Machine-to-machine and backend-to-backend with Clerk" above).

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

For cross-origin setups (e.g., app.example.com and api.example.com), same-origin requests use the __session cookie; cross-origin requests use Authorization: Bearer with the JWT from getToken() (Clerk making requests, Clerk cookies guide).

Architecture (one transport per hop):

┌───────────────────┐                                ┌─────────────────────┐
│ Web (Next.js 16)  │  Authorization: Bearer <JWT>   │ API (CF Worker)     │
│ proxy.ts          │ ─────────────────────────────→ │ @clerk/backend      │
│ clerkMiddleware() │   (cross-origin: api.domain)   │ authenticateRequest │
└───────────────────┘                                └─────────┬───────────┘

┌───────────────────┐  Authorization: Bearer <JWT>             │
│ Mobile (Expo)     │ ───────────────────────────────────────→ │
│ @clerk/expo       │   (always cross-origin)                  │
└───────────────────┘                                          │

┌───────────────────┐  Authorization: Bearer <M2M JWT>         │
│ Background Worker │ ───────────────────────────────────────→ │
│ CLERK_MACHINE_    │   (service-to-service)                   │
│ SECRET_KEY        │                                          │
└───────────────────┘                                          ▼
                                                        tokenType switch:
                                                        session_token | m2m_token
  • User-identity hop (web → API, cross-origin). The frontend calls getToken() for the __session JWT, sending it via Authorization: Bearer to the API. The Worker verifies it networklessly via jwtKey.
  • User-identity hop (mobile → API). @clerk/expo caches and attaches the session token as Authorization: Bearer. Verification remains identical.
  • M2M hop (background worker → API). Workers use CLERK_MACHINE_SECRET_KEY to mint JWT M2M tokens, sending them via Authorization: Bearer. The API accepts both via acceptsToken: ['session_token', 'm2m_token'].

Same-origin alternative. For same-origin APIs (both at example.com), the __session cookie flows automatically to authenticateRequest() without Bearer headers. Choose the transport matching your deployment.

Next Steps

In this second part, we covered the critical constraints of major serverless platforms, tackled the unique challenges of monorepo authentication architectures, and walked through a complete implementation utilizing Clerk Core 3 and Next.js 16. By pushing JWT verification to the edge and centralizing shared auth configuration, you can eliminate network round trips and avoid config drift across distributed applications.

In Part 3, we will shift focus to advanced use cases, including robust security patterns, session revocation in stateless systems, and managing edge authorization securely.

Frequently Asked Questions