This is the longest step in the tutorial, and the most important. Everything else is mechanical; this is where the server stops being “just for me” and becomes a multi-user service.
The template’s README is explicit about this: “For multi-user or production setups, consider upgrading to OAuth 2.1.” That’s what you do now.
Why bother - and what the spec actually requires
The MCP spec requires OAuth 2.1 for remote servers. The reasoning is the same one you saw in step 1 - a shared bearer token is fine until you need to revoke one client without breaking the rest, audit who called what, or scope a token to a single user. OAuth solves all three out of the box.
sequenceDiagram
participant C as MCP client
participant AS as Authorization server<br/>(same process)
participant GH as GitHub
participant RS as /mcp Resource
C->>RS: GET /.well-known/oauth-protected-resource
RS-->>C: { authorization_servers: ["https://notes-mcp.../"] }
C->>AS: GET /.well-known/oauth-authorization-server
AS-->>C: discovery doc (endpoints, PKCE required,...)
C->>AS: POST /register (dynamic client registration)
AS-->>C: client_id
C->>AS: GET /authorize?...&code_challenge=...
AS->>GH: redirect to github.com/login/oauth/authorize
GH-->>C: redirect back with GitHub code
C->>AS: GET /oauth/github/callback?code=...
AS->>GH: exchange GitHub code -> GitHub user
AS-->>C: redirect with the authorization code
C->>AS: POST /token (code + code_verifier)
AS-->>C: access_token (signed JWT)
C->>RS: POST /mcp + Bearer access_token
RS-->>C: 200 (tool result)
Four moving pieces:
| Piece | Role |
|---|---|
| GitHub | Upstream identity provider. You never see passwords; GitHub tells the server who the user is. |
| Authorization server | Implements OAuth 2.1: dynamic client registration, /authorize, /token, JWT signing. Lives in the same Express process. |
/mcp resource server | The thing the template gave you. New gate: every request must carry a valid Bearer token. |
| MCP client | Drives the whole dance. The MCP Inspector and Claude Desktop both do this automatically once they hit the metadata. |
1. Register a GitHub OAuth App
Go to github.com/settings/developers → OAuth Apps → New OAuth App.
| Field | Value |
|---|---|
| Application name | notes-mcp (local) |
| Homepage URL | http://localhost:10000 |
| Authorization callback URL | http://localhost:10000/oauth/github/callback |
You’ll create a second app in step 7 for the deployed URL. Two apps, two sets of credentials - same pattern as any web app.
GitHub gives you a client ID. Generate a client secret. Add both to .env and .gitignore the file:
DATABASE_URL=postgres://notes:notes@localhost:5432/notesPUBLIC_URL=http://localhost:10000
GITHUB_CLIENT_ID=Iv1.xxxxxxxxxxxxxxxxGITHUB_CLIENT_SECRET=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
JWT_SIGNING_SECRET=run-once-then-paste-hereGenerate the JWT secret with openssl rand -hex 32 and paste it in. Same pattern Auth.js uses.
The template doesn’t depend on dotenv - it reads env vars directly. Add it for the local convenience:
npm install jose dotenvpnpm add jose dotenvyarn add jose dotenv| Package | Why |
|---|---|
jose | Sign and verify JWTs. Modern, audited, has the cleanest API for ES256/HS256. |
dotenv | Load .env in dev. Render injects env vars natively in prod. |
Extend src/config.ts from step 4:
import "dotenv/config";
function required(name: string): string { const value = process.env[name]; if (!value) throw new Error(`Missing required env var: ${name}`); return value;}
export const config = { port: Number(process.env.PORT ?? 10000), publicUrl: required("PUBLIC_URL"), databaseUrl: required("DATABASE_URL"), github: { clientId: required("GITHUB_CLIENT_ID"), clientSecret: required("GITHUB_CLIENT_SECRET"), }, jwtSecret: required("JWT_SIGNING_SECRET"),};PUBLIC_URL is the URL the client sees. In dev it’s http://localhost:10000; in prod it’s your Render URL. Every OAuth metadata doc and every redirect URL the server generates is anchored on this value.
2. The OAuth provider
The MCP TypeScript SDK ships an OAuthServerProvider interface plus an mcpAuthRouter helper that mounts the metadata and protocol endpoints (/authorize, /token, /register, the well-knowns). You implement the provider; the SDK handles the wire format.
A real production provider would back its client and token stores with Postgres. For the tutorial, use in-memory Maps - they reset on restart, which is fine for a single dev box. The tutorial calls this out and revisit in step 9.
import { randomUUID } from "node:crypto";import { SignJWT, jwtVerify } from "jose";import type { OAuthServerProvider, AuthorizationParams,} from "@modelcontextprotocol/sdk/server/auth/provider.js";import type { OAuthClientInformationFull, OAuthTokens,} from "@modelcontextprotocol/sdk/shared/auth.js";import type { AuthInfo } from "@modelcontextprotocol/sdk/server/auth/types.js";import type { Response } from "express";
import { config } from "../config.js";
type PendingAuthorization = { clientId: string; redirectUri: string; codeChallenge: string; scopes: string[]; state?: string; githubState: string; user?: { login: string; id: number };};
const clients = new Map<string, OAuthClientInformationFull>();const pending = new Map<string, PendingAuthorization>();const codes = new Map<string, PendingAuthorization>();const jwtKey = new TextEncoder().encode(config.jwtSecret);const ISSUER = config.publicUrl;const TOKEN_TTL_SECONDS = 60 * 60;
async function mintAccessToken(user: { login: string; id: number }, clientId: string, scopes: string[]): Promise<string> { return new SignJWT({ scope: scopes.join(" "), client_id: clientId, github_login: user.login }) .setProtectedHeader({ alg: "HS256" }) .setIssuer(ISSUER) .setAudience(`${ISSUER}/mcp`) .setSubject(`github:${user.id}`) .setIssuedAt() .setExpirationTime(`${TOKEN_TTL_SECONDS}s`) .setJti(randomUUID()) .sign(jwtKey);}
export const provider: OAuthServerProvider = { clientsStore: { async getClient(clientId) { return clients.get(clientId); }, async registerClient(client) { const full: OAuthClientInformationFull = { ...client, client_id: client.client_id ?? randomUUID(), client_id_issued_at: Math.floor(Date.now() / 1000), }; clients.set(full.client_id, full); return full; }, },
async authorize(client, params: AuthorizationParams, res: Response) { const githubState = randomUUID(); pending.set(githubState, { clientId: client.client_id, redirectUri: params.redirectUri, codeChallenge: params.codeChallenge, scopes: params.scopes ?? [], state: params.state, githubState, }); const url = new URL("https://github.com/login/oauth/authorize"); url.searchParams.set("client_id", config.github.clientId); url.searchParams.set("redirect_uri", `${config.publicUrl}/oauth/github/callback`); url.searchParams.set("scope", "read:user"); url.searchParams.set("state", githubState); res.redirect(url.toString()); },
async challengeForAuthorizationCode(_client, authorizationCode) { const entry = codes.get(authorizationCode); if (!entry) throw new Error("Unknown authorization code"); return entry.codeChallenge; },
async exchangeAuthorizationCode(client, authorizationCode, _codeVerifier) { const entry = codes.get(authorizationCode); if (!entry || entry.clientId !== client.client_id || !entry.user) { throw new Error("Invalid authorization code"); } codes.delete(authorizationCode); const accessToken = await mintAccessToken(entry.user, client.client_id, entry.scopes); return { access_token: accessToken, token_type: "Bearer", expires_in: TOKEN_TTL_SECONDS, scope: entry.scopes.join(" ") }; },
async exchangeRefreshToken(): Promise<OAuthTokens> { throw new Error("Refresh tokens not implemented for this tutorial"); },
async verifyAccessToken(token): Promise<AuthInfo> { const { payload } = await jwtVerify(token, jwtKey, { issuer: ISSUER, audience: `${ISSUER}/mcp` }); return { token, clientId: payload.client_id as string, scopes: ((payload.scope as string) ?? "").split(" ").filter(Boolean), expiresAt: payload.exp, extra: { github_login: payload.github_login as string, sub: payload.sub as string }, }; },};
export function completeGithubCallback(githubState: string, user: { login: string; id: number }): { redirectUri: string; code: string; state?: string } { const entry = pending.get(githubState); if (!entry) throw new Error("Unknown GitHub state"); pending.delete(githubState); entry.user = user; const code = randomUUID(); codes.set(code, entry); setTimeout(() => codes.delete(code), 5 * 60 * 1000); return { redirectUri: entry.redirectUri, code, state: entry.state };}The key design choices:
| Choice | Why |
|---|---|
| HS256 JWTs signed by a 32-byte secret | Symmetric, fast, and this service is the only verifier. Upgrade to ES256 + JWKS when a second service needs to verify tokens. |
audience: "$PUBLIC_URL/mcp" | Token is scoped to this resource server. A token minted here can’t be replayed against an unrelated MCP server that happens to share the signing key. |
| 5-minute authorization-code TTL | The spec says short and one-use. Delete on exchange and set a timer as backstop. |
| Pending map keyed by GitHub state | Survives the GitHub redirect round-trip. Without this, the server would lose the original client’s redirect_uri and code_challenge. |
3. Replace the template’s bearer middleware in src/app.ts
The template has a single app.use(...) block that checks MCP_API_TOKEN. Replace that block with three more targeted pieces:
mcpAuthRouter(...)- mounts the OAuth metadata + protocol endpoints (public).- A GitHub callback handler at
/oauth/github/callback(public). requireBearerAuth(...)- middleware applied to/mcponly.
Here’s the exact diff for the auth section of src/app.ts:
- import { timingSafeEqual } from "node:crypto";- const MCP_API_TOKEN = process.env.MCP_API_TOKEN;- app.use((req, res, next) => {- if (req.path === "/health" || !MCP_API_TOKEN) return next();- const auth = req.headers.authorization ?? "";- const expected = `Bearer ${MCP_API_TOKEN}`;- if (auth.length === expected.length &&- timingSafeEqual(Buffer.from(auth), Buffer.from(expected))) {- return next();- }- res.status(401).json({- jsonrpc: "2.0",- error: { code: -32001, message: "Unauthorized" },- id: null,});});app.get("/health", (_req, res) => res.json({ status: "ok" }));- app.post("/mcp", async (req, res) => {const server = createServer();const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });await server.connect(transport);await transport.handleRequest(req, res, req.body);});
+ import { mcpAuthRouter } from "@modelcontextprotocol/sdk/server/auth/router.js";+ import { requireBearerAuth } from "@modelcontextprotocol/sdk/server/auth/middleware/bearerAuth.js";+ import { provider, completeGithubCallback } from "./auth/provider.js";+ app.use(mcpAuthRouter({+ provider,+ issuerUrl: new URL(config.publicUrl),+ baseUrl: new URL(config.publicUrl),+ serviceDocumentationUrl: new URL(`${config.publicUrl}/docs`),+ }));+ app.get("/oauth/github/callback", async (req, res, next) => {+ try {+ const code = String(req.query.code ?? "");+ const state = String(req.query.state ?? "");+ if (!code || !state) return res.status(400).send("Missing code or state");+ const tokenResp = await fetch("https://github.com/login/oauth/access_token", {+ method: "POST",+ headers: { Accept: "application/json", "Content-Type": "application/json" },+ body: JSON.stringify({+ client_id: config.github.clientId,+ client_secret: config.github.clientSecret,+ code,+ }),+ });+ const { access_token: ghToken } = (await tokenResp.json()) as { access_token: string };++ const userResp = await fetch("https://api.github.com/user", {+ headers: { Authorization: `Bearer ${ghToken}`, Accept: "application/vnd.github+json" },+ });+ const user = (await userResp.json()) as { login: string; id: number };++ const { redirectUri, code: ourCode, state: ourState } = completeGithubCallback(state, user);+ const url = new URL(redirectUri);+ url.searchParams.set("code", ourCode);+ if (ourState) url.searchParams.set("state", ourState);+ res.redirect(url.toString());+ } catch (err) { next(err); }});++ const bearerAuth = requireBearerAuth({+ verifier: { verifyAccessToken: provider.verifyAccessToken.bind(provider) },+ resourceMetadataUrl: `${config.publicUrl}/.well-known/oauth-protected-resource`,});app.get("/health", (_req, res) => res.json({ status: "ok" }));+ app.post("/mcp", bearerAuth, async (req, res) => {const server = createServer();const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });await server.connect(transport);await transport.handleRequest(req, res, req.body);});
The only line that changes inside app.post("/mcp",...) is adding bearerAuth as the second argument. The handler body that creates the per-request transport stays exactly as the template wrote it. /health stays public.
| Bit | What it does |
|---|---|
mcpAuthRouter({ provider, issuerUrl, baseUrl }) | Mounts /.well-known/oauth-authorization-server, /.well-known/oauth-protected-resource, /authorize, /token, and /register for you. Reads what’s possible from the provider you pass. |
requireBearerAuth({ verifier, resourceMetadataUrl }) | Express middleware. Rejects with 401 WWW-Authenticate: Bearer resource_metadata=... when no/bad token. The header is what bootstraps the MCP client into the OAuth flow. |
req.auth (after requireBearerAuth) | The AuthInfo the verifier returned. Tool handlers can read it via the extra argument the SDK threads through. |
4. Test the full flow with the MCP Inspector
Rebuild and restart. Open the inspector and connect to http://localhost:10000/mcp again - only this time it’ll detect the 401 + WWW-Authenticate header and switch into OAuth mode.
- The inspector hits /.well-known/oauth-protected-resource It learns the authorization server is at
http://localhost:10000. - It hits /.well-known/oauth-authorization-server Reads the discovery doc, sees PKCE is required.
- It POSTs to /register Gets back a fresh
client_id. Dynamic registration means the inspector didn’t need any pre-configured credentials. - It opens a browser window to /authorize Which redirects to GitHub.
- You approve in GitHub GitHub redirects back to
/oauth/github/callback. The handler swaps the GitHub code for the user, then redirects to the inspector’sredirect_uriwith the authorization code. - The inspector POSTs /token with the code + verifier Gets back a signed JWT.
- It retries /mcp with `Authorization: Bearer...` Now it gets through, and the rest of the session is normal.
The whole dance takes ~3 seconds in the UI. If anything fails, the inspector shows you the exact request/response on each step.
Show hint
Stuck at the GitHub callback with “Bad verification code”? Either the GITHUB_CLIENT_SECRET is wrong or you’re hitting the /oauth/github/callback URL twice (e.g. a hot-reload restarted the server mid-flow and lost the in-memory state). Restart from the connect button in the inspector - it’ll do a fresh authorize.
5. Decode the access token
The token in the inspector’s network panel is a JWT. Decode it yourself first - paste it into jwt.io or run npx jose decode <token> - then check your answer:
{ "scope": "", "client_id": "5d4f72e9-...", "github_login": "your-username", "iss": "http://localhost:10000", "aud": "http://localhost:10000/mcp", "sub": "github:1234567", "iat": 1718812345, "exp": 1718815945, "jti": "85a1..."}That’s a real identity. sub is stable across sessions - you can pin notes to a user, audit calls, or revoke by listing tokens in your store and refusing any with that jti. The aud claim is what makes this token unforgeable across resource servers; you’ll answer a quiz on that in a second.
What you learned
- The template's shared `MCP_API_TOKEN` is fine for solo use; MCP requires OAuth 2.1 for multi-user remote servers
- You run an in-process authorization server using `mcpAuthRouter` and use GitHub as the upstream identity provider
- Access tokens are HS256 JWTs scoped to `aud: <PUBLIC_URL>/mcp` so they can't be replayed against other resource servers
- `requireBearerAuth` middleware gates `/mcp` only - the OAuth metadata endpoints stay public
- In-memory client and code stores are a tutorial shortcut - step 9 covers backing them with Postgres