Secure your MCP server for the real world — implement Authorization Code + PKCE, validate JWTs, handle token refresh, and design per-user scopes without compromising developer experience.
/sse URL can call all your tools with full privileges — including destructive ones.S256 (SHA-256). The older plain method provides no security — never use it.// src/auth/pkce.ts — PKCE helpers for an MCP client
import { createHash, randomBytes } from 'crypto';
export function generateCodeVerifier(): string {
// 64 random bytes → 86 Base64url chars (well within 43–128 range)
return randomBytes(64).toString('base64url');
}
export function generateCodeChallenge(verifier: string): string {
return createHash('sha256').update(verifier).digest('base64url');
}
export function generateState(): string {
return randomBytes(32).toString('hex');
}
// Build the authorization URL
export function buildAuthUrl(params: {
authorizationEndpoint: string;
clientId: string;
redirectUri: string;
scopes: string[];
codeChallenge: string;
state: string;
}): string {
const url = new URL(params.authorizationEndpoint);
url.searchParams.set('response_type', 'code');
url.searchParams.set('client_id', params.clientId);
url.searchParams.set('redirect_uri', params.redirectUri);
url.searchParams.set('scope', params.scopes.join(' '));
url.searchParams.set('code_challenge', params.codeChallenge);
url.searchParams.set('code_challenge_method', 'S256');
url.searchParams.set('state', params.state);
return url.toString();
}
// Exchange auth code for tokens
export async function exchangeCode(params: {
tokenEndpoint: string;
clientId: string;
code: string;
codeVerifier: string;
redirectUri: string;
}): Promise<{ access_token: string; refresh_token?: string; expires_in: number }> {
const res = await fetch(params.tokenEndpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'authorization_code',
client_id: params.clientId,
code: params.code,
code_verifier: params.codeVerifier,
redirect_uri: params.redirectUri,
}),
});
if (!res.ok) throw new Error(`Token exchange failed: ${await res.text()}`);
return res.json();
}src/auth/pkce.ts
// src/auth/jwt-validator.ts — Production JWT validation for MCP HTTP server
import { createRemoteJWKSet, jwtVerify } from 'jose'; // npm install jose
const JWKS_URI = process.env.JWKS_URI ?? 'https://YOUR_DOMAIN/.well-known/jwks.json';
const AUDIENCE = process.env.JWT_AUDIENCE ?? 'https://your-mcp-server.com';
const ISSUER = process.env.JWT_ISSUER ?? 'https://YOUR_DOMAIN/';
// Cache the JWKS (automatically refreshed by jose when keys rotate)
const JWKS = createRemoteJWKSet(new URL(JWKS_URI));
export interface TokenClaims {
sub: string; // user ID
iss: string; // issuer
aud: string | string[]; // audience
exp: number; // expiry timestamp
scope?: string; // space-delimited scopes
email?: string;
[key: string]: unknown;
}
export async function validateToken(rawToken: string): Promise<TokenClaims> {
const { payload } = await jwtVerify(rawToken, JWKS, {
issuer: ISSUER,
audience: AUDIENCE,
});
return payload as TokenClaims;
}
export function hasScope(claims: TokenClaims, required: string): boolean {
const scopes = (claims.scope ?? '').split(' ');
return scopes.includes(required);
}
// ── Express middleware ──────────────────────────────────────────────────────
import type { Request, Response, NextFunction } from 'express';
declare global {
namespace Express {
interface Request { tokenClaims?: TokenClaims }
}
}
export function requireAuth(requiredScope?: string) {
return async (req: Request, res: Response, next: NextFunction) => {
const auth = req.headers.authorization ?? '';
if (!auth.startsWith('Bearer ')) {
res.status(401).json({ error: 'Missing Bearer token' });
return;
}
try {
const claims = await validateToken(auth.slice(7));
if (requiredScope && !hasScope(claims, requiredScope)) {
res.status(403).json({ error: `Insufficient scope — requires: ${requiredScope}` });
return;
}
req.tokenClaims = claims; // attach to request for downstream use
next();
} catch (err) {
res.status(401).json({ error: 'Invalid or expired token' });
}
};
}
// Usage:
// app.get('/sse', requireAuth('mcp:read'), sseHandler);
// app.post('/message', requireAuth('mcp:read'), postHandler);src/auth/jwt-validator.ts
jose is the gold standard for JWT validation in Node.js. It supports RS256, ES256, JWKS rotation, audience/issuer checks, and works edge runtimes (Cloudflare Workers, Deno). Install: npm install jose.| Scope | Access level | Example tools allowed | Type |
|---|---|---|---|
| mcp:read | Read all resources and call read-only tools | search_repos, get_file_content, list_users | READ |
| mcp:write | Call tools that create or modify data | create_issue, update_record, send_notification | WRITE |
| mcp:admin | All tools including destructive operations | delete_repo, rotate_secrets, purge_cache | ADMIN |
| github:repos | GitHub-specific tool subset | search_repos, get_file_content, create_pr_draft | RESOURCE |
| github:issues | Issue management tools only | list_issues, create_issue, close_issue | RESOURCE |
| offline_access | Allow refresh token issuance | (enables background token refresh) | OAUTH |
// src/auth/scope-guard.ts — per-tool scope enforcement
// Map each tool name to its required scope(s)
const TOOL_SCOPES: Record<string, string[]> = {
search_repos: ['mcp:read', 'github:repos'], // either scope works
get_file_content: ['mcp:read', 'github:repos'],
analyze_complexity: ['mcp:read'],
create_pr_draft: ['mcp:write', 'github:repos'],
create_issue: ['mcp:write', 'github:issues'],
delete_repo: ['mcp:admin'],
};
export function canCallTool(claims: TokenClaims, toolName: string): boolean {
const required = TOOL_SCOPES[toolName];
if (!required) return true; // unregistered tools are unrestricted (adjust per policy)
const tokenScopes = (claims.scope ?? '').split(' ');
// Token needs to have AT LEAST ONE of the required scopes
return required.some(s => tokenScopes.includes(s));
}
// Integrate into tool handler:
server.tool('delete_repo', 'Delete a GitHub repository', { name: z.string() }, async (args, { server: srv }) => {
// In HTTP servers, attach claims to the request context
const claims = getRequestClaims(); // pull from AsyncLocalStorage context
if (!canCallTool(claims, 'delete_repo')) {
return { isError: true, content: [{ type: 'text' as const,
text: 'Forbidden: token missing mcp:admin scope' }] };
}
// ... proceed with deletion
});src/auth/scope-guard.ts
// src/auth/token-manager.ts — silent token refresh with proactive renewal
interface TokenSet {
accessToken: string;
refreshToken?: string;
expiresAt: number; // Date.now() + expires_in * 1000
}
export class TokenManager {
private tokens: TokenSet | null = null;
private refreshPromise: Promise<TokenSet> | null = null;
constructor(
private readonly tokenEndpoint: string,
private readonly clientId: string,
) {}
async getAccessToken(): Promise<string> {
if (!this.tokens) throw new Error('Not authenticated — call setTokens() first');
// Proactive refresh: renew if less than 60 seconds remain
const expiresIn = this.tokens.expiresAt - Date.now();
if (expiresIn < 60_000) {
this.tokens = await this.refresh();
}
return this.tokens.accessToken;
}
setTokens(set: TokenSet) { this.tokens = set; }
private async refresh(): Promise<TokenSet> {
// Deduplicate concurrent refresh calls
if (this.refreshPromise) return this.refreshPromise;
this.refreshPromise = (async () => {
if (!this.tokens?.refreshToken) throw new Error('No refresh token available');
const res = await fetch(this.tokenEndpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: this.tokens.refreshToken,
client_id: this.clientId,
}),
});
if (!res.ok) throw new Error(`Token refresh failed: ${await res.text()}`);
const data = await res.json();
return {
accessToken: data.access_token,
refreshToken: data.refresh_token ?? this.tokens!.refreshToken,
expiresAt: Date.now() + data.expires_in * 1000,
};
})().finally(() => { this.refreshPromise = null; });
return this.refreshPromise;
}
}
// Usage in the HTTP+SSE client transport:
// const mgr = new TokenManager(TOKEN_ENDPOINT, CLIENT_ID);
// mgr.setTokens({ accessToken: '...', refreshToken: '...', expiresAt: Date.now() + 3600_000 });
//
// const transport = new SSEClientTransport(new URL(SERVER_URL + '/sse'), {
// requestInit: async () => ({
// headers: { Authorization: `Bearer ${await mgr.getAccessToken()}` }
// }),
// });src/auth/token-manager.ts
refreshPromise deduplication pattern ensures only one refresh happens at a time.search_repos searches their GitHub, not a shared service account. Use Node.js AsyncLocalStorage to pass request context without polluting every function signature.// src/auth/context.ts — AsyncLocalStorage for per-request auth context
import { AsyncLocalStorage } from 'async_hooks';
import type { TokenClaims } from './jwt-validator.js';
export interface RequestContext {
userId: string;
userEmail: string;
scopes: string[];
// Per-user API credentials (fetched from your secrets store)
githubToken?: string;
}
export const requestContext = new AsyncLocalStorage<RequestContext>();
export function getContext(): RequestContext {
const ctx = requestContext.getStore();
if (!ctx) throw new Error('No request context — are you inside a tool handler?');
return ctx;
}
// ── Attach to express SSE + POST handlers ─────────────────────────────────
// In your Express app, wrap the handler in requestContext.run():
app.get('/sse', requireAuth('mcp:read'), async (req, res) => {
const claims = req.tokenClaims!;
const ctx: RequestContext = {
userId: claims.sub,
userEmail: claims.email ?? '',
scopes: (claims.scope ?? '').split(' '),
githubToken: await fetchUserGitHubToken(claims.sub), // from your secrets store
};
requestContext.run(ctx, async () => {
const transport = new SSEServerTransport('/message', res);
const mcpServer = buildMcpServer(); // creates a new McpServer per connection
await mcpServer.connect(transport);
});
});
// ── Inside a tool handler, get the user's context ─────────────────────────
server.tool('search_repos', 'Search GitHub', { query: z.string() }, async ({ query }) => {
const { userId, githubToken } = getContext();
// Each user gets results from their own GitHub account
const results = await githubSearch(query, githubToken);
return { content: [{ type: 'text' as const, text: JSON.stringify(results) }] };
});src/auth/context.ts
// src/auth/dcr.ts — Dynamic Client Registration (RFC 7591)
// Your auth server exposes POST /oauth/register
// MCP clients call it once to get a client_id automatically
export async function registerClient(params: {
registrationEndpoint: string;
clientName: string;
redirectUris: string[];
scopes: string[];
grantTypes?: string[];
}): Promise<{ client_id: string; client_secret?: string }> {
const res = await fetch(params.registrationEndpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
client_name: params.clientName,
redirect_uris: params.redirectUris,
scope: params.scopes.join(' '),
grant_types: params.grantTypes ?? ['authorization_code', 'refresh_token'],
response_types: ['code'],
token_endpoint_auth_method: 'none', // public client (PKCE, no secret)
}),
});
if (!res.ok) throw new Error(`DCR failed: ${await res.text()}`);
return res.json();
}
// ── MCP server discovery endpoint ─────────────────────────────────────────
// Expose /.well-known/oauth-protected-resource so clients auto-discover auth
app.get('/.well-known/oauth-protected-resource', (_req, res) => {
res.json({
resource: 'https://your-mcp-server.com',
authorization_servers: ['https://your-auth-server.com'],
scopes_supported: ['mcp:read', 'mcp:write', 'mcp:admin', 'offline_access'],
bearer_methods_supported: ['header'],
});
});src/auth/dcr.ts
WWW-Authenticate header pointing to your discovery URL, it can automatically find your authorization server, register itself, and complete the PKCE flow — all without any manual configuration by the user.// src/auth/client-credentials.ts — M2M token acquisition
export class MachineTokenManager {
private cachedToken: { value: string; expiresAt: number } | null = null;
constructor(
private readonly tokenEndpoint: string,
private readonly clientId: string,
private readonly clientSecret: string,
private readonly scopes: string[],
) {}
async getToken(): Promise<string> {
// Serve from cache if still valid (with 60s buffer)
if (this.cachedToken && Date.now() < this.cachedToken.expiresAt - 60_000) {
return this.cachedToken.value;
}
const res = await fetch(this.tokenEndpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Authorization': `Basic ${Buffer.from(`${this.clientId}:${this.clientSecret}`).toString('base64')}`,
},
body: new URLSearchParams({
grant_type: 'client_credentials',
scope: this.scopes.join(' '),
}),
});
if (!res.ok) throw new Error(`M2M token request failed: ${await res.text()}`);
const data = await res.json();
this.cachedToken = {
value: data.access_token,
expiresAt: Date.now() + data.expires_in * 1000,
};
return this.cachedToken.value;
}
}
// Usage in a CI/CD pipeline client:
const m2m = new MachineTokenManager(
process.env.TOKEN_ENDPOINT!,
process.env.CLIENT_ID!,
process.env.CLIENT_SECRET!, // stored in CI secrets, never in code
['mcp:read', 'github:repos'],
);
const transport = new SSEClientTransport(new URL(SERVER_URL + '/sse'), {
requestInit: async () => ({
headers: { Authorization: `Bearer ${await m2m.getToken()}` }
}),
});src/auth/client-credentials.ts
client_secret must live in a secrets manager (AWS Secrets Manager, GitHub Actions secrets, Vault) and be injected as an environment variable at runtime. Rotate secrets regularly and revoke them immediately if compromised.state parameter on callback — prevents CSRF attacks that trick users into connecting your client to an attacker's accountS256 PKCE on all public clients — never plain, never skip PKCE entirely even if the auth server allows itiss, aud, and exp on every JWT — the jose library does this automatically when you provide issuer + audience optionslocalStorage (XSS accessible). Server-side clients can use encrypted storage.mcp:admin for a read-only tool consumer.https://localhost with a local cert for dev.TokenClaims interface.// Quick-start: Auth0 + Express MCP server — complete setup
import express from 'express';
import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
import { requireAuth } from './auth/jwt-validator.js';
import { requestContext } from './auth/context.js';
const app = express();
app.use(express.json());
// AUTH0 ENV VARS:
// JWKS_URI=https://YOUR_DOMAIN.auth0.com/.well-known/jwks.json
// JWT_ISSUER=https://YOUR_DOMAIN.auth0.com/
// JWT_AUDIENCE=https://your-mcp-server.com
const sessions = new Map();
app.get('/sse', requireAuth('mcp:read'), async (req, res) => {
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
res.setHeader('X-Accel-Buffering', 'no');
const claims = req.tokenClaims!;
const ctx = {
userId: claims.sub,
userEmail: claims.email ?? '',
scopes: (claims.scope ?? '').split(' '),
};
requestContext.run(ctx, async () => {
const transport = new SSEServerTransport('/message', res);
sessions.set(transport.sessionId, transport);
const server = buildMcpServer(); // registerTools(server), etc.
await server.connect(transport);
req.on('close', () => sessions.delete(transport.sessionId));
});
});
app.post('/message', requireAuth('mcp:read'), async (req, res) => {
const transport = sessions.get(req.query.sessionId as string);
if (!transport) { res.status(404).json({ error: 'Session not found' }); return; }
await transport.handlePostMessage(req, res, req.body);
});
app.listen(3000, () => console.error('[MCP Auth] Listening on :3000'));complete auth setup
code_challenge and why is it safe to send in the browser redirect URL?TokenManager detects the access token has less than 60 seconds of remaining life. Two tool calls arrive simultaneously and both trigger getAccessToken(). Without the refreshPromise deduplication, what bad thing happens?scope: "mcp:read github:repos". They ask Claude to call the delete_repo tool which requires mcp:admin. What should your server return?isError: true and a message explaining the insufficient scope