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.
- 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.
- When to use this package
- Installation
- Features
- OAuth client credentials
- Device authorization (RFC 8628)
- Client ID Metadata Documents (CIMD)
- Quick Start
- API Reference
npm install mcp-oauth-server@latest --save-exact- 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.
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.
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 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.
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.
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.
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.
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).
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.
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