Render Tutorials
Build and host a full-featured, secure MCP server on Render

Replace the template's bearer token with OAuth

⏱ 18 min

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:

PieceRole
GitHubUpstream identity provider. You never see passwords; GitHub tells the server who the user is.
Authorization serverImplements OAuth 2.1: dynamic client registration, /authorize, /token, JWT signing. Lives in the same Express process.
/mcp resource serverThe thing the template gave you. New gate: every request must carry a valid Bearer token.
MCP clientDrives 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/developersOAuth AppsNew OAuth App.

FieldValue
Application namenotes-mcp (local)
Homepage URLhttp://localhost:10000
Authorization callback URLhttp://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:

.env
DATABASE_URL=postgres://notes:notes@localhost:5432/notes
PUBLIC_URL=http://localhost:10000
GITHUB_CLIENT_ID=Iv1.xxxxxxxxxxxxxxxx
GITHUB_CLIENT_SECRET=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
JWT_SIGNING_SECRET=run-once-then-paste-here

Generate 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:

Terminal
npm install jose dotenv
Terminal
pnpm add jose dotenv
Terminal
yarn add jose dotenv
PackageWhy
joseSign and verify JWTs. Modern, audited, has the cleanest API for ES256/HS256.
dotenvLoad .env in dev. Render injects env vars natively in prod.

Extend src/config.ts from step 4:

src/config.ts
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.

src/auth/provider.ts
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:

ChoiceWhy
HS256 JWTs signed by a 32-byte secretSymmetric, 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 TTLThe spec says short and one-use. Delete on exchange and set a timer as backstop.
Pending map keyed by GitHub stateSurvives 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:

  1. mcpAuthRouter(...) - mounts the OAuth metadata + protocol endpoints (public).
  2. A GitHub callback handler at /oauth/github/callback (public).
  3. requireBearerAuth(...) - middleware applied to /mcp only.

Here’s the exact diff for the auth section of src/app.ts:

src/app.ts (template)
- 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);
});
src/app.ts (with OAuth)
 
+ 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.

BitWhat 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.

  1. The inspector hits /.well-known/oauth-protected-resource It learns the authorization server is at http://localhost:10000.
  2. It hits /.well-known/oauth-authorization-server Reads the discovery doc, sees PKCE is required.
  3. It POSTs to /register Gets back a fresh client_id. Dynamic registration means the inspector didn’t need any pre-configured credentials.
  4. It opens a browser window to /authorize Which redirects to GitHub.
  5. 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’s redirect_uri with the authorization code.
  6. The inspector POSTs /token with the code + verifier Gets back a signed JWT.
  7. 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:

The MCP spec requires the access token's `aud` (audience) claim to match the resource server's URL (here, `http://localhost:10000/mcp`). What attack does that prevent?

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