How to Build an MCP Server with OAuth
Protect a remote MCP server with OAuth: metadata discovery, PKCE, token validation, scopes, testing, troubleshooting and runnable TypeScript.
Short answer: protect a remote HTTP MCP server as an OAuth resource server. Publish Protected Resource Metadata, direct clients to an authorization server, use authorization-code flow with PKCE, and validate every bearer token for issuer, expiry, audience, resource binding, and scopes before dispatching an MCP request. Your MCP server does not need to issue tokens itself.
This guide follows the 2026-07-28 MCP authorization specification. OAuth is primarily for HTTP transports. A local stdio server normally receives credentials from its environment instead.
What you are building
The trust boundary has two services:
- Authorization server: your identity provider or a server you operate. It authenticates users and issues access tokens.
- MCP resource server: your HTTP MCP endpoint. It publishes metadata and validates tokens. It does not have to mint them.
The client discovers resource metadata, learns which authorization server can issue a token, obtains consent, and sends Authorization: Bearer … to the MCP endpoint.
Prerequisites and design decisions
- Use HTTP when clients connect remotely. Keep stdio credentials local.
- List sensitive tools and resources. Decide whether authorization applies to the whole server or only selected tools.
- Choose an existing OIDC/OAuth provider when it supports discovery, PKCE, and your registration policy. A self-operated authorization server adds key rotation, consent, registration, and incident-response work.
- Choose one canonical resource identifier, such as
https://mcp.example.com. Use it consistently in metadata, authorization requests, token requests, and validation.
How the OAuth flow works
- The client calls the protected MCP endpoint without a token.
- The server returns an authentication challenge pointing to its RFC 9728 Protected Resource Metadata document.
- The client fetches metadata, selects an authorization server, and reads that server’s authorization and token metadata.
- The client creates a PKCE verifier and challenge, includes the
resourceparameter, and completes authorization-code exchange. - The client calls MCP with the access token.
- The server validates signature or introspection, issuer, expiry, audience/resource binding, and required scopes before invoking a tool.
Read the authorization specification and security guidance beside your implementation.
Minimal TypeScript resource server
This example uses Express and jose to protect an MCP-style JSON endpoint. It assumes JWT access tokens and a provider JWKS endpoint. For opaque tokens, replace JWT verification with token introspection.
npm init -y
npm install express jose
npm install -D typescript tsx @types/express @types/node
import express from "express";
import { createRemoteJWKSet, jwtVerify, type JWTPayload } from "jose";
const app = express();
app.use(express.json());
const PORT = Number(process.env.PORT ?? 8787);
const RESOURCE = process.env.MCP_RESOURCE ?? "https://mcp.example.com";
const ISSUER = process.env.OAUTH_ISSUER!;
const JWKS = createRemoteJWKSet(new URL(`${ISSUER}/.well-known/jwks.json`));
app.get("/.well-known/oauth-protected-resource", (_req, res) => {
res.json({
resource: RESOURCE,
authorization_servers: [ISSUER],
scopes_supported: ["mcp:read", "mcp:write"]
});
});
function bearer(req: express.Request) {
const value = req.header("authorization") ?? "";
return value.match(/^Bearer\\s+(.+)$/i)?.[1];
}
async function requireToken(req: express.Request, res: express.Response, next: express.NextFunction) {
const token = bearer(req);
if (!token) {
res.set("WWW-Authenticate", `Bearer resource_metadata="${RESOURCE}/.well-known/oauth-protected-resource"`);
return res.status(401).json({ error: "unauthorized" });
}
try {
const { payload } = await jwtVerify(token, JWKS, {
issuer: ISSUER,
audience: RESOURCE
});
(req as any).auth = payload;
next();
} catch {
res.set("WWW-Authenticate", "Bearer error=\"invalid_token\"");
return res.status(401).json({ error: "invalid_token" });
}
}
function hasScope(payload: JWTPayload, wanted: string) {
return typeof payload.scope === "string" && payload.scope.split(/\\s+/).includes(wanted);
}
app.post("/mcp", requireToken, (req, res) => {
const auth = (req as any).auth as JWTPayload;
if (req.body?.method === "tools/call" && !hasScope(auth, "mcp:write")) {
return res.status(403).json({ error: "insufficient_scope", scope: "mcp:write" });
}
// Dispatch through your MCP SDK or transport here.
// Never forward this token to another API.
return res.json({ jsonrpc: "2.0", id: req.body?.id, result: { ok: true } });
});
app.listen(PORT, () => console.log(`MCP resource listening on :${PORT}`));
Set OAUTH_ISSUER, MCP_RESOURCE, and PORT, then run npx tsx server.ts. The exact well-known URL depends on the resource path; construct it according to RFC 9728 and the current MCP specification.
Client authorization-code flow with PKCE
Your MCP client or companion application should discover the authorization server, use authorization-code flow, and send the resulting token. Use your client SDK for redirect handling and secure token storage.
const resource = "https://mcp.example.com";
const metadata = await fetch(`${resource}/.well-known/oauth-protected-resource`).then(r => r.json());
const as = metadata.authorization_servers[0];
const verifier = randomUrlSafe(32);
const challenge = base64url(sha256(verifier));
const state = randomUrlSafe(24);
const authorize = new URL(`${as}/authorize`);
authorize.search = new URLSearchParams({
client_id: CLIENT_ID,
response_type: "code",
redirect_uri: REDIRECT_URI,
scope: "mcp:read mcp:write",
state,
code_challenge: challenge,
code_challenge_method: "S256",
resource
}).toString();
// Redirect the user. Verify state on callback.
// Exchange the code with code_verifier and resource.
Read authorization-server metadata before using PKCE. When S256 is supported, use it. Do not silently downgrade when the server does not advertise a method your client can safely use.
Registration: CIMD and DCR
The current specification prefers Client ID Metadata Documents (CIMD). Dynamic Client Registration (DCR) remains for backward compatibility. Select the method supported by both your target clients and provider, document compatibility paths, and validate redirect URIs exactly. Do not assume every MCP client implements the same registration flow.
Scopes and per-tool authorization
Authentication identifies the token holder; authorization decides what that holder may do. Define scopes such as mcp:read and mcp:write, map them to tools, and reject missing scopes according to your SDK and specification version. Server-wide middleware is the baseline; handler-level checks add defense in depth. Per-tool patterns described by MCP Apps are implementation guidance, not a universal rule for every MCP stack.
Security checklist
- Validate signature or introspection, issuer, expiry, not-before, audience, and resource binding.
- Require the token’s intended audience to be this MCP server. A trusted issuer alone is insufficient.
- Never forward an MCP access token to a downstream API unless it was intentionally issued for that downstream audience through delegation or token exchange.
- Use HTTPS, exact redirect URI matching, state, and PKCE.
- Return
401for missing or invalid credentials and403for insufficient permission, following your SDK’s semantics. - Keep keys, client secrets, refresh tokens, and authorization codes out of logs.
- Rotate signing keys, cache JWKS, and refresh on an unknown key ID.
- Rate-limit expensive tools and audit subject, client, tool, scope, and outcome without storing bearer tokens.
Testing plan
- Fetch metadata anonymously and verify the canonical
resource, authorization-server list, and scopes. - Check that the unauthenticated challenge points to the exact metadata document.
- Test unknown state, wrong redirect URI, and failed PKCE.
- Call MCP with expired, malformed, wrong-issuer, wrong-audience, and insufficient-scope tokens.
- Confirm a read token cannot call write tools.
- Confirm downstream requests use separate credentials.
- Repeat with every MCP client and identity provider you intend to support. There is no universal interoperability matrix.
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
| Client never starts OAuth | Missing or malformed metadata or challenge | Return the metadata URL in WWW-Authenticate; verify JSON and HTTPS. |
invalid_audience |
Token was issued for another API | Send the same canonical resource in authorization and token requests and validate aud. |
| PKCE mismatch | Verifier was lost or encoded incorrectly | Persist the verifier per attempt; use unpadded base64url and S256. |
| 401 after key rotation | Stale JWKS cache | Refresh JWKS once on an unknown kid; keep a bounded cache. |
| 403 on a valid token | Required scope is absent | Request and grant the scope, then enforce the same name in the handler. |
| Works in one client only | Registration or redirect capability differs | Compare CIMD/DCR support, metadata parsing, redirect URI, and resource handling. |
| Downstream API rejects calls | MCP token was passed through | Use a separate service credential or documented exchange/delegation. |
Performance, reliability, and cost
- Cache provider discovery and JWKS according to HTTP cache headers; refresh early enough to survive key rotation.
- JWT verification is local after key retrieval. Opaque-token introspection adds a network round trip, so use bounded timeouts and a short cache only when your threat model allows it.
- Run authorization middleware before expensive tool execution. Set request, provider, and downstream timeouts.
- Make tool calls idempotent where retries are possible.
- Log decision metadata, latency, and correlation IDs. Redact tokens and authorization codes.
- OAuth costs come from your identity provider, hosting, and downstream APIs. MCP defines no universal price or uptime guarantee.
Or skip the browser setup
If your MCP agent needs website screenshots, ScreenshotNeo provides a single authenticated HTTP call and an MCP server. Cookie and consent banners are accepted, then 60+ known consent platforms, newsletter popups, and chat widgets are removed before capture. Bot checks, blank pages, timeouts, failed loads, and cache hits are not billed, and response headers report the page verdict and billing result. AI clients can use take_screenshot, get_page_info, and capture_pdf.
curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://stripe.com -o shot.webp
import requests
r = requests.get("https://api.screenshotneo.com/v1/shot", params={"access_key": "YOUR_API_KEY", "url": "https://stripe.com"}, timeout=90)
open("shot.webp", "wb").write(r.content)
const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://stripe.com' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);
See the ScreenshotNeo API documentation for the 63 capture options, signed webhooks, and MCP setup. The free plan includes 1,000 screenshots per month with no card; paid plans start at $5 for 3,000. Create a free ScreenshotNeo account.
FAQ
Does an MCP server need its own OAuth server?
No. It can trust an external authorization server, publish that server in resource metadata, and validate the resulting tokens.
Is OAuth required for every MCP server?
No. The authorization specification covers HTTP authorization. A local stdio server commonly uses environment credentials.
Can I accept any token from my identity provider?
No. Validate issuer, cryptographic validity, lifetime, audience/resource, and scopes for each operation.
Should I use CIMD or DCR?
Prefer CIMD where the current client and provider support it. Keep DCR for older combinations and test both paths.
Can I reuse the MCP token for another API?
Not by default. Obtain a credential intended for that downstream audience through deliberate delegation or token exchange.


