kavachOS
Platform

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):

EndpointRFCPurpose
GET /.well-known/oauth-authorization-serverRFC 8414Authorization server metadata
GET /.well-known/oauth-protected-resourceRFC 9728Protected resource metadata
POST /oauth/registerRFC 7591Dynamic client registration
GET /oauth/authorizeOAuth 2.1Authorization endpoint
POST /oauth/tokenOAuth 2.1Token endpoint
POST /oauth/revokeRFC 7009Token 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 ID
  • aud: the audience URL from your config
  • scope: space-separated granted scopes
  • exp: expiry timestamp
  • iat: 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 object

Validation 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 header

Registering 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.

On this page