Skip to main content
The Model Context Protocol (MCP) has become the standard way to connect AI agents to external tools and data sources. Claude Desktop, Cursor, Windsurf, and a growing list of clients all speak MCP. But MCP has a gap that matters in production: authentication is mandatory in the spec, but building it is left as an exercise for the server developer. The MCP specification mandates OAuth 2.1 for transport-level authorization. Implementing it correctly requires PKCE S256, Dynamic Client Registration (RFC 7591), token introspection (RFC 7662), token revocation (RFC 7009), proper JWKS key management, rate limiting, and a well-known metadata endpoint. That is weeks of work before you write a single tool handler. This post walks through adding production-ready OAuth 2.1 authentication to any MCP server using @grantex/mcp-auth — a single function call that handles the entire OAuth stack.

Why MCP Servers Need Authentication

Without authentication, any MCP client that can reach your server can call any tool with full access. In a local development setup, this is fine. In production — where your MCP server reads databases, calls APIs, or modifies infrastructure — it is a serious exposure. Consider a concrete scenario: you build an MCP server that gives Claude Desktop access to your company’s Jira instance. Without auth, anyone who discovers the server URL can list tickets, create issues, and close sprints. With auth, each user gets a scoped token that limits what they can do and expires automatically. MCP authentication solves three problems:
  1. Identity. You know who is calling your tools — which user, which agent, which client application.
  2. Scoped access. You control what they can do. A read-only user cannot call write tools, even if the MCP client sends the request.
  3. Revocation. You can cut off access immediately when a user leaves, a session expires, or an agent misbehaves.

Setting Up MCP OAuth Authentication with @grantex/mcp-auth

Step 1: Install Dependencies

npm install @grantex/mcp-auth @grantex/sdk
@grantex/mcp-auth works in both managed (Grantex Cloud) and self-hosted modes. The API is identical — just point the SDK at a different base URL.

Step 2: Create the Auth Server

The createMcpAuthServer() function spins up a fully compliant OAuth 2.1 authorization server as a Fastify instance. It registers six endpoints that MCP clients auto-discover:
import { Grantex } from '@grantex/sdk';
import { createMcpAuthServer } from '@grantex/mcp-auth';

const grantex = new Grantex({
  baseUrl: 'https://grantex-auth-dd4mtrt2gq-uc.a.run.app',
  apiKey: process.env.GRANTEX_API_KEY!,
});

const authServer = await createMcpAuthServer({
  grantex,
  agentId: 'ag_your_mcp_server',
  scopes: [
    'tools:read',       // List available tools
    'tools:execute',    // Call tools
    'resources:read',   // Read resources
    'resources:write',  // Create/update resources
  ],
  issuer: 'https://your-mcp-server.example.com',
});

await authServer.listen({ port: 3001 });
console.log('MCP Auth Server running on http://localhost:3001');
This single call gives you:
EndpointRFCPurpose
/.well-known/oauth-authorization-serverRFC 8414Metadata discovery for MCP clients
/registerRFC 7591Dynamic Client Registration
/authorizeOAuth 2.1Authorization endpoint (PKCE required)
/tokenOAuth 2.1Token exchange and refresh
/introspectRFC 7662Token introspection
/revokeRFC 7009Token revocation

Step 3: Protect Your MCP Routes with Middleware

Use the Express middleware to validate tokens on every incoming request:
import express from 'express';
import { requireMcpAuth } from '@grantex/mcp-auth/express';
import type { McpAuthRequest } from '@grantex/mcp-auth/express';

const app = express();

// All MCP routes require a valid token with tools:execute scope
app.use('/mcp', requireMcpAuth({
  issuer: 'https://your-mcp-server.example.com',
  scopes: ['tools:execute'],
}));

app.post('/mcp/tools/call', (req: McpAuthRequest, res) => {
  const grant = req.mcpGrant!;
  console.log(`Agent: ${grant.agentDid}`);
  console.log(`Scopes: ${grant.scopes.join(', ')}`);
  console.log(`User: ${grant.sub}`);

  // Your tool logic here — the caller is authenticated and authorized
  res.json({ result: 'tool executed' });
});

app.listen(3000);
The middleware:
  • Fetches the JWKS from {issuer}/.well-known/jwks.json (cached after first request)
  • Validates the JWT signature, expiry, and issuer
  • Checks that all required scopes are present in the token
  • Attaches the decoded grant to req.mcpGrant for your handler to use
  • Returns 401 for missing/invalid tokens and 403 for insufficient scopes

Step 4: Verify Client Auto-Discovery

MCP clients find your auth server automatically via the well-known metadata endpoint:
curl https://your-mcp-server.example.com/.well-known/oauth-authorization-server
Response:
{
  "issuer": "https://your-mcp-server.example.com",
  "authorization_endpoint": "https://your-mcp-server.example.com/authorize",
  "token_endpoint": "https://your-mcp-server.example.com/token",
  "registration_endpoint": "https://your-mcp-server.example.com/register",
  "introspection_endpoint": "https://your-mcp-server.example.com/introspect",
  "revocation_endpoint": "https://your-mcp-server.example.com/revoke",
  "response_types_supported": ["code"],
  "grant_types_supported": ["authorization_code", "refresh_token"],
  "code_challenge_methods_supported": ["S256"],
  "scopes_supported": ["tools:read", "tools:execute", "resources:read", "resources:write"]
}
Claude Desktop, Cursor, and other MCP clients read this metadata to know where to send authorization requests. No manual client configuration needed.

How the PKCE Authorization Flow Works

When an MCP client connects to your authenticated server, the full OAuth 2.1 + PKCE flow runs automatically:
  1. Discovery. The client fetches /.well-known/oauth-authorization-server to find endpoints.
  2. Registration. The client calls /register to get a client_id (Dynamic Client Registration per RFC 7591).
  3. Authorization. The client redirects the user to /authorize with a PKCE code_challenge (S256). The user sees a consent screen listing the requested scopes.
  4. Consent. The user approves or denies. On approval, the auth server redirects back with an authorization code.
  5. Token exchange. The client sends the code and code_verifier to /token. The server verifies PKCE, issues a JWT access token and a refresh token.
  6. API calls. The client includes the token as a Bearer token on all subsequent MCP requests.
  7. Refresh. When the token expires, the client uses the refresh token to get a new one without re-prompting the user.
PKCE (Proof Key for Code Exchange) prevents authorization code interception attacks. The S256 method is the only one supported — plain PKCE is rejected. The default consent page works out of the box, but you can brand it:
const authServer = await createMcpAuthServer({
  grantex,
  agentId: 'ag_your_server',
  scopes: ['tools:read', 'tools:execute'],
  issuer: 'https://your-mcp-server.example.com',
  consentUi: {
    appName: 'Acme Analytics MCP',
    appLogo: 'https://acme.example.com/logo.png',
    privacyUrl: 'https://acme.example.com/privacy',
    termsUrl: 'https://acme.example.com/terms',
  },
});
Users see your app name, logo, and the exact scopes being requested before they approve.

Lifecycle Hooks for Audit and Monitoring

React to authorization events for audit logging, analytics, or downstream notifications:
const authServer = await createMcpAuthServer({
  grantex,
  agentId: 'ag_your_server',
  scopes: ['tools:read', 'tools:execute'],
  issuer: 'https://your-mcp-server.example.com',
  hooks: {
    onTokenIssued: async (event) => {
      console.log(`Token issued for client ${event.clientId}`);
      console.log(`Scopes: ${event.scopes.join(', ')}`);
      console.log(`Grant ID: ${event.grantId}`);
      // Send to your monitoring system
    },
    onRevocation: async (jti) => {
      console.log(`Token ${jti} was revoked`);
      // Invalidate cached sessions
    },
  },
});
Combined with the Grantex audit trail, you get a complete record of who authorized what, when tokens were issued, and when access was revoked.

Token Introspection and Revocation

Resource servers can validate tokens by calling the introspection endpoint directly:
curl -X POST https://your-mcp-server.example.com/introspect \
  -H "Content-Type: application/json" \
  -d '{"token": "eyJhbGciOiJSUzI1NiIs..."}'
The response includes the token’s active status, scopes, subject, expiry, and Grantex-specific claims like the agent DID and grant ID. Revoke tokens when a user logs out or an agent is deauthorized:
curl -X POST https://your-mcp-server.example.com/revoke \
  -H "Content-Type: application/json" \
  -d '{"token": "eyJhbGciOiJSUzI1NiIs..."}'
Per RFC 7009, the endpoint always returns 200 OK, even if the token was already revoked.

Managed vs Self-Hosted

@grantex/mcp-auth works in two modes:
FeatureManaged (Grantex Cloud)Self-Hosted
SetupPoint SDK at Grantex CloudPoint SDK at your infrastructure
Client StoreIn-memory (stateless, horizontal scale)Bring your own (Postgres, Redis)
Token SigningGrantex signs tokensGrantex signs (delegated)
Rate LimitingBuilt-in per-endpointBuilt-in, configurable
Audit TrailFull audit via Grantex eventsFull audit via Grantex events
Uptime SLA99.9%Your responsibility
The API is identical in both modes. Start with managed, move to self-hosted when you need it.

Next Steps