Auth & Identity · 2026-07-24 · Auth arc
MCP Tools for Auth & Identity Providers: Verification Model Spectrum, Token Lifetime Strategies, and Revocation Responsiveness
Five auth systems — Clerk, WorkOS, Keycloak, NextAuth / Auth.js, and Firebase Auth — all solve the same problem (authenticating users and machines accessing your MCP server) while making fundamentally different decisions about how tokens are verified, how long they live, and how quickly a revoked session stops working. These differences determine how an MCP server must be structured — and what breaks silently when those structures are misunderstood. Clerk session tokens expire in 60 seconds by default, and auth().getToken({ template: 'name' }) is a live network call to Clerk's API adding 50–300 ms per invocation — not a local JWT read — while currentUser() makes a separate API request on every call and is unsuitable for MCP tool handlers that run on every agent turn. WorkOS M2M API keys are opaque Bearer strings, not JWTs: they cannot be verified locally with JOSE or jsonwebtoken, and every M2M-authenticated call must round-trip to WorkOS via workos.userManagement.authenticateWithToken() — without a short-TTL cache, this adds significant per-call latency. Keycloak roles are absent from access tokens by default: without an explicit User Realm Role mapper configured in the client's Mappers tab, the realm_access claim is simply omitted, and token.realm_access?.roles.includes('admin') will silently evaluate to false for every request regardless of the user's actual role assignments. NextAuth data added in the jwt callback is not automatically visible in getServerSession(): you must explicitly copy fields from token to session inside the session callback, and database session strategy queries the sessions table on every getServerSession() call — adding 5–20 ms per MCP tool invocation that compounds heavily in agent workflows. Firebase verifyIdToken(token) does not check revocation unless you pass checkRevoked: true, which adds a network round-trip to Firebase per verification — a user whose account is disabled or whose refresh tokens were revoked will continue to pass the default verification until the token's 1-hour expiry. This post covers all three patterns with working code for each system, a composite health probe comparison table, a revocation responsiveness comparison, and a platform selection guide for MCP server auth use cases.
TL;DR
Five auth systems, three patterns. (1) Verification model spectrum: Clerk — verifyToken(token, { secretKey }) verifies RS256 JWT locally using JWKS (fetched once, cached); getToken({ template }) is a live network call; session tokens expire in 60s; WorkOS — JWTs verified locally with jose + JWKS cache (24h TTL, auto-refresh on kid mismatch); M2M API keys are opaque — network call required via workos.userManagement.authenticateWithToken(), cache result for 60s; Keycloak — local JWKS verification (JOSE); roles absent by default — add User Realm Role mapper; JWKS rotation is on-demand (admin action), not scheduled; NextAuth — JWT strategy verifies cookie locally; database strategy queries DB on every session read; copy from jwt callback into session callback explicitly; Firebase Auth — Admin SDK fetches JWKS (6h cache TTL); verifyIdToken(token) skips revocation check by default; pass true as second argument for revocation check at cost of a network call. (2) Token lifetime and refresh strategies: Clerk — 60s session tokens auto-refreshed by client SDK; JWT template tokens separate TTL (also ~60s by default), cache them server-side; Keycloak access tokens default 5 min; offline tokens never expire unless explicitly configured; client credentials tokens cacheable until near expiry; NextAuth JWT maxAge 30 days (cookie); database sessions configurable maxAge; Firebase ID tokens expire 1 hour; session cookies 5 min–14 days; custom tokens are one-time credentials discarded after sign-in. (3) Revocation responsiveness: Clerk — 60s max lag for revoked sessions (short TTL is the control); clerk.sessions.revokeSession(sessionId) for immediate invalidation; WorkOS — M2M key revocation immediate on deletion; JWT revocation bounded by token TTL; Keycloak — delete session via admin API; offline token revocation via /protocol/openid-connect/revoke; password change only revokes offline tokens if Revoke Refresh Token is ON; NextAuth — database strategy: delete session row for immediate revocation; JWT strategy: no built-in revocation, requires a blocklist; Firebase — auth.revokeRefreshTokens(uid) immediate, but only detected by subsequent calls using checkRevoked: true.
Pattern 1: The Trust and Verification Model Spectrum
Authentication in MCP servers is not just "check a token." The fundamental question is where trust is established: local cryptographic verification (fast, no network) vs opaque-token network verification (slow, but revocation-aware) vs a middle path where the SDK manages key caching for you. The five systems in this arc span all three approaches — and mixing them up is the most common cause of authentication failures in MCP deployments.
Clerk: Local JWKS Verification with a Network-Call Carve-Out
Clerk session tokens are RS256 JWTs. Verifying them uses @clerk/backend's verifyToken(), which fetches Clerk's JWKS at startup, caches the public keys, and verifies subsequent tokens locally — zero network call per verification after the first. The critical carve-out: auth().getToken() (session token extraction) is local, but auth().getToken({ template: 'my-template' }) (JWT template token issuance) is a server-to-server call to Clerk's API on every invocation. The two functions have the same signature but completely different performance profiles.
import { verifyToken, createClerkClient } from '@clerk/backend';
// Fast path: verify Clerk session token locally (JWKS cached after first fetch)
async function verifyClerkSession(bearerToken: string): Promise<{ userId: string; sessionId: string }> {
const token = bearerToken.replace(/^Bearer /, '');
// verifyToken: signature check + expiry check + issuer check
// JWKS is fetched once and cached — no network call after startup
const payload = await verifyToken(token, {
secretKey: process.env.CLERK_SECRET_KEY!,
});
return { userId: payload.sub, sessionId: payload.sid };
}
// Slow path: JWT template token issuance (ALWAYS a network call to Clerk API)
// Adds 50–300ms per invocation — cache the result
const templateTokenCache = new Map<string, { token: string; expiresAt: number }>();
async function getCachedTemplateToken(userId: string, templateName: string): Promise<string | null> {
const key = `${userId}:${templateName}`;
const cached = templateTokenCache.get(key);
if (cached && Date.now() < cached.expiresAt - 10_000) {
return cached.token; // serve from cache with 10s safety margin
}
// This is the network call — Clerk issues a new signed token
const token = await auth().getToken({ template: templateName });
if (!token) return null;
const payload = JSON.parse(atob(token.split('.')[1]));
templateTokenCache.set(key, { token, expiresAt: payload.exp * 1000 });
return token;
}
// WRONG: calling currentUser() on every MCP tool invocation
// currentUser() = GET /v1/users/{userId} on every call
async function handleToolBad(params: Record<string, unknown>) {
const user = await currentUser(); // network call on every tool invocation
return processForUser(user!.id, params);
}
// RIGHT: auth() for identity (no network call), currentUser() only when full record is needed
async function handleToolGood(params: Record<string, unknown>) {
const { userId } = auth(); // reads from request context, no network call
if (!userId) throw new Error('Unauthenticated');
return processForUser(userId, params);
}
Clerk rotates signing keys only when you trigger the rotation in the dashboard — this is not a scheduled event. When rotation happens, the new key is added to the JWKS before the old key is retired, giving a brief overlap. The verifyToken() helper handles this via JWKSNoMatchingKey retry logic. The error to handle distinctly is token-expired (client must refresh) vs jwks-no-matching-key (retry after cache flush) vs token-invalid (reject, do not retry).
WorkOS: Dual Verification Paths — JWT Locally, M2M Over the Wire
WorkOS has two distinct credential types with entirely different verification models. Access tokens from AuthKit or OAuth flows are JWTs verified locally using the jose library against WorkOS's JWKS endpoint. M2M API keys are opaque Bearer strings that are not JWTs and cannot be decoded or verified locally — every verification requires a network call to WorkOS's API. This is not a deficiency; it enables immediate revocation of M2M keys, which JWT-based credentials cannot provide.
import { createRemoteJWKSet, jwtVerify } from 'jose';
import WorkOS from '@workos-inc/node';
const workos = new WorkOS(process.env.WORKOS_API_KEY!);
// Path A: JWT access token verification (local after first JWKS fetch)
// WorkOS recommends 24-hour cache TTL — keys rotate periodically
const workosJWKS = createRemoteJWKSet(
new URL('https://api.workos.com/.well-known/jwks'),
{ cacheMaxAge: 24 * 60 * 60 * 1000 } // 24 hours
);
async function verifyWorkOSJWT(accessToken: string) {
const { payload } = await jwtVerify(accessToken, workosJWKS, {
issuer: 'https://api.workos.com',
audience: process.env.WORKOS_CLIENT_ID!,
});
return {
userId: payload.sub!,
orgId: (payload as { org_id?: string }).org_id,
sessionId: (payload as { sid?: string }).sid,
};
}
// Path B: M2M API key verification (ALWAYS a network call to WorkOS)
// Cache results — M2M keys don't expire on a schedule, but revoke immediately on deletion
const m2mCache = new Map<string, { clientId: string; orgId?: string; cachedAt: number }>();
const M2M_CACHE_TTL_MS = 60_000; // 1 minute — short TTL to reflect revocations promptly
async function verifyWorkOSM2MKey(apiKey: string) {
const cached = m2mCache.get(apiKey);
if (cached && Date.now() - cached.cachedAt < M2M_CACHE_TTL_MS) {
return cached;
}
try {
const result = await workos.userManagement.authenticateWithToken({
token: apiKey,
clientId: process.env.WORKOS_CLIENT_ID!,
});
const entry = {
clientId: result.clientId ?? 'unknown',
orgId: result.organizationId ?? undefined,
cachedAt: Date.now(),
};
m2mCache.set(apiKey, entry);
return entry;
} catch {
return null; // invalid or revoked key
}
}
// Router: detect credential type and dispatch to the right verification path
async function verifyWorkOSCredential(credential: string) {
const token = credential.replace(/^Bearer /, '');
// JWTs have three dot-separated Base64 parts
if (token.split('.').length === 3) {
return { type: 'jwt', identity: await verifyWorkOSJWT(token) };
}
// Otherwise treat as M2M opaque key
const m2m = await verifyWorkOSM2MKey(token);
if (!m2m) throw new Error('Invalid M2M API key');
return { type: 'm2m', identity: m2m };
}
WorkOS Directory Sync adds a third identity path: user records arriving via webhook push events (dsync.user.created, dsync.user.updated, dsync.user.deleted). These are not authentication events — they are provisioning events. There is no REST endpoint to poll for "users that changed since timestamp." Your MCP server must consume and persist these webhook events if it needs a current view of enterprise directory state. The directory user ID (data.id) is the correct idempotency key; process events idempotently because bulk syncs can deliver them out of order.
Keycloak: Local JWT Verification with Administrator-Triggered Key Rotation
Keycloak is self-hosted, which means signing key rotation is not a scheduled SaaS event — it happens when an administrator clicks "Rotate" in the admin console or calls the admin API. The createRemoteJWKSet from jose handles this gracefully via kid lookup: if a received token's key ID is absent from the cached key set, it re-fetches the JWKS before failing. The more common Keycloak gotcha is that roles are absent from JWTs by default.
import { createRemoteJWKSet, jwtVerify } from 'jose';
const KEYCLOAK_URL = process.env.KEYCLOAK_URL!;
const REALM = process.env.KEYCLOAK_REALM!;
const KEYCLOAK_ISSUER = `${KEYCLOAK_URL}/realms/${REALM}`;
// JWKS URL: discover from OpenID configuration, never hardcode the path
// The path is stable but the issuer URL is configuration-dependent
const keycloakJWKS = createRemoteJWKSet(
new URL(`${KEYCLOAK_URL}/realms/${REALM}/protocol/openid-connect/certs`),
{
cacheMaxAge: 60 * 60 * 1000, // 1 hour; jose re-fetches on kid mismatch automatically
}
);
interface KeycloakClaims {
sub: string;
preferred_username: string;
email?: string;
realm_access?: { roles: string[] }; // ONLY present if 'User Realm Role' mapper is configured
resource_access?: { [clientId: string]: { roles: string[] } };
}
async function verifyKeycloakToken(token: string): Promise<KeycloakClaims> {
const { payload } = await jwtVerify(token, keycloakJWKS, {
issuer: KEYCLOAK_ISSUER,
});
return payload as unknown as KeycloakClaims;
}
// Roles are absent unless you add the mapper:
// Keycloak Admin → Client → Mappers → Add mapper → "User Realm Role"
// Token Claim Name: realm_access.roles
// Claim JSON Type: String
// Add to access token: ON
function assertRealmRole(claims: KeycloakClaims, role: string): void {
if (!claims.realm_access?.roles?.includes(role)) {
throw new Error(`Insufficient role: '${role}' required. Token roles: ${
JSON.stringify(claims.realm_access?.roles ?? [])
}`);
}
}
// Multi-realm multi-tenant pattern: read issuer from token before verification
async function verifyMultiRealmToken(token: string): Promise<KeycloakClaims> {
const [, payloadB64] = token.split('.');
const { iss } = JSON.parse(atob(payloadB64));
// Validate issuer is one of our known realms before trusting its JWKS
const allowedIssuers = (process.env.KEYCLOAK_ALLOWED_ISSUERS ?? '').split(',');
if (!allowedIssuers.some(i => (iss as string).startsWith(i.trim()))) {
throw new Error(`Untrusted Keycloak issuer: ${iss}`);
}
// Build or reuse a per-issuer JWKS set
if (!jwksByIssuer.has(iss as string)) {
const config = await (await fetch(`${iss}/.well-known/openid-configuration`)).json();
jwksByIssuer.set(iss as string,
createRemoteJWKSet(new URL(config.jwks_uri), { cacheMaxAge: 60 * 60 * 1000 })
);
}
const { payload } = await jwtVerify(token, jwksByIssuer.get(iss as string)!, { issuer: iss as string });
return payload as unknown as KeycloakClaims;
}
const jwksByIssuer = new Map();
NextAuth / Auth.js: Dual Session Storage Strategies with Radically Different Latency Profiles
NextAuth has two session storage strategies: JWT (session encoded in a cookie, verified locally) and database (session stored in a DB table, queried on every request). The choice affects MCP server latency in ways that compound heavily under agent workloads. The other critical model: data added in the jwt callback is stored in the cookie, but it only appears in getServerSession() if you explicitly copy it inside the session callback — the two callbacks are independent, and the session callback receives the current token but only exposes what you return from it.
import NextAuth from 'next-auth';
import GitHub from 'next-auth/providers/github';
// WRONG mental model: "I returned userId from jwt, it should be in session.user.id"
// CORRECT mental model: jwt stores the cookie payload; session EXPOSES what you choose from it
export const { auth, handlers } = NextAuth({
providers: [GitHub],
session: { strategy: 'jwt' }, // zero-latency verification for MCP tool handlers
callbacks: {
// jwt: runs on sign-in (user/account available) and on every subsequent session read
async jwt({ token, user, account }) {
if (user) {
token.userId = user.id; // persist across requests — stored in encrypted cookie
token.role = (user as { role?: string }).role ?? 'member';
}
if (account) {
// Persist provider access token for downstream MCP calls if needed
token.providerAccessToken = account.access_token;
}
return token;
},
// session: runs on every getServerSession() / auth() call
// Receives the current 'token' — you decide what to expose in 'session'
async session({ session, token }) {
// Explicit copy required — nothing from 'token' is in 'session.user' automatically
session.user.id = token.userId as string;
session.user.role = token.role as string;
// Do NOT expose token.providerAccessToken here — session is returned to the client
return session;
},
},
});
// MCP tool handler — JWT strategy: no DB query, pure cookie verification
export const POST = auth(async (req) => {
if (!req.auth?.user?.id) {
return Response.json({ error: 'Unauthenticated' }, { status: 401 });
}
const userId = req.auth.user.id;
const role = req.auth.user.role;
// ... execute tool with userId and role
});
// Database strategy adds 5–20ms DB round-trip on every call:
// SELECT s.*, u.* FROM sessions s JOIN users u ON u.id = s.userId
// WHERE s.sessionToken = $1 AND s.expires > NOW()
// For agent workflows calling 10+ tools per turn, this adds 50–200ms of pure overhead.
// Use JWT strategy unless you specifically need:
// - Immediate revocation (database: delete row; JWT: requires blocklist)
// - More than ~1KB of session data (JWT: limited by cookie size)
// - Session introspection from outside the Next.js app
Firebase Auth: Admin SDK with Google-Managed JWKS and Optional Revocation
Firebase Auth wraps Google's public key infrastructure. The Admin SDK fetches public keys from Google's endpoint and caches them with the TTL in the Cache-Control header (typically 6 hours). Token verification is local after the initial key fetch. The deliberate opt-in design for revocation checking reflects the latency trade-off: default verification is a pure local operation; revocation checking adds a network round-trip to Firebase per call. Multi-tenant Firebase (Identity Platform) adds another layer: tokens from a tenant user must be verified with the tenant-scoped authForTenant(tenantId) instance, not the project-level auth instance.
import { initializeApp, getApps, cert } from 'firebase-admin/app';
import { getAuth } from 'firebase-admin/auth';
// Initialisation guard: essential in Next.js / serverless where module re-evaluates on cold start
if (!getApps().length) {
initializeApp({
credential: cert({
projectId: process.env.FIREBASE_PROJECT_ID!,
clientEmail: process.env.FIREBASE_CLIENT_EMAIL!,
privateKey: process.env.FIREBASE_PRIVATE_KEY!.replace(/\\n/g, '\n'),
// Private key stored in env has literal '\n' — replace with actual newlines
}),
});
}
const firebaseAuth = getAuth();
// Fast path: verify signature only (no network call after initial JWKS fetch)
async function verifyFirebaseIdToken(idToken: string) {
return firebaseAuth.verifyIdToken(idToken);
// decoded.uid, decoded.email, decoded.email_verified
// decoded.firebase.sign_in_provider ('google.com', 'password', 'phone', etc.)
}
// Secure path: verify + check revocation (network call to Firebase per invocation)
// Use for write operations, admin actions, data deletion
async function verifyFirebaseIdTokenSecure(idToken: string) {
try {
return await firebaseAuth.verifyIdToken(idToken, /* checkRevoked */ true);
} catch (err) {
const code = (err as { code?: string }).code;
if (code === 'auth/id-token-revoked') {
throw new Error('Session revoked — re-authentication required');
}
if (code === 'auth/user-disabled') {
throw new Error('Account disabled');
}
throw err;
}
}
// Routing by tool sensitivity: fast for reads, secure for writes
const WRITE_TOOLS = new Set(['deleteData', 'updateProfile', 'transferOwnership']);
async function handleMcpTool(idToken: string, toolName: string, params: Record<string, unknown>) {
const decoded = WRITE_TOOLS.has(toolName)
? await verifyFirebaseIdTokenSecure(idToken)
: await verifyFirebaseIdToken(idToken);
return executeTool(decoded.uid, toolName, params);
}
// Multi-tenant: tenant tokens must use authForTenant, not project-level auth
async function verifyMultiTenantToken(idToken: string) {
// Peek at tenant claim without verifying (to route to the right auth instance)
const [, payloadB64] = idToken.split('.');
const { firebase } = JSON.parse(Buffer.from(payloadB64, 'base64').toString());
const tenantId: string | undefined = firebase?.tenant;
const allowedTenants = (process.env.ALLOWED_TENANT_IDS ?? '').split(',');
if (tenantId) {
if (!allowedTenants.includes(tenantId)) throw new Error(`Tenant not allowed: ${tenantId}`);
// Must verify with tenant-scoped instance — project-level auth rejects tenant tokens
return firebaseAuth.tenantManager().authForTenant(tenantId).verifyIdToken(idToken, true);
}
return firebaseAuth.verifyIdToken(idToken, true);
}
Pattern 2: Token Lifetime and Refresh Strategies
The lifetime of a token in your MCP server is not just about security — it directly determines the frequency of key fetches, the latency of each tool call, and the complexity of refresh logic. The five systems represent a spectrum from Clerk's aggressively short 60-second session tokens to Keycloak's offline tokens that never expire by default.
Clerk: 60-Second Session Tokens and the JWT Template Network Gap
Clerk's 60-second session token TTL is a deliberate security design — the client SDK refreshes the session token automatically before it expires, so users experience no interruption. But MCP servers face a subtler version of this: any cached decoded JWT payload older than 60 seconds is invalid. You must call verifyToken() on every inbound MCP request. Do not cache the decoded payload. Do not check expiry by hand; let the library do it.
JWT template tokens add a second TTL and a network cost. They are separately issued tokens with custom claims for passing Clerk identity to third-party services (Supabase RLS, Hasura, custom APIs). Default template TTL is also around 60 seconds, and each issuance is a server-to-server call to Clerk's API. If your MCP tool calls multiple downstream services that each need a JWT template token, those calls stack. Cache template tokens in an in-process Map keyed by userId:templateName, refreshing when less than 10 seconds remain.
WorkOS: JWT TTL Bounded by Standard OAuth, M2M Keys Unbounded Until Deletion
WorkOS JWT access tokens have a standard OAuth TTL (configurable, typically 15–60 minutes). Refresh tokens extend the session — use workos.userManagement.authenticateWithRefreshToken(). The key asymmetry is M2M: WorkOS M2M API keys do not expire on a TTL schedule. They remain valid indefinitely until explicitly deleted or revoked in the WorkOS dashboard. This makes them appropriate for long-running agent processes where token refresh would add operational complexity — but it also means that if an M2M key is leaked, it remains valid until you notice and delete it. No TTL-based expiry to limit the damage window.
import WorkOS from '@workos-inc/node';
const workos = new WorkOS(process.env.WORKOS_API_KEY!);
// Refresh an expired JWT access token
async function refreshWorkOSSession(refreshToken: string): Promise<{ accessToken: string; refreshToken: string }> {
const result = await workos.userManagement.authenticateWithRefreshToken({
refreshToken,
clientId: process.env.WORKOS_CLIENT_ID!,
});
return {
accessToken: result.accessToken,
refreshToken: result.refreshToken, // new refresh token (rotation)
};
}
// Client credentials (machine-to-machine) — no refresh needed because no expiry
// But cache to reduce per-call latency from the verification network call
// Short TTL handles the revocation window: if a key is deleted, the cache
// will serve stale results for at most M2M_CACHE_TTL_MS before detecting deletion
// Pattern: detect WorkOS access token expiry in an MCP middleware layer
async function withWorkOSAuth<T>(
req: Request,
handler: (claims: WorkOSClaims) => Promise<T>
): Promise<T> {
const authorization = req.headers.get('Authorization') ?? '';
const token = authorization.replace(/^Bearer /, '');
try {
const claims = await verifyWorkOSJWT(token);
return handler(claims);
} catch (err) {
if (String(err).includes('JWTExpired')) {
// Signal to client to refresh — do not silently retry with a stale token
throw Object.assign(new Error('Access token expired'), { code: 'TOKEN_EXPIRED' });
}
throw err;
}
}
interface WorkOSClaims {
sub: string;
org_id?: string;
role?: string;
sid: string;
}
Keycloak: Five-Minute Access Tokens, Infinite Offline Tokens
Keycloak access tokens default to 5 minutes — short enough for security, but long enough that MCP tool handlers don't need to worry about refreshing between tool calls within a single agent turn. The dangerous edge case is offline tokens. Offline tokens are persistent refresh tokens issued when you include the offline_access scope. Unlike regular session-scoped refresh tokens, offline tokens survive Keycloak server restarts, SSO session expirations, and the realm's session max settings. They never expire by default. They are meant for background services that need to act on behalf of a user long after the user's browser session closed.
// Offline token: issued once, used forever (unless revoked or configured with expiry)
// Request with 'offline_access' scope:
// scope: 'openid offline_access'
// Use the offline token as a persistent refresh token to get fresh access tokens
async function refreshWithOfflineToken(
offlineToken: string
): Promise<{ accessToken: string; newOfflineToken: string }> {
const response = await fetch(
`${KEYCLOAK_URL}/realms/${REALM}/protocol/openid-connect/token`,
{
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'refresh_token',
client_id: process.env.KEYCLOAK_CLIENT_ID!,
refresh_token: offlineToken,
}),
}
);
if (!response.ok) {
const error = await response.json();
// 'invalid_grant': offline token revoked or user account deleted
throw new Error(`Offline token refresh failed: ${error.error}`);
}
const tokens = await response.json();
return {
accessToken: tokens.access_token,
newOfflineToken: tokens.refresh_token, // Keycloak rotates offline tokens on use
// CRITICAL: persist the NEW offline token — the old one is now invalid
};
}
// Client credentials (M2M service account) token caching
// Keycloak access tokens default to 5 minutes — cache until 30 seconds before expiry
let cachedServiceToken: { token: string; expiresAt: number } | null = null;
async function getServiceToken(): Promise<string> {
if (cachedServiceToken && Date.now() < cachedServiceToken.expiresAt - 30_000) {
return cachedServiceToken.token;
}
const response = await fetch(
`${KEYCLOAK_URL}/realms/${REALM}/protocol/openid-connect/token`,
{
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'client_credentials',
client_id: process.env.KEYCLOAK_CLIENT_ID!,
client_secret: process.env.KEYCLOAK_CLIENT_SECRET!,
}),
}
);
const tokens = await response.json();
const payload = JSON.parse(atob(tokens.access_token.split('.')[1]));
cachedServiceToken = { token: tokens.access_token, expiresAt: payload.exp * 1000 };
return tokens.access_token;
}
const KEYCLOAK_URL = process.env.KEYCLOAK_URL!;
const REALM = process.env.KEYCLOAK_REALM!;
Firebase Auth: ID Tokens, Session Cookies, and the One-Time Custom Token
Firebase presents three distinct token shapes with different lifetime semantics. ID tokens expire after 1 hour — fine for browser sessions, but problematic for long-running MCP agent sessions where a user starts a task and the agent continues working for hours. Session cookies solve this: issued from an ID token by the Admin SDK, they can last 5 minutes to 14 days. Custom tokens are neither bearer tokens nor sessions — they are one-time credentials minted server-side, passed to the client, and exchanged via signInWithCustomToken() for an ID token. The custom token is discarded after the exchange; the resulting ID token is what the client sends to your MCP server.
// Three Firebase token types for MCP server contexts:
// TYPE 1: ID Token — 1 hour expiry, verified locally (fast)
// Client sends this from firebase.auth().currentUser?.getIdToken()
// Your MCP server verifies with Admin SDK
// TYPE 2: Session Cookie — 5 min to 14 days, issued by your server from a valid ID token
// Extends ID token window for long-running agent sessions
// Revocable server-side via revokeRefreshTokens(uid)
async function upgradeToSessionCookie(idToken: string): Promise<string> {
const expiresIn = 7 * 24 * 60 * 60 * 1000; // 7 days
// createSessionCookie verifies the ID token internally — no need to verify separately
return firebaseAuth.createSessionCookie(idToken, { expiresIn });
// Set the returned string as an HttpOnly, Secure, SameSite=Strict cookie
}
// TYPE 3: Custom Token — one-time sign-in credential, 1 hour validity
// Server mints it, client signs in once, discards the custom token, uses the resulting ID token
async function issueCustomToken(uid: string, claims: Record<string, unknown>): Promise<string> {
return firebaseAuth.createCustomToken(uid, claims);
// Client: const cred = await signInWithCustomToken(clientAuth, customToken);
// Then: const idToken = await cred.user.getIdToken();
// The customToken itself is NOT a bearer token — do NOT accept it directly on your MCP server
}
// Session cookie verification for long-lived MCP agent sessions
async function verifySessionCookie(cookie: string, checkRevoked = false) {
return firebaseAuth.verifySessionCookie(cookie, checkRevoked);
// checkRevoked: false — fast local verification
// checkRevoked: true — network call to Firebase, detects revokeRefreshTokens(uid)
}
NextAuth: Cookie Session vs Database Session Latency at Scale
NextAuth's two session strategies have latency profiles that diverge dramatically under agent workloads. JWT strategy: session verified by decrypting and validating a cookie — pure CPU work, zero network. Database strategy: session verified by querying the sessions table — adds a database round-trip on every getServerSession() call. At 10 tool calls per agent turn, JWT adds 0ms overhead; database strategy adds 50–200ms depending on your DB's round-trip latency. The break-even point where database strategy's revocation capability justifies its overhead depends on your threat model — for most MCP servers, JWT strategy is the correct default.
An underappreciated NextAuth behaviour: the session callback fires on every getServerSession() call, even if the session data hasn't changed. This means expensive operations inside the session callback (fetching user metadata from an external service, decrypting fields) run on every MCP tool invocation. Keep the session callback pure — only copy fields from token to session, do not add async operations.
Pattern 3: Revocation Responsiveness
When a user's session should end — logout, account suspension, security incident, employee offboarding — how quickly does that termination take effect across all MCP tools? The answer varies by over three orders of magnitude across these five systems, from immediate (database session deletion) to up to 14 days (Firebase session cookies without revocation checks). Understanding this spectrum lets you design the right revocation-response time for your MCP server's risk profile.
Clerk: Short TTL as Revocation by Design
Clerk's 60-second session token TTL bounds the maximum window during which a revoked session appears valid. If you call clerk.sessions.revokeSession(sessionId), the session is immediately invalidated in Clerk's system — any subsequent call to verifyToken() with the old session token will succeed only until that token's exp claim passes (at most 60 seconds from issuance). The short TTL is the primary revocation control, not an explicit blocklist check.
import { createClerkClient } from '@clerk/backend';
const clerk = createClerkClient({ secretKey: process.env.CLERK_SECRET_KEY! });
// Revoke a specific session (forces re-authentication on next tool call)
// Effective: the next verifyToken() call after the current token expires (≤60s) will fail
async function revokeClerkSession(sessionId: string): Promise<void> {
await clerk.sessions.revokeSession(sessionId);
}
// Revoke all active sessions for a user (e.g., on account compromise)
async function revokeAllClerkSessions(userId: string): Promise<void> {
const sessions = await clerk.sessions.getSessionList({ userId, status: 'active' });
await Promise.all(sessions.data.map(s => clerk.sessions.revokeSession(s.id)));
}
// In the MCP tool handler: extract the session ID from the verified token
// to log or revoke the specific session on detected anomalies
async function handleSensitiveToolWithAudit(bearerToken: string, params: unknown) {
const { userId, sessionId } = await verifyClerkSession(bearerToken);
// Log: { userId, sessionId, tool: 'sensitiveAction', params, timestamp }
return performAction(userId, params);
}
function verifyClerkSession(bearerToken: string) {
return verifyToken(bearerToken.replace(/^Bearer /, ''), {
secretKey: process.env.CLERK_SECRET_KEY!,
}).then(p => ({ userId: p.sub, sessionId: p.sid }));
}
import { verifyToken } from '@clerk/backend';
WorkOS: Immediate M2M Revocation, TTL-Bounded JWT Revocation
WorkOS has two very different revocation stories depending on credential type. M2M API keys: deleting a key in the WorkOS dashboard immediately invalidates it. The next call to workos.userManagement.authenticateWithToken() returns a failure. Your 60-second cache means there's at most a 60-second window before your MCP server notices — acceptable for most use cases, adjustable by shortening the TTL. JWT access tokens: revocation is bounded by the token's TTL. WorkOS does not expose a token blocklist or session revocation endpoint for JWT-based sessions. When a user logs out via AuthKit, the WorkOS session ends but the JWT access token remains valid until expiry. Design accordingly — use shorter JWT TTLs or implement an in-process blocklist keyed by the JWT's sid claim if you need faster JWT revocation.
Keycloak: Multiple Revocation Surfaces
Keycloak is the most configurable system in this arc for revocation. Regular session revocation works via the admin API or the end-session endpoint. But offline tokens require separate handling — deleting the SSO session does not revoke offline tokens unless Revoke Offline Sessions on Password Change is enabled in realm settings (which it is not by default). Offline tokens must be revoked explicitly via the revoke endpoint.
// Keycloak revocation surfaces:
// 1. End the SSO session (covers regular sessions, not offline tokens by default)
async function revokeKeycloakSession(accessToken: string): Promise<void> {
await fetch(`${KEYCLOAK_URL}/realms/${REALM}/protocol/openid-connect/logout`, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
Authorization: `Bearer ${accessToken}`,
},
body: new URLSearchParams({
client_id: process.env.KEYCLOAK_CLIENT_ID!,
client_secret: process.env.KEYCLOAK_CLIENT_SECRET!,
}),
});
}
// 2. Revoke a specific offline token (refresh token revocation endpoint)
async function revokeOfflineToken(offlineToken: string): Promise<void> {
await fetch(`${KEYCLOAK_URL}/realms/${REALM}/protocol/openid-connect/revoke`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
client_id: process.env.KEYCLOAK_CLIENT_ID!,
client_secret: process.env.KEYCLOAK_CLIENT_SECRET!,
token: offlineToken,
token_type_hint: 'refresh_token',
}),
});
}
// 3. Force-expire all sessions for a user via admin API
// Requires admin credentials and the user's Keycloak ID
async function revokeAllUserSessions(keycloakUserId: string, adminToken: string): Promise<void> {
await fetch(
`${KEYCLOAK_URL}/admin/realms/${REALM}/users/${keycloakUserId}/logout`,
{
method: 'POST',
headers: { Authorization: `Bearer ${adminToken}` },
}
);
// Note: this ends SSO sessions but does NOT automatically revoke offline tokens
// Offline token revocation requires the revoke endpoint above
}
const KEYCLOAK_URL = process.env.KEYCLOAK_URL!;
const REALM = process.env.KEYCLOAK_REALM!;
NextAuth: Database Instant, JWT Blocklist Required
NextAuth database strategy gives you immediate revocation for free: delete the session row from the sessions table and the next getServerSession() call finds nothing and returns null — user is effectively logged out immediately. JWT strategy has no built-in revocation mechanism. The JWT is a self-contained credential; once issued, it's valid until expiry. To revoke JWT sessions, you need to implement a blocklist — a set or Redis key storing revoked session IDs (token.jti or a custom field), checked in the jwt callback on every session read. This adds latency equivalent to a DB round-trip, erasing the JWT strategy's primary advantage.
// NextAuth database strategy: instant revocation
// Delete the session row, and the next getServerSession() returns null
async function revokeNextAuthDatabaseSession(sessionToken: string, prisma: PrismaClient): Promise<void> {
await prisma.session.delete({ where: { sessionToken } });
// Effective immediately — next getServerSession() returns null
}
// NextAuth JWT strategy: blocklist-based revocation
// Implement in the jwt callback so it runs on every session read
const revokedJTIs = new Set<string>(); // replace with Redis SET in production
export const { auth } = NextAuth({
session: { strategy: 'jwt' },
callbacks: {
async jwt({ token }) {
// Check blocklist on every session read — adds latency (Redis lookup ~1ms)
const jti = token.jti ?? (token.userId as string);
if (revokedJTIs.has(jti)) {
// Return a minimal token that fails authentication downstream
// Cannot throw from jwt callback — return invalid-looking token
return { ...token, blocked: true };
}
return token;
},
async session({ session, token }) {
if ((token as { blocked?: boolean }).blocked) {
// Signal unauthenticated by returning empty session
return { ...session, user: undefined as unknown as typeof session.user };
}
session.user.id = token.userId as string;
return session;
},
},
});
// To revoke a JWT session: add its JTI to the blocklist
async function revokeNextAuthJWTSession(jti: string): Promise<void> {
revokedJTIs.add(jti); // In production: await redis.sadd('revoked_jtis', jti);
}
Firebase: checkRevoked Flag and revokeRefreshTokens
Firebase's revocation model has two halves. On the server: auth.revokeRefreshTokens(uid) sets a revocation timestamp on the user record. On verification: without checkRevoked: true, the revocation timestamp is never consulted — the token appears valid until expiry. With checkRevoked: true, Firebase's API is called to compare the token's iat against the user's revocation timestamp — if the token was issued before the revocation, it fails. This design gives you a choice: pay the network cost for immediate revocation, or accept up to 1 hour of stale validity for zero-latency verification.
// Firebase revocation: immediate on the server, only visible when checkRevoked: true
async function revokeFirebaseUser(uid: string): Promise<void> {
// Sets validSince on the user record to now
// Tokens issued before validSince are rejected when checkRevoked: true
await firebaseAuth.revokeRefreshTokens(uid);
// Optional: also disable the account (prevents new sign-ins entirely)
await firebaseAuth.updateUser(uid, { disabled: true });
}
// Session cookie revocation (same call — revokeRefreshTokens covers session cookies too)
// After revokeRefreshTokens(uid), verifySessionCookie(cookie, true) throws
// auth/session-cookie-revoked for all cookies issued before revocation
// Revocation responsiveness summary for Firebase:
// verifyIdToken(token) → revocation invisible until 1-hour expiry
// verifyIdToken(token, true) → revocation visible immediately (network call)
// verifySessionCookie(c) → revocation invisible until cookie expiry (up to 14 days)
// verifySessionCookie(c, true)→ revocation visible immediately (network call)
// Monitoring recommendation: use checkRevoked for write tools (delete, update, transfer)
// and fast verification for read tools to balance security and latency
Composite Health Probe Comparison
Every MCP server that depends on an auth provider should verify connectivity to that provider at startup. Auth provider outages manifest as 401 spikes rather than application errors — from the application's perspective, every user appears unauthenticated. AliveMCP monitors auth-provider-integrated MCP endpoints by watching for 401 rate increases that don't correlate with traffic changes.
| System | Minimal health probe | What it misses | Full probe |
|---|---|---|---|
| Clerk | clerk.jwks.getJWKS() — fetches public keys, no auth required |
Backend API auth issues (invalid secret key), rate limiting | JWKS fetch + clerk.users.getUserList({ limit: 1 }) to validate secret key |
| WorkOS | GET https://api.workos.com/.well-known/jwks |
API key validity, management API availability | JWKS fetch + workos.organizations.listOrganizations({ limit: 1 }) to validate API key |
| Keycloak | GET {url}/health/ready |
Realm-specific failures (key provider errors, user federation sync issues) | Cluster health + GET {url}/realms/{realm}/.well-known/openid-configuration for realm availability |
| NextAuth | GET {base}/api/auth/session returns 200 (even for unauthenticated) |
Database connectivity (for database strategy), misconfigured NEXTAUTH_SECRET | Session endpoint + SELECT 1 on the sessions database adapter |
| Firebase Auth | GET https://www.googleapis.com/service_accounts/v1/jwk/securetoken@system.gserviceaccount.com |
Admin SDK service account credential validity, Firebase project-specific issues | JWKS endpoint + auth.listUsers(1) to validate service account credentials |
// Unified health probe for auth provider connectivity
async function checkAuthProviderHealth(provider: 'clerk' | 'workos' | 'keycloak' | 'nextauth' | 'firebase') {
const start = Date.now();
try {
switch (provider) {
case 'clerk':
await createClerkClient({ secretKey: process.env.CLERK_SECRET_KEY! }).jwks.getJWKS();
break;
case 'workos':
await fetch('https://api.workos.com/.well-known/jwks').then(r => { if (!r.ok) throw new Error(`HTTP ${r.status}`); });
break;
case 'keycloak':
await fetch(`${process.env.KEYCLOAK_URL}/realms/${process.env.KEYCLOAK_REALM}/.well-known/openid-configuration`)
.then(r => { if (!r.ok) throw new Error(`HTTP ${r.status}`); });
break;
case 'nextauth':
await fetch(`${process.env.NEXTAUTH_URL}/api/auth/session`)
.then(r => { if (!r.ok) throw new Error(`HTTP ${r.status}`); });
break;
case 'firebase':
await fetch('https://www.googleapis.com/service_accounts/v1/jwk/securetoken@system.gserviceaccount.com')
.then(r => { if (!r.ok) throw new Error(`HTTP ${r.status}`); });
break;
}
return { healthy: true, latencyMs: Date.now() - start };
} catch (err) {
return { healthy: false, latencyMs: Date.now() - start, error: String(err) };
}
}
import { createClerkClient } from '@clerk/backend';
Failure Modes Cross-Reference
| System | Silent failure mode | Root cause | Fix |
|---|---|---|---|
| Clerk | Template tokens add 50–300ms per tool call, agent turn feels slow with no error | getToken({ template }) called per tool invocation, not cached |
Cache template tokens in Map keyed by userId:templateName, refresh at <10s remaining |
| Clerk | currentUser() exhausts rate limit budget silently | currentUser() called on every MCP tool invocation (GET /v1/users/{id} per call) |
Use auth().userId for identity; call currentUser() once per request boundary only when full user record needed |
| WorkOS | Redirect URI mismatch produces cryptic error in production but not staging | Trailing slash difference between registered URI and runtime URI; WorkOS requires exact match | Register exact URIs per environment; check WORKOS_REDIRECT_URI env var per deployment |
| WorkOS | Directory Sync users appear stale after enterprise IdP update | Directory Sync is push-only — no poll endpoint; webhook endpoint was unreachable during sync | Implement idempotent webhook handlers; use workos.directorySync.listUsers() to backfill after webhook endpoint recovery |
| Keycloak | Role checks always return false even for admin users | Realm roles absent from JWT — no User Realm Role mapper configured | Client → Mappers → Add mapper → User Realm Role, Token Claim Name: realm_access.roles, Add to access token: ON |
| Keycloak | Leaked offline token grants permanent access after user account disabled | Offline tokens don't expire by default; disabling account doesn't revoke offline tokens | Enable Revoke Refresh Token in realm settings; call /protocol/openid-connect/revoke explicitly on account deprovisioning |
| NextAuth | Custom session field undefined in getServerSession() despite being set in jwt callback |
Field added to token in jwt callback but not copied to session in session callback |
Add explicit copy in session callback: session.user.customField = token.customField |
| NextAuth | Database strategy adds 50–200ms to every MCP tool call with no obvious cause | Every getServerSession() queries the sessions table; MCP tools call it on every invocation |
Switch to JWT strategy unless revocation is required; or call getServerSession() once per request, pass session down to all tool handlers |
| Firebase | Revoked user continues to authenticate for up to 1 hour | verifyIdToken(token) skips revocation check; revokeRefreshTokens(uid) was called but checkRevoked not passed |
Pass true as second argument to verifyIdToken(idToken, true) for write tools; route by tool sensitivity |
| Firebase | FirebaseAppError: The default Firebase app already exists on every cold start |
initializeApp() called unconditionally in a module that evaluates on every serverless invocation |
Guard with if (!getApps().length) initializeApp(...) |
Platform Selection Guide for MCP Server Auth
| If your MCP server needs… | Best choice | Why |
|---|---|---|
| Rapid developer setup with Next.js, hosted UI, no server auth complexity | Clerk | Best-in-class DX; session management, webhooks, and JWKS all handled; Auth.js integration; short 60s tokens limit revocation window |
| Enterprise SSO (SAML, OIDC) + enterprise Directory Sync (Okta, Azure AD, Google Workspace) | WorkOS | AuthKit handles enterprise IdP complexity; M2M keys for service-to-service; Directory Sync covers enterprise provisioning via webhook model |
| Self-hosted auth with full control over token configuration, multi-tenant realms, offline agents | Keycloak | Open source, self-hosted; offline tokens for long-running agents; multi-realm multi-tenant isolation; client credentials for M2M; PKCE for public clients |
| Next.js app with existing OAuth providers (GitHub, Google) and minimal auth infrastructure | NextAuth / Auth.js | Zero-infrastructure JWT sessions; supports 50+ OAuth providers; database adapter for revocable sessions when needed |
| Firebase-native project (Firestore, Firebase Storage, Cloud Functions) already in use | Firebase Auth | Tight Firestore security rules integration via ID token claims; session cookies extend 1-hour ID token window; custom tokens for cross-system identity bridging |
| Maximum revocation responsiveness (sub-second session termination) | NextAuth (database strategy) | Database session deletion = immediate revocation; no TTL window; trade-off is DB query per call |
| Long-running background agent acting on behalf of a user without user interaction | Keycloak (offline tokens) | Offline tokens survive SSO session expiry and Keycloak restarts; designed for exactly this use case; configure max lifetime in realm settings |
| Machine-to-machine authentication with no user context | WorkOS (M2M keys) or Keycloak (client credentials) | WorkOS M2M keys are simpler (no expiry management); Keycloak client credentials are JWT-based (locally verifiable, shorter lived) |
Monitoring Auth Providers with AliveMCP
Auth provider availability is the invisible dependency for every authenticated MCP tool call. When Clerk's API degrades, JWT template token issuance slows before failing — manifesting as agent turn latency increases before any 401s appear. When Keycloak's JWKS endpoint is unreachable, all token verifications fail immediately — manifesting as a sudden 100% 401 rate. When NextAuth's database adapter loses connectivity, all session reads return null — manifesting as every user appearing unauthenticated, with no application errors to indicate the cause.
AliveMCP probes the upstream dependencies of your MCP server — including auth provider JWKS endpoints and Admin SDK connectivity — and alerts before your users encounter authentication failures. The public dashboard tracks latency and availability for common auth provider endpoints across the MCP ecosystem.
The five pages in this arc each include a health probe code example specific to that provider:
Related guides
- MCP Tools for Real-time / WebSocket Systems — Ably, Pusher, Socket.IO, Centrifugo, Liveblocks
- MCP Tools for Cloud Storage — S3, GCS, Azure Blob, DigitalOcean Spaces, Supabase Storage
- MCP server authentication overview
- MCP server OAuth patterns
- MCP server Auth0 integration patterns
- MCP server Okta integration patterns