Every SaaS product needs authentication. The question is never whether to build it it is how fast you can implement it without creating security liabilities that cost you users, data, or both.
Building authentication from scratch is one of the most common and most expensive mistakes early-stage SaaS teams make. Password hashing, session management, OAuth flows, multi-factor authentication, team invitations, role-based access control each is a non-trivial engineering problem that takes weeks to implement correctly and months to harden against real-world attacks.
Clerk solves this. It is an authentication and user management platform designed specifically for modern SaaS applications. It handles everything from email and password login to organizations, team invitations, roles, and enterprise SSO, and integrates directly with every major frontend and backend stack.
Overview
Clerk provides a complete authentication layer as a managed service. Your application delegates all identity concerns to Clerk, which handles credential storage, session management, token issuance, social OAuth flows, MFA, and organization management. Your backend validates the short-lived JWTs Clerk issues on every protected request. Your frontend uses Clerk's SDK to render authentication UI and read the current session state.
- Pre-built, customizable SignIn and SignUp components that match your brand in minutes
- Social logins for Google, GitHub, Microsoft, Apple, and 20+ other providers with zero backend code
- Organization and team management for multi-tenant SaaS applications
- Role-based access control with custom roles and fine-grained permissions
- Multi-factor authentication with TOTP apps, SMS, and backup codes
- Short-lived JWTs for stateless backend validation across any language or framework
- Clerk webhooks that fire on user creation, session start, role changes, and organization events
Business Benefits
Faster Development
A full-featured authentication system social logins, MFA, organizations, role management takes 4 to 8 weeks to build correctly from scratch. Clerk reduces that to 1 to 2 days. The engineering time saved goes directly toward product features that differentiate your business. For early-stage teams, shipping auth in days rather than months compresses the time to first paying customer significantly.
Enterprise-Grade Security Out of the Box
Clerk handles the security engineering that most product teams get wrong: brute-force protection, credential stuffing detection, secure password hashing, token rotation, and session revocation. These are not features you configure they are enforced by default. Clerk is SOC 2 Type 2 certified and GDPR compliant, which satisfies the initial security questionnaires from enterprise buyers without any additional work.
User Onboarding That Converts
Clerk's pre-built components handle the full onboarding flow: email verification, social login, progressive profile completion, and magic link sign-in. Each component is customizable to match your brand. The result is an onboarding experience that converts at the same rate as established consumer products, without your team spending weeks building and iterating on registration flows.
Multi-Tenant Organization Management
Clerk's Organizations feature gives every B2B SaaS product team-based access control out of the box. Each organization has its own member list, invitations, and role assignments. Members can belong to multiple organizations, switch between them in the UI, and carry different roles in each. This eliminates weeks of database schema design and UI work that would otherwise be required to support team accounts.
Reduced Authentication Risk
Authentication is one of the highest-risk engineering domains. A vulnerability in your auth layer exposes user data, enables account takeover, and can result in regulatory penalties. Delegating auth to Clerk means security incidents in the authentication layer are Clerk's responsibility to detect and respond to, not yours. Clerk's security team monitors for threats 24 hours a day, patches vulnerabilities within hours, and publishes their security practices publicly.
Compliance and Audit Readiness
Enterprise buyers require SOC 2 compliance, GDPR data processing agreements, and audit logs of authentication events. Clerk provides all three. Every sign-in, sign-out, role change, and failed attempt is logged in Clerk's audit log. GDPR deletion requests are handled by a single API call. When your first enterprise customer sends a security questionnaire, Clerk's compliance documentation answers the auth sections for you.
Architecture Overview
Clerk sits between your frontend and your backend as the identity layer. Your frontend uses Clerk's SDK to render authentication UI, read session state, and get the current user's JWT. Your backend uses Clerk's SDK or a JWT library to validate that token on every protected request. Your application data is linked to the Clerk user ID as a foreign key.
flowchart LR
USR["User / Browser"] --> FE["Frontend\nReact / Next.js / Vue"]
FE -- "SignIn / SignUp\nUI Components" --> CLERK["Clerk\nAuthentication Layer"]
CLERK -- "Short-lived JWT\n+ Session Cookie" --> FE
FE -- "API request\n+ Bearer JWT" --> BE["Backend API\nNode.js / Django / Go..."]
BE -- "Verify JWT\nusing Clerk JWKS" --> CLERK
CLERK -- "Public Key" --> BE
BE -- "User ID as FK" --> DB["Database\nApplication Data"]
CLERK -- "Webhooks\non user events" --> BEThe core architectural rule: Clerk handles identity, your backend handles business logic, your database handles application data. Your frontend never calls Clerk's secret key API it only reads the session state. Your backend validates the JWT on every protected request using Clerk's public JWKS endpoint.
Authentication Flow
- 1User visits your application and is redirected to Clerk's hosted SignIn page or sees Clerk's embedded component
- 2User authenticates via email/password, social provider, magic link, or passkey
- 3Clerk validates credentials, triggers MFA if enabled, and issues a short-lived JWT (default 60 seconds)
- 4Frontend SDK stores the session cookie and makes the JWT available via getToken()
- 5Frontend adds the JWT as a Bearer token in the Authorization header of every API request
- 6Backend middleware validates the JWT against Clerk's JWKS public endpoint on every request
- 7Business logic runs with the verified user ID never trust the user ID from the frontend directly
- 8Clerk webhooks fire for user creation, organization events, and role changes, triggering your automation
Frontend Integration
Clerk provides framework-specific SDKs for React, Next.js, and Vue, and a vanilla JavaScript SDK for every other framework. The core pattern is identical across all frameworks: wrap your application in a Clerk provider, use the sign-in and sign-up components or hooks, and read the current user's session state from Clerk's context.
React
npm install @clerk/clerk-reactimport { ClerkProvider, SignedIn, SignedOut, SignInButton, UserButton, useUser } from '@clerk/clerk-react'
const PUBLISHABLE_KEY = import.meta.env.VITE_CLERK_PUBLISHABLE_KEY
function App() {
return (
<ClerkProvider publishableKey={PUBLISHABLE_KEY}>
<Header />
<main>
<SignedOut>
<SignInButton mode="modal">
<button className="btn-primary">Sign In</button>
</SignInButton>
</SignedOut>
<SignedIn>
<Dashboard />
</SignedIn>
</main>
</ClerkProvider>
)
}
function Header() {
return (
<header className="flex justify-between p-4">
<span className="font-bold">Your App</span>
<SignedIn>
<UserButton afterSignOutUrl="/" />
</SignedIn>
</header>
)
}
function Dashboard() {
const { user } = useUser()
return <h1>Welcome, {user?.firstName}</h1>
}Next.js
npm install @clerk/nextjsimport { clerkMiddleware, createRouteMatcher } from '@clerk/nextjs/server'
const isPublicRoute = createRouteMatcher(['/sign-in(.*)', '/sign-up(.*)', '/api/webhooks(.*)'])
export default clerkMiddleware(async (auth, req) => {
if (!isPublicRoute(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)(.*)'],
}import { auth, currentUser } from '@clerk/nextjs/server'
export default async function DashboardPage() {
const { userId } = await auth()
if (!userId) return null
const user = await currentUser()
return (
<div>
<h1>Welcome, {user?.firstName}</h1>
<p>Your Clerk user ID: {userId}</p>
</div>
)
}Vue.js, Nuxt
npm install @clerk/vue// Nuxt plugin
import { clerkPlugin } from '@clerk/vue'
export default defineNuxtPlugin((nuxtApp) => {
nuxtApp.vueApp.use(clerkPlugin, {
publishableKey: useRuntimeConfig().public.clerkPublishableKey,
})
})<script setup lang="ts">
import { SignedIn, SignedOut, UserButton, SignInButton } from '@clerk/vue'
import { useUser } from '@clerk/vue'
const { user } = useUser()
</script>
<template>
<header class="flex justify-between p-4">
<span class="font-bold">Your App</span>
<SignedIn>
<p>Hello, {{ user?.firstName }}</p>
<UserButton after-sign-out-url="/" />
</SignedIn>
<SignedOut>
<SignInButton mode="modal">
<button>Sign In</button>
</SignInButton>
</SignedOut>
</header>
</template>Angular
npm install @clerk/clerk-jsimport { Injectable } from '@angular/core'
import Clerk from '@clerk/clerk-js'
import { BehaviorSubject } from 'rxjs'
@Injectable({ providedIn: 'root' })
export class ClerkService {
private clerk!: Clerk
readonly user$ = new BehaviorSubject<any>(null)
readonly isLoaded$ = new BehaviorSubject(false)
async initialize() {
this.clerk = new Clerk(import.meta.env['NG_APP_CLERK_PUBLISHABLE_KEY'])
await this.clerk.load()
this.isLoaded$.next(true)
this.user$.next(this.clerk.user)
this.clerk.addListener(({ user }) => this.user$.next(user))
}
openSignIn() { this.clerk.openSignIn({}) }
openSignUp() { this.clerk.openSignUp({}) }
signOut() { return this.clerk.signOut() }
async getToken(): Promise<string | null> {
return this.clerk.session?.getToken() ?? null
}
}import { HttpInterceptorFn } from '@angular/common/http'
import { inject } from '@angular/core'
import { from, switchMap } from 'rxjs'
import { ClerkService } from '../services/clerk.service'
export const authInterceptor: HttpInterceptorFn = (req, next) => {
const clerk = inject(ClerkService)
return from(clerk.getToken()).pipe(
switchMap((token) => {
if (!token) return next(req)
return next(req.clone({ setHeaders: { Authorization: `Bearer ${token}` } }))
})
)
}Svelte
npm install @clerk/clerk-js<script lang="ts">
import Clerk from '@clerk/clerk-js'
import { setContext, onMount } from 'svelte'
import { writable } from 'svelte/store'
export let publishableKey: string
const clerk = writable<Clerk | null>(null)
const user = writable<any>(null)
setContext('clerk', clerk)
setContext('user', user)
onMount(async () => {
const instance = new Clerk(publishableKey)
await instance.load()
clerk.set(instance)
user.set(instance.user)
instance.addListener(({ user: u }) => user.set(u))
})
</script>
<slot /><script lang="ts">
import { getContext } from 'svelte'
import type { Writable } from 'svelte/store'
import type Clerk from '@clerk/clerk-js'
const clerk = getContext<Writable<Clerk | null>>('clerk')
function openSignIn() {
$clerk?.openSignIn({})
}
</script>
<button on:click={openSignIn}><slot>Sign In</slot></button>Solid.js
import Clerk from '@clerk/clerk-js'
import { createContext, createSignal, onMount, useContext, JSX } from 'solid-js'
const ClerkContext = createContext<{ clerk: () => Clerk | null; user: () => any }>()
export function ClerkProvider(props: { publishableKey: string; children: JSX.Element }) {
const [clerk, setClerk] = createSignal<Clerk | null>(null)
const [user, setUser] = createSignal<any>(null)
onMount(async () => {
const instance = new Clerk(props.publishableKey)
await instance.load()
setClerk(instance)
setUser(instance.user)
instance.addListener(({ user: u }) => setUser(u))
})
return (
<ClerkContext.Provider value={{ clerk, user }}>
{props.children}
</ClerkContext.Provider>
)
}
export const useClerk = () => useContext(ClerkContext)!Qwik
import { component$, useVisibleTask$, useStore } from '@builder.io/qwik'
import Clerk from '@clerk/clerk-js'
export const ClerkLayout = component$(() => {
const state = useStore<{ user: any; loaded: boolean }>({ user: null, loaded: false })
useVisibleTask$(async () => {
const clerk = new Clerk(import.meta.env.PUBLIC_CLERK_PUBLISHABLE_KEY as string)
await clerk.load()
state.user = clerk.user
state.loaded = true
clerk.addListener(({ user }) => { state.user = user })
})
return (
<div>
{state.loaded && (
state.user
? <span>Hello, {state.user.firstName}</span>
: <button onClick$={() => (window as any).Clerk?.openSignIn({})}>Sign In</button>
)}
<slot />
</div>
)
})Astro
npm install @clerk/astroimport { defineConfig } from 'astro/config'
import clerk from '@clerk/astro'
export default defineConfig({
integrations: [clerk()],
})---
import { auth } from '@clerk/astro/server'
const { userId } = auth()
if (!userId) return Astro.redirect('/sign-in')
---
<html>
<body>
<h1>Dashboard</h1>
<p>User ID: {userId}</p>
</body>
</html>Backend Integration
Every protected backend endpoint must validate the Clerk JWT on every request. The JWT is short-lived (60 seconds by default) and signed with Clerk's private key. Your backend verifies the signature using Clerk's public JWKS endpoint. The validated token contains the user ID, organization ID, and any public metadata you have set on the user.
Node.js / Express
npm install @clerk/backend expressimport { createClerkClient } from '@clerk/backend'
import type { Request, Response, NextFunction } from 'express'
const clerkClient = createClerkClient({ secretKey: process.env.CLERK_SECRET_KEY! })
export async function requireAuth(req: Request, res: Response, next: NextFunction) {
const token = req.headers.authorization?.replace('Bearer ', '')
if (!token) return res.status(401).json({ error: 'No token provided' })
try {
const { sub: userId, org_id: orgId } = await clerkClient.verifyToken(token)
;(req as any).userId = userId
;(req as any).orgId = orgId
next()
} catch {
res.status(401).json({ error: 'Invalid or expired token' })
}
}
// Usage
import express from 'express'
const app = express()
app.get('/api/profile', requireAuth, async (req, res) => {
const userId = (req as any).userId
const user = await clerkClient.users.getUser(userId)
res.json({ id: user.id, email: user.emailAddresses[0]?.emailAddress })
})NestJS
npm install @clerk/backend @nestjs/passport passportimport { CanActivate, ExecutionContext, Injectable, UnauthorizedException } from '@nestjs/common'
import { createClerkClient } from '@clerk/backend'
import { ConfigService } from '@nestjs/config'
@Injectable()
export class ClerkAuthGuard implements CanActivate {
private readonly clerk
constructor(private readonly config: ConfigService) {
this.clerk = createClerkClient({ secretKey: this.config.getOrThrow('CLERK_SECRET_KEY') })
}
async canActivate(context: ExecutionContext): Promise<boolean> {
const req = context.switchToHttp().getRequest()
const token = req.headers.authorization?.replace('Bearer ', '')
if (!token) throw new UnauthorizedException()
try {
const payload = await this.clerk.verifyToken(token)
req.userId = payload.sub
req.orgId = payload.org_id
return true
} catch {
throw new UnauthorizedException('Invalid token')
}
}
}import { Controller, Get, Req, UseGuards } from '@nestjs/common'
import { ClerkAuthGuard } from '../auth/clerk.guard'
@Controller('api')
@UseGuards(ClerkAuthGuard)
export class UsersController {
@Get('profile')
getProfile(@Req() req: any) {
return { userId: req.userId, orgId: req.orgId }
}
}Python FastAPI
pip install clerk-backend-api python-jose httpximport httpx
from jose import jwt, JWTError
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from functools import lru_cache
from app.core.config import settings
security = HTTPBearer()
@lru_cache(maxsize=1)
def get_clerk_jwks() -> dict:
res = httpx.get(f"https://api.clerk.com/v1/jwks", headers={"Authorization": f"Bearer {settings.CLERK_SECRET_KEY}"})
return res.json()
async def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(security)) -> dict:
token = credentials.credentials
try:
jwks = get_clerk_jwks()
payload = jwt.decode(token, jwks, algorithms=["RS256"], options={"verify_aud": False})
user_id: str = payload.get("sub")
if not user_id:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
return {"user_id": user_id, "org_id": payload.get("org_id")}
except JWTError:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
# Usage in a route
from fastapi import APIRouter
router = APIRouter()
@router.get("/api/profile")
async def profile(current_user: dict = Depends(get_current_user)):
return current_userLaravel
composer require firebase/php-jwt<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Firebase\JWT\JWT;
use Firebase\JWT\JWK;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
class ClerkAuth
{
public function handle(Request $request, Closure $next): mixed
{
$token = $request->bearerToken();
if (!$token) {
return response()->json(['error' => 'Unauthorized'], 401);
}
try {
$jwks = Cache::remember('clerk_jwks', 300, function () {
return Http::withToken(config('services.clerk.secret_key'))
->get('https://api.clerk.com/v1/jwks')
->json();
});
$keys = JWK::parseKeySet($jwks);
$payload = JWT::decode($token, $keys);
$request->merge([
'clerk_user_id' => $payload->sub,
'clerk_org_id' => $payload->org_id ?? null,
]);
return $next($request);
} catch (\Exception $e) {
return response()->json(['error' => 'Invalid token'], 401);
}
}
}Django
pip install PyJWT requestsimport jwt
import requests
from functools import lru_cache
from django.http import JsonResponse
from django.conf import settings
from jwt.algorithms import RSAAlgorithm
@lru_cache(maxsize=1)
def get_clerk_public_keys():
res = requests.get(
"https://api.clerk.com/v1/jwks",
headers={"Authorization": f"Bearer {settings.CLERK_SECRET_KEY}"},
)
jwks = res.json()
return {key["kid"]: RSAAlgorithm.from_jwk(key) for key in jwks["keys"]}
class ClerkAuthMiddleware:
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
if not request.path.startswith("/api/"):
return self.get_response(request)
auth = request.headers.get("Authorization", "")
if not auth.startswith("Bearer "):
return JsonResponse({"error": "Unauthorized"}, status=401)
token = auth[7:]
try:
header = jwt.get_unverified_header(token)
keys = get_clerk_public_keys()
public_key = keys[header["kid"]]
payload = jwt.decode(token, public_key, algorithms=["RS256"], options={"verify_aud": False})
request.clerk_user_id = payload["sub"]
request.clerk_org_id = payload.get("org_id")
except Exception:
return JsonResponse({"error": "Invalid token"}, status=401)
return self.get_response(request)Go
go get github.com/golang-jwt/jwt/v5 github.com/lestrrat-go/jwx/v2/jwkpackage middleware
import (
"context"
"net/http"
"strings"
"time"
"github.com/golang-jwt/jwt/v5"
"github.com/lestrrat-go/jwx/v2/jwk"
)
type contextKey string
const UserIDKey contextKey = "clerkUserID"
const OrgIDKey contextKey = "clerkOrgID"
var jwksCache jwk.Set
var jwksExpiry time.Time
func getJWKS() (jwk.Set, error) {
if jwksCache != nil && time.Now().Before(jwksExpiry) {
return jwksCache, nil
}
set, err := jwk.Fetch(context.Background(), "https://api.clerk.com/v1/jwks")
if err != nil {
return nil, err
}
jwksCache = set
jwksExpiry = time.Now().Add(5 * time.Minute)
return set, nil
}
func ClerkAuth(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
auth := r.Header.Get("Authorization")
if !strings.HasPrefix(auth, "Bearer ") {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
tokenStr := auth[7:]
jwks, err := getJWKS()
if err != nil {
http.Error(w, "Failed to fetch JWKS", http.StatusInternalServerError)
return
}
token, err := jwt.Parse(tokenStr, func(t *jwt.Token) (interface{}, error) {
kid, _ := t.Header["kid"].(string)
key, found := jwks.LookupKeyID(kid)
if !found {
return nil, jwt.ErrTokenSignatureInvalid
}
var rawKey interface{}
_ = key.Raw(&rawKey)
return rawKey, nil
})
if err != nil || !token.Valid {
http.Error(w, "Invalid token", http.StatusUnauthorized)
return
}
claims, _ := token.Claims.(jwt.MapClaims)
ctx := context.WithValue(r.Context(), UserIDKey, claims["sub"])
ctx = context.WithValue(ctx, OrgIDKey, claims["org_id"])
next.ServeHTTP(w, r.WithContext(ctx))
})
}Spring Boot
@Component
public class ClerkJwtFilter extends OncePerRequestFilter {
private final String clerkJwksUrl = "https://api.clerk.com/v1/jwks";
private JwkProvider jwkProvider;
@PostConstruct
public void init() {
this.jwkProvider = new JwkProviderBuilder(clerkJwksUrl)
.cached(10, 60, TimeUnit.MINUTES)
.build();
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain chain) throws IOException, ServletException {
String auth = request.getHeader("Authorization");
if (auth == null || !auth.startsWith("Bearer ")) {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
return;
}
String token = auth.substring(7);
try {
DecodedJWT jwt = JWT.decode(token);
Jwk jwk = jwkProvider.get(jwt.getKeyId());
Algorithm algorithm = Algorithm.RSA256((RSAPublicKey) jwk.getPublicKey(), null);
JWTVerifier verifier = JWT.require(algorithm).build();
DecodedJWT verified = verifier.verify(token);
request.setAttribute("clerkUserId", verified.getSubject());
request.setAttribute("clerkOrgId", verified.getClaim("org_id").asString());
chain.doFilter(request, response);
} catch (Exception e) {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
}
}
}ASP.NET Core
// Program.cs
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.Authority = $"https://clerk.{builder.Configuration["Clerk:Domain"]}";
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateAudience = false,
ValidateIssuer = true,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
};
options.ConfigurationManager = new ConfigurationManager<OpenIdConnectConfiguration>(
$"https://api.clerk.com/v1/jwks",
new JwksRetriever(),
TimeSpan.FromMinutes(5),
TimeSpan.FromMinutes(1)
);
});
builder.Services.AddAuthorization();
// Controller usage
[ApiController]
[Route("api")]
[Authorize]
public class ProfileController : ControllerBase
{
[HttpGet("profile")]
public IActionResult GetProfile()
{
var userId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
var orgId = User.FindFirst("org_id")?.Value;
return Ok(new { userId, orgId });
}
}Rust
# Cargo.toml: axum = "0.7", jsonwebtoken = "9", reqwest = { features = ["json"] }, serde_json = "1"use axum::{extract::Request, http::StatusCode, middleware::Next, response::Response};
use jsonwebtoken::{decode, decode_header, DecodingKey, Validation, Algorithm};
use reqwest::Client;
use serde_json::Value;
use std::sync::OnceLock;
static JWKS: OnceLock<Value> = OnceLock::new();
async fn fetch_jwks() -> Value {
Client::new()
.get("https://api.clerk.com/v1/jwks")
.send().await.unwrap()
.json().await.unwrap()
}
pub async fn clerk_auth(mut request: Request, next: Next) -> Result<Response, StatusCode> {
let token = request
.headers()
.get("Authorization")
.and_then(|v| v.to_str().ok())
.and_then(|v| v.strip_prefix("Bearer "))
.ok_or(StatusCode::UNAUTHORIZED)?;
let header = decode_header(token).map_err(|_| StatusCode::UNAUTHORIZED)?;
let kid = header.kid.ok_or(StatusCode::UNAUTHORIZED)?;
let jwks = JWKS.get_or_init(|| futures::executor::block_on(fetch_jwks()));
let key_data = jwks["keys"].as_array().unwrap().iter()
.find(|k| k["kid"].as_str() == Some(&kid))
.ok_or(StatusCode::UNAUTHORIZED)?;
let decoding_key = DecodingKey::from_rsa_components(
key_data["n"].as_str().unwrap(),
key_data["e"].as_str().unwrap(),
).map_err(|_| StatusCode::UNAUTHORIZED)?;
let mut validation = Validation::new(Algorithm::RS256);
validation.validate_aud = false;
let token_data = decode::<serde_json::Map<String, Value>>(token, &decoding_key, &validation)
.map_err(|_| StatusCode::UNAUTHORIZED)?;
request.extensions_mut().insert(token_data.claims["sub"].as_str().unwrap_or("").to_string());
Ok(next.run(request).await)
}Authentication
Clerk authentication involves a publishable key for the frontend, a secret key for backend API calls, and webhook signing secrets for validating Clerk event notifications. The publishable key is safe to expose in client-side code. The secret key and webhook signing secret must never appear in client-side code, version control, or logs.
Clerk App Setup
- 1Sign in to clerk.com and create a new application give it your product name
- 2Select the sign-in methods to enable: email, password, Google, GitHub, etc.
- 3Copy the Publishable Key (safe for frontend) from the API Keys panel
- 4Copy the Secret Key (backend only) from the API Keys panel
- 5In Webhooks, create a new endpoint pointing to your backend URL for Clerk events
- 6Copy the Webhook Signing Secret from the webhook configuration
- 7Enable Organizations in the Organizations settings if your app is multi-tenant
- 8Configure custom roles in the Roles section under Organizations
- 9Set up Email Templates in the Emails section to match your brand
Environment Variables
# Frontend safe to expose, prefixed for Next.js
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_live_xxxxxxxxxxxxx
# Or for Vite / React
VITE_CLERK_PUBLISHABLE_KEY=pk_live_xxxxxxxxxxxxx
# Backend only never put in client-side code or commit to version control
CLERK_SECRET_KEY=sk_live_xxxxxxxxxxxxx
# Webhook signing secret used to validate Clerk event webhooks
CLERK_WEBHOOK_SECRET=whsec_xxxxxxxxxxxxx
# Redirect URLs after sign-in / sign-out
NEXT_PUBLIC_CLERK_SIGN_IN_URL=/sign-in
NEXT_PUBLIC_CLERK_SIGN_UP_URL=/sign-up
NEXT_PUBLIC_CLERK_AFTER_SIGN_IN_URL=/dashboard
NEXT_PUBLIC_CLERK_AFTER_SIGN_UP_URL=/dashboardWebhook Signature Validation
import { Webhook } from 'svix'
import { headers } from 'next/headers'
import { WebhookEvent } from '@clerk/nextjs/server'
export async function POST(req: Request) {
const WEBHOOK_SECRET = process.env.CLERK_WEBHOOK_SECRET
if (!WEBHOOK_SECRET) throw new Error('Missing CLERK_WEBHOOK_SECRET')
const headerPayload = await headers()
const svix_id = headerPayload.get('svix-id')
const svix_timestamp = headerPayload.get('svix-timestamp')
const svix_signature = headerPayload.get('svix-signature')
if (!svix_id || !svix_timestamp || !svix_signature) {
return new Response('Missing svix headers', { status: 400 })
}
const payload = await req.json()
const body = JSON.stringify(payload)
const wh = new Webhook(WEBHOOK_SECRET)
let event: WebhookEvent
try {
event = wh.verify(body, { 'svix-id': svix_id, 'svix-timestamp': svix_timestamp, 'svix-signature': svix_signature }) as WebhookEvent
} catch {
return new Response('Invalid webhook signature', { status: 401 })
}
// Process validated event
switch (event.type) {
case 'user.created':
await handleUserCreated(event.data)
break
case 'organization.created':
await handleOrgCreated(event.data)
break
case 'organizationMembership.created':
await handleMemberAdded(event.data)
break
}
return new Response('OK', { status: 200 })
}
async function handleUserCreated(data: any) {
const email = data.email_addresses[0]?.email_address
await db.users.create({ data: { clerkId: data.id, email } })
}
async function handleOrgCreated(data: any) {
await db.organizations.create({ data: { clerkId: data.id, name: data.name } })
}
async function handleMemberAdded(data: any) {
console.log('Member added to org:', data.organization.id)
}JWT Validation Best Practices
- Cache the Clerk JWKS response with a TTL of 5 minutes Clerk rotates keys periodically and fetching on every request adds latency
- Always verify the JWT signature never decode and trust without verification
- Use the sub claim as the user identifier in your database, not the email address
- Set the JWT token lifetime to 60 seconds in Clerk dashboard for maximum security
- On the backend, validate the token on every request never cache a validated user session in server memory
- The secret key (sk_live_) must only ever be used server-side never expose it to the browser
Automation Examples
User Registered Provision and Notify
import { WebClient as Slack } from '@slack/web-api'
import { Client as HubSpot } from '@hubspot/api-client'
const slack = new Slack(process.env.SLACK_BOT_TOKEN)
const hubspot = new HubSpot({ accessToken: process.env.HUBSPOT_ACCESS_TOKEN })
export async function handleUserCreated(data: {
id: string
email_addresses: Array<{ email_address: string }>
first_name: string | null
last_name: string | null
}) {
const email = data.email_addresses[0]?.email_address
const name = [data.first_name, data.last_name].filter(Boolean).join(' ')
// 1. Create user record in your database
await db.users.create({ data: { clerkId: data.id, email, name } })
// 2. Create contact in HubSpot
await hubspot.crm.contacts.basicApi.create({
properties: { email, firstname: data.first_name ?? '', lastname: data.last_name ?? '' },
})
// 3. Notify the growth team in Slack
await slack.chat.postMessage({
channel: process.env.SLACK_GROWTH_CHANNEL!,
blocks: [
{
type: 'section',
text: { type: 'mrkdwn', text: `:wave: *New user registered*\n*Name:* ${name || 'Unknown'}\n*Email:* ${email}` },
},
],
})
}Organization Created Provision Resources
export async function handleOrganizationCreated(data: {
id: string
name: string
slug: string
created_by: string
}) {
// 1. Create org record in your database
const org = await db.organizations.create({
data: { clerkId: data.id, name: data.name, slug: data.slug },
})
// 2. Provision default resources for the new organization
await db.workspaces.create({ data: { orgId: org.id, name: `${data.name}'s Workspace` } })
// 3. Assign the creator the Owner role in your database
const creator = await db.users.findUnique({ where: { clerkId: data.created_by } })
if (creator) {
await db.orgMemberships.create({
data: { userId: creator.id, orgId: org.id, role: 'owner' },
})
}
// 4. Send a welcome email via your email provider
await sendEmail({
to: creator?.email ?? '',
template: 'org-welcome',
data: { orgName: data.name },
})
}Role Changed Sync Permissions
export async function handleMembershipUpdated(data: {
id: string
role: string
public_user_data: { user_id: string }
organization: { id: string; name: string }
}) {
const clerkUserId = data.public_user_data.user_id
const clerkOrgId = data.organization.id
// Sync the new role to your database
const user = await db.users.findUnique({ where: { clerkId: clerkUserId } })
const org = await db.organizations.findUnique({ where: { clerkId: clerkOrgId } })
if (user && org) {
await db.orgMemberships.upsert({
where: { userId_orgId: { userId: user.id, orgId: org.id } },
update: { role: data.role },
create: { userId: user.id, orgId: org.id, role: data.role },
})
// If the user was granted admin, send them an email
if (data.role === 'org:admin') {
await sendEmail({
to: user.email,
template: 'role-upgraded',
data: { orgName: data.organization.name, role: 'Admin' },
})
}
}
}CRM Integrations
HubSpot
import { Client } from '@hubspot/api-client'
const hubspot = new Client({ accessToken: process.env.HUBSPOT_ACCESS_TOKEN })
export async function syncClerkUserToHubSpot(clerkUser: {
id: string
email: string
firstName: string | null
lastName: string | null
}) {
// Check if contact already exists
const existing = await hubspot.crm.contacts.searchApi.doSearch({
filterGroups: [{ filters: [{ propertyName: 'email', operator: 'EQ', value: clerkUser.email }] }],
properties: ['email', 'hs_object_id'],
limit: 1,
after: 0,
sorts: [],
})
const properties = {
email: clerkUser.email,
firstname: clerkUser.firstName ?? '',
lastname: clerkUser.lastName ?? '',
clerk_user_id: clerkUser.id,
}
if (existing.total > 0) {
const id = existing.results[0].id
await hubspot.crm.contacts.basicApi.update(id, { properties })
} else {
await hubspot.crm.contacts.basicApi.create({ properties })
}
}Salesforce
import jsforce from 'jsforce'
const conn = new jsforce.Connection({ loginUrl: process.env.SF_LOGIN_URL })
export async function syncClerkUserToSalesforce(user: {
id: string
email: string
firstName: string | null
lastName: string | null
}) {
await conn.login(process.env.SF_USERNAME!, process.env.SF_PASSWORD! + process.env.SF_SECURITY_TOKEN!)
// Upsert on Clerk__User_ID__c custom field
await (conn.sobject('Lead') as any).upsert(
{
Email: user.email,
FirstName: user.firstName ?? '',
LastName: user.lastName ?? 'Unknown',
Clerk__User_ID__c: user.id,
LeadSource: 'Web Signup',
},
'Clerk__User_ID__c'
)
}Zoho CRM
Zoho CRM does not have a push-based webhook for new leads, but you can create Zoho contacts directly from your Clerk webhook handler. When Clerk fires a user.created event, your backend calls the Zoho CRM v2 API at https://www.zohoapis.com/crm/v2/Contacts with an OAuth access token. Obtain the OAuth token using Zoho's server-to-server self-client flow and cache it with the expiry timestamp. Map Clerk's user ID to a custom field in Zoho so you can update the contact on subsequent Clerk events.
Custom CRM via Webhook
interface CRMContact {
externalId: string
email: string
name: string
source: string
metadata: Record<string, string>
}
export async function upsertCRMContact(user: {
id: string
email: string
name: string
orgId?: string
}) {
const contact: CRMContact = {
externalId: user.id,
email: user.email,
name: user.name,
source: 'clerk_registration',
metadata: {
clerkUserId: user.id,
...(user.orgId && { clerkOrgId: user.orgId }),
},
}
const res = await fetch(`${process.env.CRM_BASE_URL}/contacts/upsert`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-API-Key': process.env.CRM_API_KEY!,
},
body: JSON.stringify(contact),
})
if (!res.ok) {
throw new Error(`CRM upsert failed: ${res.status} ${await res.text()}`)
}
return res.json()
}AI Integrations
OpenAI
import { auth } from '@clerk/nextjs/server'
import { NextRequest, NextResponse } from 'next/server'
import OpenAI from 'openai'
const openai = new OpenAI()
export async function POST(req: NextRequest) {
const { userId, orgId, sessionClaims } = await auth()
if (!userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const { message } = await req.json()
const role = (sessionClaims?.org_role as string) ?? 'member'
// Tailor the system prompt based on the user's role and organization
const systemPrompt = orgId
? `You are an AI assistant for organization ${orgId}. The user's role is ${role}. ${role === 'org:admin' ? 'They have full access to all features.' : 'They have standard member access.'}`
: 'You are a helpful AI assistant.'
const completion = await openai.chat.completions.create({
model: 'gpt-4o',
messages: [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: message },
],
})
return NextResponse.json({ reply: completion.choices[0].message.content })
}Anthropic Claude
import { auth } from '@clerk/nextjs/server'
import { NextRequest, NextResponse } from 'next/server'
import Anthropic from '@anthropic-ai/sdk'
const claude = new Anthropic()
export async function POST(req: NextRequest) {
const { userId, orgId, sessionClaims } = await auth()
if (!userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const { message } = await req.json()
// Fetch user-specific context from your database
const userContext = await db.users.findUnique({
where: { clerkId: userId },
select: { name: true, plan: true, preferences: true },
})
const response = await claude.messages.create({
model: 'claude-sonnet-4-6',
max_tokens: 2048,
system: `You are a personalized AI assistant. The user is ${userContext?.name}. Plan: ${userContext?.plan}. Org: ${orgId ?? 'personal'}.`,
messages: [{ role: 'user', content: message }],
})
const text = response.content.filter((b) => b.type === 'text').map((b) => b.text).join('')
return NextResponse.json({ reply: text })
}Knowledge Base Search
import { auth } from '@clerk/nextjs/server'
import { NextRequest, NextResponse } from 'next/server'
import { searchKnowledgeBase } from '@/lib/kb'
import OpenAI from 'openai'
const openai = new OpenAI()
export async function POST(req: NextRequest) {
const { userId, orgId, sessionClaims } = await auth()
if (!userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const { query } = await req.json()
const role = (sessionClaims?.org_role as string) ?? 'member'
// Search only docs the user's role has access to
const accessLevel = role === 'org:admin' ? 'all' : 'member'
const docs = await searchKnowledgeBase(query, { orgId, accessLevel, topK: 3 })
if (!docs.length) {
return NextResponse.json({ answer: 'No relevant documentation found for your access level.' })
}
const context = docs.map((d) => `## ${d.title}\n${d.content}`).join('\n\n')
const { choices } = await openai.chat.completions.create({
model: 'gpt-4o',
messages: [
{ role: 'system', content: `Answer from this documentation only:\n${context}` },
{ role: 'user', content: query },
],
})
return NextResponse.json({
answer: choices[0].message.content,
sources: docs.map((d) => ({ title: d.title, url: d.url })),
})
}Security Considerations
Never Trust the Frontend Session Alone
The frontend session is controlled by the browser and can be tampered with. Every protected backend endpoint must validate the JWT independently on every request. The fact that a user is signed in on the frontend does not mean their request is authorized on the backend. Apply JWT validation middleware to every route that touches application data, never skip it for convenience during development.
Access Control
Role-based access control must be enforced on the backend. Frontend permission checks are for UX they hide UI elements from unauthorized users, which improves usability. They do not prevent a determined attacker from calling your API directly. Backend permission checks are the security boundary. Every mutation endpoint must verify the caller's role from the validated JWT claims before executing.
Token Rotation and Secrets Management
Clerk's secret key grants full access to your user database including read, write, and deletion. Store it in a secrets management service, not a .env file on the server. Rotate it if you suspect exposure. The publishable key is designed to be public expose it freely. The signing key for webhooks must be validated on every inbound event from Clerk to prevent arbitrary payloads from triggering your automation.
Data Protection
Clerk stores user credentials and session data. Your database stores application data linked to the Clerk user ID. Keep sensitive application data health information, financial records, private messages in your database, not in Clerk's user metadata. Clerk's public metadata is readable by the client, and private metadata is accessible to anyone with your secret key.
Audit Logging
Log every authentication event your backend processes from Clerk webhooks: user created, user signed in, role changed, organization created, member added. Include the Clerk user ID, organization ID, event type, and timestamp. For compliance-sensitive applications, ship these logs to an immutable log store (CloudWatch Logs with retention policy, Datadog Archive) so the audit trail cannot be modified after the fact.
Monitoring and Observability
Sentry
Attach the Clerk user ID to every Sentry event using Sentry's user context. In Next.js, read the userId from auth() in your global error boundary and set it on the Sentry scope. This links every error report to the specific user who triggered it, which dramatically reduces debugging time for user-reported issues. Tag events with the organization ID for multi-tenant applications so you can filter errors by org.
Datadog and Grafana
Track authentication metrics: sign-in success rate, sign-in failure rate by error type, new user registrations per day, organization creation rate, and JWT validation failure rate on your backend. Dashboard these alongside your application's conversion funnel metrics. A spike in sign-in failures often indicates a UX problem in the auth flow, a bug in your middleware, or an active credential stuffing attack.
Structured Logging
export function logAuthEvent(event: {
type: 'jwt_validated' | 'jwt_rejected' | 'webhook_received' | 'webhook_rejected' | 'user_synced'
userId?: string
orgId?: string
reason?: string
durationMs?: number
}) {
console.log(JSON.stringify({
service: 'clerk-auth',
...event,
timestamp: new Date().toISOString(),
}))
}
// In JWT middleware:
logAuthEvent({ type: 'jwt_validated', userId: payload.sub, orgId: payload.org_id, durationMs: 12 })
// On failure:
logAuthEvent({ type: 'jwt_rejected', reason: 'token_expired', durationMs: 3 })Webhook Health Monitoring
Clerk retries webhook delivery on failures and marks your endpoint as degraded if it consistently returns non-2xx responses. Monitor your webhook endpoint's success rate in Clerk's dashboard under Webhooks. Set up an alert if the failure rate exceeds 5% over a 10-minute window. A failing webhook endpoint means your user provisioning, CRM sync, and automation pipelines are silently falling behind.
Common Mistakes
Exposing the Clerk Secret Key on the Frontend
The Clerk secret key (sk_live_) gives read and write access to every user in your workspace. Prefixing it with NEXT_PUBLIC_ or VITE_ exposes it in your JavaScript bundle where any visitor can extract it from DevTools. Only the publishable key (pk_live_) belongs in client-side code. The secret key belongs in server-side environment variables and is used only for backend API calls and webhook validation.
Skipping Backend JWT Validation
A common mistake during development is trusting the user ID sent directly from the frontend in the request body rather than validating the JWT. This allows any client to impersonate any user by simply sending a different user ID. The validated sub claim from the JWT is the only trustworthy source of user identity on the backend. Apply JWT middleware to every protected route without exception.
Not Handling Webhook Events Idempotently
Clerk retries webhook delivery when your endpoint returns non-2xx responses. Without idempotency checks, a transient database error causes the same user.created event to be processed twice creating duplicate user records, duplicate CRM contacts, and duplicate Slack notifications. Use the Clerk event ID as an idempotency key: store it in your database and skip processing if it already exists.
Not Handling Organization Context in Multi-Tenant Queries
In multi-tenant applications, every database query that returns organization-scoped data must filter by organization ID from the JWT claims, not from the request body. A user who belongs to two organizations must not be able to read the second organization's data by sending the wrong org ID in the request. Extract org_id from the validated JWT claims and apply it as a WHERE clause on every scoped query.
Caching JWT Validation Results
Clerk JWTs are short-lived 60 seconds by default. Caching a validated JWT payload in server memory and reusing it across requests defeats the purpose of short-lived tokens. If a user's session is revoked or their role changes, cached validation results will serve stale permissions for up to the cache TTL. Validate the JWT on every request. The JWKS response should be cached, not the decoded token.
Production Deployment Checklist
- 1CLERK_SECRET_KEY and CLERK_WEBHOOK_SECRET stored in a secrets manager, not in .env files on the server
- 2NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY (or equivalent) is the only Clerk value exposed to the frontend
- 3JWT validation middleware applied to every protected backend route without exception
- 4Webhook handler validates the svix signature before processing any event data
- 5Webhook events processed idempotently using the Clerk event ID as a deduplication key
- 6Clerk JWKS response cached with a 5-minute TTL not the decoded JWT payload
- 7Organization ID extracted from the JWT claims on every multi-tenant query
- 8Role and permission checks enforced on the backend, not just in the frontend UI
- 9Clerk dashboard email addresses verified test with real providers, not only test accounts
- 10Social provider OAuth credentials (Google, GitHub) using production app credentials, not dev credentials
- 11Clerk webhook endpoint returning 200 within 5 seconds verify in Clerk's webhook delivery logs
- 12User deletion flow tested end-to-end: Clerk user deleted, application data cleaned up, CRM updated