Guide · Auth & Identity
MCP Tools for Clerk — session tokens, JWT templates, M2M machine tokens, webhook verification
Three Clerk behaviours catch developers building MCP auth integrations: session tokens expire in 60 seconds by default — any server-side code that caches the raw __session cookie value for longer than that will encounter 401s, and you must re-verify on every inbound MCP request using @clerk/backend's verifyToken() or Clerk's framework helper rather than trusting a cached decoded payload; auth().getToken({ template: 'my-template' }) is a server-to-server network call, not a local operation — unlike auth().getToken() which validates the existing session JWT locally, JWT template token issuance requires Clerk's API to sign a new token with the template's claims, adding 50–300 ms of latency on every invocation; and currentUser() makes an API request to Clerk's servers on every call — it fetches the full user record from GET /v1/users/{userId} and is unsuitable for tool handlers that run on every agent turn, prefer auth().userId for identity and make a single currentUser() call only when the full user object is genuinely needed.
TL;DR
Verify inbound token: const { userId } = await verifyToken(token, { secretKey: process.env.CLERK_SECRET_KEY }). Get session token server-side: const token = await auth().getToken() (local, fast). Get JWT template token: const token = await auth().getToken({ template: 'name' }) (network call, cache it). Webhooks: verify svix-signature header against raw body bytes before processing.
Session token verification in MCP tool handlers
Clerk session tokens are short-lived RS256 JWTs. The client-side Clerk SDK refreshes them automatically before they expire, but your MCP server receives a token per request and must verify it each time. Never cache a decoded payload across requests — always verify the signature and check the expiry claim.
import { verifyToken } from '@clerk/backend';
// Verify a Clerk session token in an MCP tool handler
async function verifyClerkSession(bearerToken: string): Promise<{ userId: string; sessionId: string }> {
// Strip 'Bearer ' prefix if present
const token = bearerToken.startsWith('Bearer ') ? bearerToken.slice(7) : bearerToken;
// verifyToken performs: signature verification, expiry check, issuer check
// It fetches Clerk's JWKS on first call and caches the public keys internally
const payload = await verifyToken(token, {
secretKey: process.env.CLERK_SECRET_KEY!,
// Optional: restrict to your frontend API issuer
// authorizedParties: ['https://your-frontend.com'],
});
return {
userId: payload.sub, // Clerk user ID (user_...)
sessionId: payload.sid, // Clerk session ID (sess_...)
};
}
// MCP tool handler example
export async function handleGetUserData(params: { token: string; resourceId: string }) {
const { userId } = await verifyClerkSession(params.token);
// proceed with userId — token is valid and not expired
return fetchUserResource(userId, params.resourceId);
}
The verifyToken helper from @clerk/backend fetches Clerk's JWKS endpoint at startup and caches the public keys with a TTL matching Clerk's Cache-Control response header. If Clerk rotates signing keys (which can happen when you regenerate keys in the dashboard), the cache must expire before the new key is used — this typically means a brief window where tokens signed with the new key fail. Mitigate by not rotating keys in production without a planned rollout, and handle JWTExpired and JWKSNoMatchingKey errors distinctly.
JWT templates — network latency and caching
JWT templates allow you to issue tokens with custom claims — useful for passing Clerk identity to third-party services (Supabase, Hasura, custom APIs). The critical distinction: auth().getToken() extracts the existing session token from the request (no network call), while auth().getToken({ template: 'my-template' }) calls Clerk's API to issue a new signed token every time.
import { auth } from '@clerk/nextjs/server';
// Fast path: extract the existing session token (no network call)
async function getSessionToken(): Promise<string | null> {
return auth().getToken(); // reads from request context, validates locally
}
// Slow path: fetch a JWT template token (network call to Clerk API ~50-300ms)
async function getTemplateToken(templateName: string): Promise<string | null> {
return auth().getToken({ template: templateName });
}
// Pattern: cache JWT template tokens for their remaining lifetime
// JWT templates typically issue tokens with 60-second TTL by default
// Cache key: userId + templateName
const templateTokenCache = new Map<string, { token: string; expiresAt: number }>();
async function getCachedTemplateToken(
userId: string,
templateName: string
): Promise<string | null> {
const cacheKey = `${userId}:${templateName}`;
const cached = templateTokenCache.get(cacheKey);
// Refresh when less than 10 seconds remain (avoid using an about-to-expire token)
if (cached && Date.now() < cached.expiresAt - 10_000) {
return cached.token;
}
const token = await auth().getToken({ template: templateName });
if (!token) return null;
// Decode the JWT to read the actual expiry (without verifying — it came from Clerk)
const payload = JSON.parse(atob(token.split('.')[1]));
templateTokenCache.set(cacheKey, {
token,
expiresAt: payload.exp * 1000,
});
return token;
}
JWT template tokens have a separate JWKS validation path from session tokens. If your downstream service (e.g., Supabase) verifies the JWT template token using Clerk's JWKS, it uses the same public keys as session token verification — but the iss (issuer) and aud (audience) claims differ. Ensure your downstream service's JWT verification config accepts the template's issuer URL, which is your Clerk Frontend API URL (e.g., https://clerk.your-domain.com or https://<key-prefix>.clerk.accounts.dev).
Minimising currentUser() calls in hot paths
Every call to currentUser() in @clerk/nextjs/server sends an HTTP request to Clerk's API to fetch the current user's full record. In agent workflows where an MCP server handles tens of tool invocations per minute, this adds significant latency and consumes Clerk API rate limit budget.
import { auth, currentUser } from '@clerk/nextjs/server';
import type { User } from '@clerk/nextjs/server';
// WRONG: calls Clerk API on every MCP tool invocation
async function handleToolBad(params: Record<string, unknown>) {
const user = await currentUser(); // <-- network call every time
if (!user) throw new Error('Unauthenticated');
return processForUser(user.id, params);
}
// RIGHT: use auth() for identity (no network call), fetch user record only when needed
async function handleToolGood(params: Record<string, unknown>) {
const { userId } = auth();
if (!userId) throw new Error('Unauthenticated');
return processForUser(userId, params);
}
// RIGHT: fetch full user record once at request boundary, pass down
async function handleRequestWithUser(
token: string,
tools: Array<(user: User, params: Record<string, unknown>) => Promise<unknown>>
) {
// Single currentUser() call per request, not per tool
const user = await currentUser();
if (!user) throw new Error('Unauthenticated');
const results = [];
for (const tool of tools) {
results.push(await tool(user, {}));
}
return results;
}
// auth() fields available without network calls:
// auth().userId — Clerk user ID
// auth().sessionId — Clerk session ID
// auth().orgId — active organisation ID (if Organisations feature used)
// auth().orgRole — active organisation role
// auth().sessionClaims — decoded JWT claims (including custom session claims)
If you need user metadata (e.g., publicMetadata, privateMetadata, email addresses) on every tool call, consider caching the user record in a short-lived per-request store (e.g., a Map scoped to the HTTP request lifecycle) rather than calling currentUser() per tool invocation.
JWKS endpoint and key caching
Clerk's public keys for session token verification are served at the JWKS endpoint. The exact URL depends on your Clerk Frontend API hostname, which is environment-specific — never hardcode a production key in development code or vice versa.
// Clerk's JWKS URL structure
// Development instance: https://<publishable-key-prefix>.clerk.accounts.dev/.well-known/jwks.json
// Production instance: https://<your-custom-domain>/.well-known/jwks.json
// Or via Clerk's API: https://api.clerk.dev/v1/jwks (requires authentication)
// The CLERK_PUBLISHABLE_KEY encodes your frontend API URL
// pk_live_... → production; pk_test_... → development
import { createClerkClient } from '@clerk/backend';
// Using the official client (handles JWKS caching internally)
const clerkClient = createClerkClient({
secretKey: process.env.CLERK_SECRET_KEY!,
publishableKey: process.env.CLERK_PUBLISHABLE_KEY!,
});
// Verify a token using the Clerk client (recommended)
async function verifyWithClerkClient(token: string) {
try {
const tokenPayload = await clerkClient.verifyToken(token);
return { valid: true, userId: tokenPayload.sub };
} catch (err) {
// Common error codes:
// TokenVerificationError: token-invalid — malformed JWT
// TokenVerificationError: token-expired — iat/exp check failed
// TokenVerificationError: jwks-no-matching-key — key rotated, cache miss
// TokenVerificationError: token-invalid-authorized-party — aud mismatch
return { valid: false, error: String(err) };
}
}
// If verifying outside of @clerk/backend (e.g., edge functions without Node.js APIs):
// Fetch JWKS manually and use a JOSE/jsonwebtoken library
// Cache the JWK set according to Cache-Control: max-age from the response header
// Clerk rotates keys when you regenerate them in the dashboard — treat key rotation
// as an operational event, not something that happens automatically on a schedule
In multi-environment setups (development, staging, production), each environment has a separate Clerk instance with separate keys. Ensure CLERK_PUBLISHABLE_KEY and CLERK_SECRET_KEY are set per environment. Mixing keys across environments produces jwks-no-matching-key errors that can be difficult to diagnose.
Webhook verification with Svix
Clerk sends webhooks using the Svix platform. Every webhook delivery includes three headers: svix-id (unique delivery ID), svix-timestamp (Unix timestamp), and svix-signature (one or more HMAC-SHA256 signatures). Verification requires the raw request body as bytes — any middleware that parses or re-serializes the body before the verification step will invalidate the signature.
import { Webhook } from 'svix';
// Webhook endpoint in your MCP server or API route
// IMPORTANT: must receive raw body bytes, not parsed JSON
async function handleClerkWebhook(
rawBody: string | Buffer,
headers: {
'svix-id': string;
'svix-timestamp': string;
'svix-signature': string;
}
): Promise<{ type: string; data: unknown }> {
const webhookSecret = process.env.CLERK_WEBHOOK_SECRET!;
// webhookSecret format: whsec_... (from Clerk dashboard → Webhooks)
const wh = new Webhook(webhookSecret);
let event: { type: string; data: unknown };
try {
// verify() checks: HMAC-SHA256 signature, timestamp within tolerance (5 min),
// and svix-id uniqueness (Svix tracks IDs to prevent replay attacks)
event = wh.verify(rawBody, {
'svix-id': headers['svix-id'],
'svix-timestamp': headers['svix-timestamp'],
'svix-signature': headers['svix-signature'],
}) as { type: string; data: unknown };
} catch (err) {
throw new Error(`Webhook verification failed: ${err}`);
}
return event;
}
// Next.js App Router example — must disable body parsing for this route
// app/api/webhooks/clerk/route.ts
export async function POST(req: Request) {
// Read raw body as text (do NOT use req.json() before this)
const rawBody = await req.text();
const svixId = req.headers.get('svix-id') ?? '';
const svixTimestamp = req.headers.get('svix-timestamp') ?? '';
const svixSignature = req.headers.get('svix-signature') ?? '';
const event = await handleClerkWebhook(rawBody, {
'svix-id': svixId,
'svix-timestamp': svixTimestamp,
'svix-signature': svixSignature,
});
switch (event.type) {
case 'user.created':
await onUserCreated(event.data as ClerkUserPayload);
break;
case 'user.updated':
await onUserUpdated(event.data as ClerkUserPayload);
break;
case 'session.created':
await onSessionCreated(event.data as ClerkSessionPayload);
break;
// Handle other event types: user.deleted, organization.created, etc.
}
return new Response('OK', { status: 200 });
}
// Express example — must use raw body middleware for this route ONLY
// app.post('/webhooks/clerk', express.raw({ type: 'application/json' }), async (req, res) => {
// const rawBody = req.body; // Buffer when using express.raw()
// ...
// });
The svix-timestamp tolerance window is 5 minutes — webhook deliveries with a timestamp more than 5 minutes old are rejected. This prevents replay attacks. In development, if your system clock is skewed, verification will fail with a timestamp error rather than a signature error, which can be confusing. Ensure NTP synchronisation on your server.
Backend API authentication for server-to-server calls
MCP servers that need to read or write user data server-side (e.g., update user metadata when a tool completes) use Clerk's Backend API with the secret key. The secret key is a Bearer token for all Clerk Backend API endpoints — it does not expire, but should be rotated if leaked.
import { createClerkClient } from '@clerk/backend';
const clerk = createClerkClient({
secretKey: process.env.CLERK_SECRET_KEY!,
});
// Update user's public metadata after a tool action
async function updateUserMetadataAfterTool(
userId: string,
toolName: string,
result: unknown
): Promise<void> {
await clerk.users.updateUserMetadata(userId, {
publicMetadata: {
lastToolRun: {
tool: toolName,
completedAt: new Date().toISOString(),
},
},
});
}
// Read user's private metadata (server-side only — never expose to client)
async function getUserPrivateMetadata(userId: string): Promise<Record<string, unknown>> {
const user = await clerk.users.getUser(userId);
return user.privateMetadata as Record<string, unknown>;
}
// List active sessions for a user (e.g., to enforce single-session policy)
async function getActiveSessions(userId: string) {
const sessions = await clerk.sessions.getSessionList({ userId, status: 'active' });
return sessions.data;
}
// Revoke a session (force logout)
async function revokeSession(sessionId: string): Promise<void> {
await clerk.sessions.revokeSession(sessionId);
}
Clerk's Backend API has rate limits — consult the Clerk dashboard for your plan's limits. For bulk operations (e.g., backfilling metadata for many users), use the list API with pagination rather than fetching users individually. The getUser endpoint does not have a bulk variant; for user lookups by external ID, use getUserList({ externalId: ['...'] }) to batch across users in one request.
Health probe — Clerk reachability from an MCP server
An MCP server that depends on Clerk for authentication should verify connectivity to Clerk's API at startup. A failed Clerk connection means all authenticated tools will reject requests. Include Clerk reachability in your health check so monitors detect authentication outages, not just application outages.
import { createClerkClient } from '@clerk/backend';
const clerk = createClerkClient({ secretKey: process.env.CLERK_SECRET_KEY! });
async function checkClerkHealth(): Promise<{ healthy: boolean; latencyMs?: number; error?: string }> {
const start = Date.now();
try {
// GET /v1/clients is a lightweight probe that validates secret key auth
// and Clerk API reachability without side effects
// Alternative: clerk.jwks.getJWKS() — fetches public keys, no auth required
await clerk.jwks.getJWKS();
return { healthy: true, latencyMs: Date.now() - start };
} catch (err) {
return { healthy: false, error: String(err) };
}
}
// Startup check
async function startMcpServer() {
const health = await checkClerkHealth();
if (!health.healthy) {
console.error('Clerk unreachable at startup:', health.error);
process.exit(1);
}
console.log(`Clerk reachable (${health.latencyMs}ms). Starting MCP server.`);
// ... initialise MCP server
}
AliveMCP monitors Clerk-gated MCP endpoints by sending probe requests with a test session token and watching for 401 response patterns that indicate the Clerk middleware is failing to verify tokens — a signal that often precedes user-visible authentication breakdowns by several minutes.
Related guides
- MCP Tools for WorkOS — JWKS rotation, AuthKit, M2M API keys, Directory Sync
- MCP Tools for Keycloak — realm config, offline tokens, role claim mappers
- MCP Tools for NextAuth / Auth.js — session vs JWT callbacks, credentials provider
- MCP Tools for Firebase Auth — ID tokens, custom tokens, session cookies
- MCP server Auth0 integration patterns
- MCP server authentication overview