mcp-oauth-server
Self-hosted OAuth 2.1 Authorization Server for MCP servers, as Express middleware in TypeScript. Implements the MCP Authorization Spec so you can protect your MCP server with OAuth without depending on a hosted auth vendor - and with no runtime dependency on the MCP SDK.
Originally forked from the (now sunset) OAuth 2.1 Authorization Server implementation in @modelcontextprotocol/typescript-sdk.
When to use this package
- You are building a remote MCP server and need an OAuth authorization server that MCP clients (Claude, ChatGPT, VS Code, ...) can register against and obtain tokens from - with your own login/consent screen and your own storage.
- You want the authorization server and the protected MCP server (resource server) in one Express app, or split across services sharing an
OAuthServerModel. - You need the client registration mechanisms MCP clients actually use: Client ID Metadata Documents and Dynamic Client Registration.
If you only need to validate tokens issued by an external identity provider, you don't need the full server - use requireBearerAuth with a custom verifier.
Table of Contents
- When to use this package
- Installation
- Features
- OAuth client credentials
- Device authorization (RFC 8628)
- Client ID Metadata Documents (CIMD)
- Quick Start
- API Reference
Installation
npm install mcp-oauth-server@latest --save-exact
Features
- MCP Authorization Spec compliant: Aligns with the MCP Authorization Spec
- OAuth 2.1 with mandatory PKCE (
S256only, RFC 7636) - Client ID Metadata Documents - opt-in, see CIMD
- Bearer token usage (RFC 6750) -
requireBearerAuthwithWWW-Authenticatechallenges (resource_metadata,scope,insufficient_scope) - Resource Indicators (RFC 8707) with token audience validation
- Dynamic Client Registration (RFC 7591) - deprecated by the MCP spec in favor of CIMD, kept for backwards compatibility
- Token Revocation (RFC 7009)
- Authorization Server Metadata (RFC 8414)
- Protected Resource Metadata (RFC 9728)
- Authorization Server Issuer Identification (RFC 9207)
- Loopback redirect URIs with any port for native apps (RFC 8252 §7.3)
- OAuth 2.1 with mandatory PKCE (
- Grant types: Configurable via
grantTypes-authorization_code,refresh_token,client_credentials, and device authorization (urn:ietf:params:oauth:grant-type:device_code, RFC 8628) - Compatibility: Works with MCP clients that omit a
resourceindicator (RFC 8707) or requested scopes when needed (strictResource) - Flexible storage: In-memory model for development (
MemoryOAuthServerModel) or your ownOAuthServerModelfor production
Not supported:
- Token introspection (RFC 7662) - validate access tokens via
OAuthServer.verifyAccessToken(andrequireBearerAuth) instead. private_key_jwtclient authentication for CIMD clients - CIMD clients are treated as public clients (token_endpoint_auth_method: 'none').- OpenID Connect Discovery 1.0 - RFC 8414 metadata satisfies the MCP spec's discovery requirement on its own.
OAuth client credentials (machine-to-machine)
Use this grant when no end user is present (services, CI, daemons). MCP documents this as the OAuth Client Credentials extension (io.modelcontextprotocol/oauth-client-credentials). See the official guide: OAuth Client Credentials.
Server
- Include
'client_credentials'ingrantTypes. - Clients registered for this flow must include
client_credentialsingrant_types(via dynamic registration or your model).
import { OAuthServer } from 'mcp-oauth-server';
const oauthServer = new OAuthServer({
authorizationUrl: new URL('https://example.com/consent'),
scopesSupported: ['mcp:tools'],
grantTypes: ['authorization_code', 'refresh_token', 'client_credentials'],
});
Client (conceptually)
POST to the token endpoint with grant_type=client_credentials and authenticate the client (for example client_id / client_secret per RFC 6749 §4.4). MCP clients using @modelcontextprotocol/client can use ClientCredentialsProvider as described in the extension docs above.
Tokens minted for this grant typically have no userId on AuthInfo - authorize by clientId and scopes where appropriate.
Device authorization (RFC 8628)
Use the device authorization grant for clients that cannot easily run a browser redirect (CLIs, TVs, constrained devices).
Server
- Set
deviceAuthorizationUrlto the URL where the user enters or confirms the user code (your UX page). - Add the device grant type to
grantTypes(use the exported constant so you do not typo the URN):
import { OAuthServer, DEVICE_AUTHORIZATION_GRANT_TYPE } from 'mcp-oauth-server';
const oauthServer = new OAuthServer({
authorizationUrl: new URL('https://example.com/consent'),
scopesSupported: ['mcp:tools'],
grantTypes: ['authorization_code', 'refresh_token', DEVICE_AUTHORIZATION_GRANT_TYPE],
deviceAuthorizationUrl: new URL('https://example.com/device'),
});
- Implement the device-related methods on
OAuthServerModel(saveDeviceAuthorization,getDeviceAuthorizationByDeviceCode,getDeviceAuthorizationByUserCode,deleteDeviceAuthorization) - seeMemoryOAuthServerModelfor a reference.
The auth router exposes POST /device (under your AS base path) when the device grant and deviceAuthorizationUrl are configured. Metadata lists device_authorization_endpoint accordingly.
Approving or denying a login
Wire approveDeviceAuthorizationHandler and denyDeviceAuthorizationHandler on routes you choose; they accept user_code (and resolve the authenticated user via getUser) so the user can approve or reject the device login out-of-band.
Client ID Metadata Documents (CIMD)
Client ID Metadata Documents let clients use an HTTPS URL as their client_id. The authorization server fetches a JSON metadata document from that URL (client_id, client_name, redirect_uris, ...) instead of requiring registration. The draft MCP Authorization spec recommends CIMD and deprecates Dynamic Client Registration in its favor.
Support is opt-in because it makes the server issue outbound HTTPS requests to client-supplied URLs:
const oauthServer = new OAuthServer({
// enable with defaults
clientIdMetadataDocuments: true,
// or configure it
clientIdMetadataDocuments: {
// Trust policy: reject metadata URLs outside your allowlist
validateClientIdUrl: (url) => trustedDomains.includes(url.hostname),
defaultCacheTtlSeconds: 300,
maxCacheTtlSeconds: 3600,
fetchTimeoutMs: 5000,
fetch: myCustomFetch, // defaults to the Node built-in fetch
},
// ...
});
When enabled, the server metadata advertises client_id_metadata_document_supported: true and OAuthServer.getClient resolves URL-formatted client ids by fetching and validating the document: the document's client_id must equal the URL exactly, and client_name and at least one redirect_uris entry are required.
Fetched documents are cached through your OAuthServerModel (saveClientIdMetadataDocument / getClientIdMetadataDocument, implemented by MemoryOAuthServerModel out of the box) respecting Cache-Control headers, so multiple server instances sharing a model share the cache. Enabling CIMD on a custom model requires implementing both methods.
Security notes
- CIMD clients are public clients - documents demanding any
token_endpoint_auth_methodother thannoneare rejected (private_key_jwtis not supported). - Non-HTTPS URLs, loopback/IP-literal hosts, redirects, and documents over 10 KB are always rejected. These checks do not cover DNS rebinding or internal hostnames - use
validateClientIdUrl(or network egress filtering) if the server can reach internal services. - CIMD cannot prevent localhost redirect URI impersonation by itself; consent screens should display the redirect URI hostname to the user.
Quick Start
A minimal MCP authorization server protecting an MCP endpoint:
import express from 'express';
import { OAuthServer, mcpAuthRouter, requireBearerAuth, getOAuthProtectedResourceMetadataUrl } from 'mcp-oauth-server';
const mcpServerUrl = new URL('https://example.com/mcp');
const oauthServer = new OAuthServer({
issuerUrl: new URL('https://example.com'),
authorizationUrl: new URL('https://example.com/consent'), // your login/consent page
scopesSupported: ['mcp:tools'],
clientIdMetadataDocuments: true,
});
const app = express();
// OAuth endpoints: /.well-known metadata, /authorize, /token, /register, /revoke
app.use(mcpAuthRouter({ provider: oauthServer, resourceServerUrl: mcpServerUrl }));
// Your protected MCP endpoint
app.post(
'/mcp',
requireBearerAuth({
verifier: oauthServer,
requiredScopes: ['mcp:tools'],
resourceMetadataUrl: getOAuthProtectedResourceMetadataUrl(mcpServerUrl),
resource: mcpServerUrl,
}),
(req, res) => {
// req.auth is the validated token: clientId, userId, scopes
},
);
app.listen(3000);
Your consent page completes the flow by calling authenticateHandler with the authenticated user id.
A complete runnable example with a memory-backed authorization server and consent screen lives in ./example.
Run the demo
-
Start the server:
pnpm example:server -
In another terminal, authenticate with the server:
pnpm example:client
The example covers mounting the OAuth router, a simple consent screen, and confirming authorization.
API Reference
OAuthServer
OAuth 2.1 server instance passed to mcpAuthRouter.
import { OAuthServer } from 'mcp-oauth-server';
const oauthServer = new OAuthServer({
issuerUrl: new URL('http://localhost:3000'),
authorizationUrl: new URL('http://localhost:3000/consent'),
scopesSupported: ['mcp:tools'],
grantTypes: ['authorization_code', 'refresh_token', 'client_credentials'],
});
Options
model: (optional) Storage backend. Default:MemoryOAuthServerModel.issuerUrl: (required when usingmcpAuthRouter) Authorization server issuer identifier (HTTPS in production; localhost is allowed for development). Used as the metadataissuerand appended as the RFC 9207issparameter on authorization responses.authorizationUrl: (required) Redirect URL for interactive authorization (consent). Required whenauthorization_codeis enabled.resourceServerUrl: (optional) MCP resource server URL; used for resource validation and metadata when set.scopesSupported: (optional) Supported scopes; if the client omitsscope, the server may default to these supported scopes.accessTokenLifetime: (optional) Access token lifetime in seconds. Default:3600.refreshTokenLifetime: (optional) Refresh token lifetime in seconds. Default:1209600(14 days).clientSecretLifetime: (optional) Client secret expiry in seconds, or0for no expiry. Default:7776000(90 days). Public clients (token_endpoint_auth_method: 'none') have no secret.authorizationCodeLifetime: (optional) Authorization code lifetime in seconds. Default:300.strictResource: (optional) Validate the RFC 8707resourceparameter on authorize requests. Default:true.modifyAuthorizationRedirectUrl: (optional) Mutate the consent redirect URL (e.g. add client display hints as query parameters).errorHandler: (optional) Hook for logging or handling errors inside OAuth flows.dynamicClientRegistration: (optional) Enable RFC 7591/register. Default:true. Construction fails if enabled andmodel.registerClientis missing. Note: the MCP spec deprecates Dynamic Client Registration in favor of CIMD; keep it enabled for backwards compatibility with clients that do not support CIMD.clientIdMetadataDocuments: (optional) Enable Client ID Metadata Documents - passtrueor aClientIdMetadataDocumentOptionsobject. Default:false.grantTypes: (optional) Enabled grants. Default:['authorization_code', 'refresh_token']. Add'client_credentials'and/orDEVICE_AUTHORIZATION_GRANT_TYPEas needed.deviceAuthorizationUrl: (optional) Page URL where the user enters the user code (RFC 8628). Required together with the device grant ongrantTypes.deviceAuthorizationLifetime: (optional) Device code lifetime in seconds. Default:900.devicePollIntervalSeconds: (optional) Minimum poll interval returned to clients while authorization is pending. Default:5.
OAuthServerModel
Storage interface for clients, codes, tokens, and (when enabled) device authorization records.
import type { OAuthServerModel, AccessToken } from 'mcp-oauth-server';
export class PostgresModel implements OAuthServerModel {
async getClient(clientId: string) {
return await this.db.loadClient(clientId);
}
async saveAccessToken(accessToken: AccessToken): Promise<void> {
await this.db.saveAccessToken(accessToken);
}
async getAccessToken(token: string): Promise<AccessToken | undefined> {
return await this.db.getAccessToken(token);
}
// Implement the remaining methods required for your enabled grant types:
// authorization codes, refresh tokens, revocation, optional registration,
// and device authorization helpers when using the device grant.
}
Methods
getClient: (required) Resolve a registered client by id.registerClient: (required ifdynamicClientRegistrationis true) Persist dynamic registration.- Authorization code grant:
saveAuthorizationCode,getAuthorizationCode,revokeAuthorizationCodewhenauthorization_codeis enabled. - Device grant:
saveDeviceAuthorization,getDeviceAuthorizationByDeviceCode,getDeviceAuthorizationByUserCode,deleteDeviceAuthorizationwhen the device grant is enabled. - CIMD:
saveClientIdMetadataDocument,getClientIdMetadataDocumentwhenclientIdMetadataDocumentsis enabled. - Tokens:
saveAccessToken,getAccessToken,revokeAccessToken,saveRefreshToken,getRefreshToken,revokeRefreshToken.
mcpAuthRouter
Express middleware that mounts OAuth authorization server routes and .well-known metadata.
import express from 'express';
import { mcpAuthRouter } from 'mcp-oauth-server';
const app = express();
app.use(
mcpAuthRouter({
provider: oauthServer,
baseUrl: new URL('http://localhost:3000/oauth'),
resourceServerUrl: new URL('http://localhost:3000/mcp'),
scopesSupported: ['mcp:tools'],
}),
);
provider: TheOAuthServerinstance. ItsissuerUrlis used as the issuer identifier in the advertised metadata.baseUrl: Optional AS URL base for OAuth endpoints (defaults to the provider'sissuerUrl).resourceServerUrl: Resource server URL for protected-resource metadata.
Endpoints (paths are relative to where you mount the router and to baseUrl / issuer pathname):
/.well-known/oauth-authorization-serverand path-specific protected-resource metadata (RFC 8414 / RFC 9728)/authorizewhenauthorization_codeis ingrantTypes/token- authorization code, refresh token, client credentials, and device code exchange (according tograntTypes)/devicewhen the device grant is enabled anddeviceAuthorizationUrlis set/registerwhendynamicClientRegistrationis true/revoke- token revocation (RFC 7009)
Install at the application root (see src/router.ts).
authenticateHandler
Handles user consent completion after your consent UI (authorization code flow).
import { authenticateHandler } from 'mcp-oauth-server';
app.post(
'/confirm',
authenticateUserMiddleware(),
authenticateHandler({
provider: oauthServer,
getUser: async (req) => {
return req.session?.userId;
},
rateLimit: {
windowMs: 15 * 60 * 1000,
max: 100,
},
}),
);
Options
provider: (required)OAuthServerinstance.getUser: (required) Returns the authenticated user id (string or promise).rateLimit: (optional)express-rate-limitoptions, orfalseto disable.
requireBearerAuth
Validates Authorization: Bearer tokens for protected routes (for example your MCP HTTP endpoint).
import { getOAuthProtectedResourceMetadataUrl, requireBearerAuth } from 'mcp-oauth-server';
const mcpUrl = new URL('http://localhost:3000/mcp');
app.post(
'/mcp',
requireBearerAuth({
verifier: oauthServer,
requiredScopes: ['mcp:tools'],
resourceMetadataUrl: getOAuthProtectedResourceMetadataUrl(mcpUrl),
resource: mcpUrl,
}),
async (req, res) => {
const clientId = req.auth!.clientId;
const userId = req.auth!.userId;
// Handle MCP request…
},
);
verifier: Token verifier, typically theOAuthServerinstance.requiredScopes: (optional) Scopes the token must include; missing scopes yield403with aninsufficient_scopechallenge.resourceMetadataUrl: (optional) Protected resource metadata URL advertised inWWW-Authenticatechallenges (RFC 9728).resource: (optional) Canonical RFC 8707 resource identifier of this endpoint. When set, tokens must carry a matching resource (token audience validation); tokens without one are rejected with401.OAuthServer.verifyAccessTokenalready enforces this whenresourceServerUrlis configured - set it here when using a custom verifier or protecting multiple resources.
See src/middleware/bearerAuth.ts for details.
After successful authentication, req.auth contains:
token: Raw access token stringclientId: OAuth client idscopes: Granted scopesuserId: Present for user-centric grants; absent for pure client-credentials tokens
No comments yet
Be the first to share your take.