MCP OAuth 2.1
Setting up the KavachOS authorization server for the Model Context Protocol.
What MCP auth is
The Model Context Protocol defines how AI clients connect to tool servers. The 2025-03 revision added an auth layer: MCP servers can now require OAuth 2.1 tokens before accepting tool calls.
KavachOS implements the full MCP auth stack:
- OAuth 2.1 with PKCE (S256 code challenge method only)
- Protected Resource Metadata (RFC 9728)
- Authorization Server Metadata (RFC 8414)
- Resource Indicators (RFC 8707)
- Dynamic Client Registration (RFC 7591)
Setup
Setting up
import { createKavach } from 'kavachos';
const kavach = createKavach({
database: { provider: 'sqlite', url: 'kavach.db' },
baseUrl: 'https://auth.yourapp.com',
mcp: {
issuer: 'https://auth.yourapp.com',
audience: 'https://mcp.yourapp.com',
accessTokenTtl: 3600, // seconds
refreshTokenTtl: 86400,
},
});Then mount the MCP module via a framework adapter. See Framework adapters for how to pass createMcpModule to your adapter.
MCP config options
Prop
Type
Endpoints
Once mounted, KavachOS serves these endpoints (relative to your basePath, default /api/kavach):
| Endpoint | RFC | Purpose |
|---|---|---|
GET /.well-known/oauth-authorization-server | RFC 8414 | Authorization server metadata |
GET /.well-known/oauth-protected-resource | RFC 9728 | Protected resource metadata |
POST /oauth/register | RFC 7591 | Dynamic client registration |
GET /oauth/authorize | OAuth 2.1 | Authorization endpoint |
POST /oauth/token | OAuth 2.1 | Token endpoint |
POST /oauth/revoke | RFC 7009 | Token revocation |
The well-known endpoints are served at the root of your domain, not under basePath. KavachOS registers them separately so MCP clients can discover your auth server from any path.
OAuth flow
PKCE flow
KavachOS only accepts S256. Plain PKCE is rejected at the authorization endpoint.
Generate a code verifier
The client generates a cryptographically random string between 43 and 128 characters.
const array = new Uint8Array(32);
crypto.getRandomValues(array);
const codeVerifier = btoa(String.fromCharCode(...array))
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');Compute the code challenge
const encoder = new TextEncoder();
const data = encoder.encode(codeVerifier);
const digest = await crypto.subtle.digest('SHA-256', data);
const codeChallenge = btoa(String.fromCharCode(...new Uint8Array(digest)))
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');Redirect to the authorization endpoint
const authUrl = new URL('https://auth.yourapp.com/oauth/authorize');
authUrl.searchParams.set('response_type', 'code');
authUrl.searchParams.set('client_id', clientId);
authUrl.searchParams.set('redirect_uri', redirectUri);
authUrl.searchParams.set('code_challenge', codeChallenge);
authUrl.searchParams.set('code_challenge_method', 'S256');
authUrl.searchParams.set('scope', 'mcp:read mcp:execute');Exchange the code for tokens
After the user approves, the server issues an authorization code. The client sends it to /oauth/token along with the original code_verifier.
The server computes base64url(sha256(code_verifier)) and compares it to the stored challenge. A mismatch returns a 400.
Token format
Access tokens are JWTs signed with HS256 (typ: at+jwt). They carry:
sub: the agent IDaud: the audience URL from your configscope: space-separated granted scopesexp: expiry timestampiat: issued-at timestamp
Refresh tokens rotate on each use for public clients. Reusing an old refresh token invalidates the entire grant.
Token validation
Validating tokens
Call kavach.mcp.validate(token) to check a token before processing a tool call:
const result = await kavach.mcp.validate(token);
if (!result.valid) {
return new Response('Unauthorized', { status: 401 });
}
// result.agentId — the agent making the call
// result.userId — the human owner
// result.scopes — granted scopes as a string array
// result.expiresAt — Date objectValidation checks the JWT signature, audience binding, scope presence, and expiry. A token issued for a different audience fails even if the signature is valid.
withMcpAuth middleware
The withMcpAuth middleware extracts the bearer token from the Authorization header, validates it, and attaches the agent context to the request. It returns 401 if no token is present or invalid, and 403 if the token is valid but lacks the required scope.
// Hono example — see adapters doc for other frameworks
import { kavachHono } from '@kavachos/hono';
import { createMcpModule } from 'kavachos';
const mcp = createMcpModule(kavach);
const { withMcpAuth } = kavachHono(kavach, { mcp });
app.use('/mcp/*', withMcpAuth());
app.get('/mcp/tools/list', (c) => {
const agent = c.get('agent'); // AgentIdentity set by middleware
return c.json({ tools: [] });
});buildUnauthorizedResponse
When writing custom handlers, use buildUnauthorizedResponse to produce a well-formed 401 with a WWW-Authenticate header pointing to your auth server:
import { buildUnauthorizedResponse } from 'kavachos';
return buildUnauthorizedResponse({
issuer: 'https://auth.yourapp.com',
resource: 'https://mcp.yourapp.com',
error: 'invalid_token',
});
// Returns a Response with status 401 and the correct WWW-Authenticate headerRegistering MCP servers
const server = await kavach.mcp.register({
name: 'github-mcp',
endpoint: 'https://mcp.yourapp.com/github',
tools: ['list_repos', 'get_issue', 'create_comment'],
authRequired: true,
rateLimit: { rpm: 60 },
});
// server.id → "mcp_..."Registered servers appear in the protected resource metadata document. Their tool names can be referenced directly in permission resources, for example mcp:github-mcp:list_repos.