Guide · Auth & Identity
MCP Tools for WorkOS — JWKS rotation, AuthKit, M2M API keys, Directory Sync webhooks
Three WorkOS behaviours catch developers building MCP enterprise auth integrations: WorkOS rotates JWKS keys and recommends a 24-hour cache TTL — hardcoding a public key string in source or caching JWKs indefinitely will produce JWTVerifyError failures after a key rotation, and you must always fetch from https://api.workos.com/.well-known/jwks with a time-bounded cache; WorkOS M2M API keys are opaque Bearer tokens, not JWTs — they cannot be verified locally with JOSE or jsonwebtoken, and must be authenticated against WorkOS's API via workos.userManagement.authenticateWithToken(), adding a server-to-server round-trip to every M2M-authenticated MCP tool call; and Directory Sync is a push-only webhook model with no REST poll endpoint — your MCP server cannot query "what users changed in the last hour" on demand; it must consume dsync.user.created, dsync.user.updated, and dsync.user.deleted webhooks in real time or process them from a persisted event log.
TL;DR
Verify access tokens: fetch JWKS from https://api.workos.com/.well-known/jwks, cache with 24-hour TTL, verify with jose. AuthKit redirect: workos.userManagement.getAuthorizationUrl({ provider: 'authkit', redirectUri, clientId }). PKCE exchange: workos.userManagement.authenticateWithCode({ code, codeVerifier, clientId }). Directory Sync: consume webhooks at /webhooks/workos, verify WorkOS-Signature header, handle dsync.* event types.
Access token verification with JWKS caching
WorkOS issues access tokens as JWTs signed with RS256. The public keys are served at WorkOS's JWKS endpoint. Keys rotate periodically — the recommended approach is to cache the key set with a TTL of up to 24 hours and re-fetch on cache miss or on JWTVerifyError caused by an unknown key ID.
import { createRemoteJWKSet, jwtVerify, JWTVerifyResult } from 'jose';
import type { JWTPayload } from 'jose';
// WorkOS JWKS endpoint — same for all environments (sandbox and production)
const WORKOS_JWKS_URL = 'https://api.workos.com/.well-known/jwks';
// createRemoteJWKSet caches the key set in-process with automatic refresh
// on key ID (kid) mismatch — built-in handling for WorkOS key rotation
const workosJWKS = createRemoteJWKSet(new URL(WORKOS_JWKS_URL), {
// Refresh cache if a received token's kid is not in the current key set
cacheMaxAge: 24 * 60 * 60 * 1000, // 24 hours in ms
});
interface WorkOSTokenPayload extends JWTPayload {
sid: string; // WorkOS session ID
org_id?: string; // WorkOS organisation ID (present when user belongs to an org)
role?: string; // User's role in the organisation
}
async function verifyWorkOSToken(accessToken: string): Promise<WorkOSTokenPayload> {
let result: JWTVerifyResult;
try {
result = await jwtVerify(accessToken, workosJWKS, {
// issuer matches WorkOS's token issuer for your environment
issuer: 'https://api.workos.com',
// audience should match your application's client ID
audience: process.env.WORKOS_CLIENT_ID!,
});
} catch (err) {
// JWTExpired: token is past its exp claim — client must refresh
// JWTInvalid: malformed token
// JWSSignatureVerificationFailed: signature mismatch (wrong key or tampered)
// JWKSNoMatchingKey: kid not found — may indicate key rotation in progress
throw new Error(`WorkOS token verification failed: ${err}`);
}
return result.payload as WorkOSTokenPayload;
}
// MCP tool handler with WorkOS auth
async function handleMcpTool(bearerToken: string, params: Record<string, unknown>) {
const token = bearerToken.replace(/^Bearer /, '');
const claims = await verifyWorkOSToken(token);
const userId = claims.sub!;
const orgId = claims.org_id; // undefined for personal accounts
return performToolAction(userId, orgId, params);
}
WorkOS's JWKS endpoint returns multiple keys, and the active signing key is identified by the kid field in the JWT header. When WorkOS rotates keys, the new key is added to the JWKS response before the old key is retired, giving a brief overlap window. The createRemoteJWKSet helper from jose handles kid lookups automatically — if a received token's kid is absent from the cached key set, it re-fetches the JWKS before failing, covering the rotation overlap without manual intervention.
AuthKit vs manual OAuth — redirect URI precision
WorkOS offers AuthKit (a hosted authentication UI) and a manual OAuth flow. Both use the same authorization code exchange, but the redirect URI handling differs in a subtle way that produces hard-to-diagnose errors in production.
import WorkOS from '@workos-inc/node';
const workos = new WorkOS(process.env.WORKOS_API_KEY!);
// AuthKit flow — hosted login page
async function initiateAuthKitLogin(state: string): Promise<{ url: string; codeVerifier: string }> {
// PKCE: generate a code verifier and challenge for every authorization request
const crypto = await import('crypto');
const codeVerifier = crypto.randomBytes(32).toString('base64url');
const codeChallenge = crypto
.createHash('sha256')
.update(codeVerifier)
.digest('base64url');
const url = workos.userManagement.getAuthorizationUrl({
provider: 'authkit',
redirectUri: process.env.WORKOS_REDIRECT_URI!,
// IMPORTANT: redirectUri must match EXACTLY what is registered in WorkOS dashboard
// 'https://app.example.com/callback' ≠ 'https://app.example.com/callback/'
// Trailing slash mismatch → "redirect_uri did not match any registered URIs"
clientId: process.env.WORKOS_CLIENT_ID!,
state,
codeChallenge,
codeChallengeMethod: 'S256',
});
return { url, codeVerifier };
// Store codeVerifier in session — needed for the exchange step
}
// Exchange the authorization code for tokens
async function exchangeCode(
code: string,
codeVerifier: string
): Promise<{ accessToken: string; refreshToken: string; user: WorkOSUser }> {
const result = await workos.userManagement.authenticateWithCode({
code,
codeVerifier, // must match the verifier used to generate the challenge
clientId: process.env.WORKOS_CLIENT_ID!,
// session cookie is set by WorkOS if using AuthKit — do not set it manually
});
return {
accessToken: result.accessToken,
refreshToken: result.refreshToken,
user: result.user,
};
}
// Refresh an expired access token
async function refreshAccessToken(refreshToken: string) {
const result = await workos.userManagement.authenticateWithRefreshToken({
refreshToken,
clientId: process.env.WORKOS_CLIENT_ID!,
});
return result.accessToken;
}
The redirect URI registered in the WorkOS dashboard must be an exact string match — including scheme, hostname, path, and trailing slash. WorkOS does not support wildcard redirect URIs or path prefix matching. In multi-environment setups, register separate redirect URIs for each environment (development, staging, production) and use the correct WORKOS_REDIRECT_URI environment variable per deployment.
M2M API keys — server-to-server verification
WorkOS M2M (machine-to-machine) API keys are opaque credential strings — they are not JWTs and cannot be verified locally. An MCP server receiving an M2M API key must call WorkOS's API to authenticate it, which adds latency per call. Cache the authentication result for the duration of the API key's validity.
import WorkOS from '@workos-inc/node';
const workos = new WorkOS(process.env.WORKOS_API_KEY!);
// M2M API key authentication — network call to WorkOS
async function authenticateM2MKey(
apiKey: string
): Promise<{ clientId: string; orgId?: string } | null> {
try {
const result = await workos.userManagement.authenticateWithToken({
// This endpoint verifies the M2M API key against WorkOS's records
// It does NOT return a JWT — it returns a confirmation payload
token: apiKey,
clientId: process.env.WORKOS_CLIENT_ID!,
});
return {
clientId: result.clientId ?? result.user?.id ?? 'unknown',
orgId: result.organizationId ?? undefined,
};
} catch {
return null; // invalid or revoked key
}
}
// Pattern: cache M2M authentication results to avoid per-call latency
// M2M keys don't expire on a schedule — use a short TTL to handle revocation
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 quickly
async function verifyM2MKey(
apiKey: string
): Promise<{ clientId: string; orgId?: string } | null> {
const cached = m2mCache.get(apiKey);
if (cached && Date.now() - cached.cachedAt < M2M_CACHE_TTL_MS) {
return cached;
}
const result = await authenticateM2MKey(apiKey);
if (result) {
m2mCache.set(apiKey, { ...result, cachedAt: Date.now() });
}
return result;
}
WorkOS M2M API keys should be treated like passwords: transmit only over HTTPS, store hashed in your database if you manage them, and rotate them when a client is offboarded. Unlike JWTs, revoked M2M keys take effect immediately — but your cache TTL (above) controls how quickly your MCP server notices a revocation. Use a shorter TTL for higher-security contexts.
Directory Sync — webhook-first event model
WorkOS Directory Sync synchronises users and groups from enterprise identity providers (Okta, Azure AD, Google Workspace). The sync model is webhook-push only — there is no REST endpoint to poll for "changes since timestamp". Your MCP server must consume and persist webhook events to have a current view of directory state.
import WorkOS from '@workos-inc/node';
import crypto from 'crypto';
const workos = new WorkOS(process.env.WORKOS_API_KEY!);
// Webhook signature verification
function verifyWorkOSWebhook(
rawBody: Buffer | string,
signatureHeader: string,
secret: string
): boolean {
// WorkOS-Signature format: "t=timestamp,v1=signature"
const parts = signatureHeader.split(',');
const timestamp = parts.find(p => p.startsWith('t='))?.slice(2);
const signature = parts.find(p => p.startsWith('v1='))?.slice(3);
if (!timestamp || !signature) return false;
// Reject webhooks older than 5 minutes
const age = Date.now() - parseInt(timestamp, 10) * 1000;
if (age > 5 * 60 * 1000) return false;
const payload = `${timestamp}.${rawBody.toString()}`;
const expected = crypto
.createHmac('sha256', secret)
.update(payload)
.digest('hex');
return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature));
}
// Directory Sync event handler
interface DirectorySyncEvent {
id: string;
event: string;
data: {
id: string;
directory_id: string;
organization_id: string;
raw_attributes: Record<string, unknown>;
// User events
username?: string;
emails?: Array<{ type: string; value: string; primary: boolean }>;
// Group events
name?: string;
};
}
async function handleDirectorySyncWebhook(event: DirectorySyncEvent): Promise<void> {
const { event: eventType, data } = event;
switch (eventType) {
case 'dsync.user.created':
// New user provisioned in the enterprise directory
await provisionUser({
externalId: data.id,
directoryId: data.directory_id,
orgId: data.organization_id,
email: data.emails?.find(e => e.primary)?.value,
username: data.username,
});
break;
case 'dsync.user.updated':
// User attributes changed in the IdP (name, email, role, etc.)
await updateUser(data.id, {
email: data.emails?.find(e => e.primary)?.value,
attributes: data.raw_attributes,
});
break;
case 'dsync.user.deleted':
// User deprovisioned — revoke access immediately
await deprovisionUser(data.id);
break;
case 'dsync.group.created':
case 'dsync.group.updated':
case 'dsync.group.deleted':
case 'dsync.group.user_added':
case 'dsync.group.user_removed':
await handleGroupChange(eventType, data);
break;
}
}
// CRITICAL: Directory Sync does NOT have a REST polling endpoint for changes.
// If your webhook endpoint was down during a sync window, use WorkOS's
// directory user list API to backfill the current state:
async function backfillDirectoryUsers(directoryId: string): Promise<void> {
let cursor: string | undefined;
do {
const { data: users, listMetadata } = await workos.directorySync.listUsers({
directory: directoryId,
limit: 100,
after: cursor,
});
for (const user of users) {
await upsertUser(user);
}
cursor = listMetadata?.after;
} while (cursor);
}
Directory Sync webhooks can arrive out of order when a large bulk sync is in progress (e.g., a new enterprise customer connecting their Okta). Build your event handlers to be idempotent — processing a dsync.user.created event twice must not create duplicate records. Use the data.id (WorkOS directory user ID) as the idempotency key.
Organisation ID mapping — WorkOS ID vs your tenant ID
WorkOS issues org_id claims in access tokens that identify the WorkOS organisation object. This ID is WorkOS's internal identifier, not your application's tenant or account ID. You must maintain a mapping table between WorkOS org IDs and your internal tenant IDs.
// WorkOS org_id in JWT claims vs your application's tenant concept
interface WorkOSClaims {
sub: string; // WorkOS user ID (usr_...)
org_id: string; // WorkOS organisation ID (org_...) — NOT your tenant ID
role: string; // User's role in the org (configured in WorkOS dashboard)
}
// Your mapping table (stored in your DB)
interface OrgMapping {
workosOrgId: string; // org_01abc...
tenantId: string; // your internal tenant UUID
tenantName: string;
dsyncDirectoryId?: string; // linked Directory Sync directory, if any
}
// Resolve WorkOS org_id to your application's tenant context
async function resolveTenant(workosOrgId: string): Promise<OrgMapping> {
const mapping = await db.query(
'SELECT * FROM org_mappings WHERE workos_org_id = $1',
[workosOrgId]
);
if (!mapping) {
// This happens when a WorkOS org exists but hasn't been mapped to a tenant yet
// e.g., admin created the org in WorkOS but didn't complete your onboarding flow
throw new Error(`No tenant found for WorkOS org: ${workosOrgId}`);
}
return mapping;
}
// Webhook: sync new organisation connection to your mapping table
async function handleConnectionActivated(event: WorkOSWebhookEvent) {
// connection.activated fires when SSO is configured for an org
const { organization_id, id: connectionId } = event.data;
// Fetch the organisation details from WorkOS
const org = await workos.organizations.getOrganization(organization_id);
await db.query(
'UPDATE tenants SET workos_org_id = $1, sso_connection_id = $2 WHERE domain = $3',
[organization_id, connectionId, org.domains[0]?.domain]
);
}
WorkOS SSO connections belong to organisations. When an enterprise customer configures SAML or OIDC with WorkOS, a connection object is created under their organisation. Multiple SSO connections can exist per organisation (e.g., Okta for most employees, Google Workspace for contractors). The org_id in the access token is stable — use it as the foreign key in your tenant mapping, not the connection ID, which may change if a customer switches IdPs.
Health probe — WorkOS API reachability
MCP servers gated behind WorkOS SSO should verify WorkOS API reachability at startup. A WorkOS outage means SSO login fails for all enterprise customers — visible as a sudden drop in authenticated traffic rather than application errors.
import WorkOS from '@workos-inc/node';
const workos = new WorkOS(process.env.WORKOS_API_KEY!);
async function checkWorkOSHealth(): Promise<{ healthy: boolean; latencyMs?: number; error?: string }> {
const start = Date.now();
try {
// List organisations is a lightweight read that validates API key auth
// and WorkOS API reachability
await workos.organizations.listOrganizations({ limit: 1 });
return { healthy: true, latencyMs: Date.now() - start };
} catch (err) {
return { healthy: false, error: String(err) };
}
}
// Also verify JWKS endpoint is reachable (separate from API availability)
async function checkJWKSReachability(): Promise<{ healthy: boolean; error?: string }> {
try {
const response = await fetch('https://api.workos.com/.well-known/jwks');
if (!response.ok) throw new Error(`HTTP ${response.status}`);
await response.json(); // ensure valid JSON
return { healthy: true };
} catch (err) {
return { healthy: false, error: String(err) };
}
}
AliveMCP monitors WorkOS-integrated MCP endpoints by probing the JWKS endpoint availability and tracking the latency of JWKS key fetches — elevated JWKS latency is an early indicator of WorkOS API degradation that surfaces before SSO login failures become widespread.