This episode secures an MCP server with OAuth 2.1: the separation of resource server and authorization server roles, the Mcp-Authorization header, refresh tokens, scope accumulation for step-up auth, DPoP bound tokens, and SSRF and CRLF mitigation on server URLs.

Episode 9 deployed the MCP server to HTTP and unlocked horizontal scaling. But a public endpoint without authentication is an open door — anyone can call your tools, including dangerous ones. This episode closes that gap with OAuth 2.1 Authorization plus the security practices you must understand before a server touches production.
Roadmap: understand the two main roles (resource server and authorization server), the authorization code flow with PKCE, the use of the Mcp-Authorization header and refresh tokens, step-up authorization with scope accumulation, DPoP bound tokens, then SSRF and CRLF mitigation on server URLs.
OAuth 2.1 divides responsibility between two entities:
The common flow: the host application directs the user to the authorization server to log in, obtains an authorization code, then exchanges it for an access token. This token is then sent to the MCP server on every request. The MCP server doesn't need to understand the user's password — it just validates the token it receives.
Warning
Don't store client secrets in frontend code. The authorization code flow with PKCE keeps the code_verifier on the host application side; client secrets for public applications must not leak to the browser.
The flow recommended by OAuth 2.1 is Authorization Code + PKCE. Before directing the user, the application creates a code_verifier (a random string) and a code_challenge (its hash). After the user logs in and approves the scopes, the authorization server returns a code; the application exchanges it for a token while sending the code_verifier as proof of possession.
curl -X POST https://auth.example.com/oauth/token \
-H "Content-Type: application/x-www-form-urlencoded" \
--data-urlencode "grant_type=authorization_code" \
--data-urlencode "code=abc123" \
--data-urlencode "redirect_uri=https://host.example.com/callback" \
--data-urlencode "code_verifier=verifier-rahasia"PKCE prevents authorization code interception attacks: without the correct code_verifier, a stolen code can't be exchanged for a token. Replay attacks are also mitigated because every code is valid only once and is very short-lived.
Once you have an access token, every request to the MCP server carries the token in the Mcp-Authorization header:
curl https://mcp.example.com/mcp \
-H "Content-Type: application/json" \
-H "Mcp-Authorization: Bearer access-token-123" \
--data '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'Mcp-Authorization is the MCP-specific header read by the resource server to extract the token — its separation from regular Authorization prevents conflicts when MCP is embedded in an application that already uses OAuth for its own API.
Access tokens are usually short-lived (e.g. 15 minutes). When one expires, the application doesn't need to force the user to log in again — it just exchanges a valid refresh token for a new access token. The refresh token is stored securely by the host application and rotated each time it's used; never expose it to the resource server.
Sometimes a request needs a higher permission than what was already approved — for example transfer-funds may only be executed after the user reconfirms. OAuth 2.1 handles this via scope accumulation: the application submits an additional authorization request with more specific scopes, the user approves, and the new token combines the old scopes with the new ones.
This step-up pairs perfectly with MRTR from episode 8: when the MCP server replies pending because it needs additional permission, the host runs the OAuth step-up flow, then continues the request with the same messageId and a new token. The user experience: a single "allow fund transfer" dialog appears exactly when needed — not at the start of the session.
Plain (bearer) tokens are "whoever holds them uses them" — if a token leaks in logs or a proxy, an attacker can use it. Bound tokens (e.g. via DPoP, RFC 9449) bind the token to a public key owned by the client: every request must include proof of possession of the matching private key, so a stolen token without the private key is useless.
const headers = {
Authorization: `DPoP ${accessToken}`,
DPoP: dpopProof,
};dpopProof is a JWS signing the combination of the HTTP method, URL, and a random jti. DPoP significantly raises the security bar for tokens passing through many hops. For production MCP servers handling sensitive data, it's worth making a requirement.
An MCP server that accepts URLs from users — for example a "fetch page" tool — is vulnerable to SSRF (Server-Side Request Forgery): an attacker tells the server to access localhost or internal metadata services. Validate strictly before connecting:
function sanitizeUrl(raw: string): string {
const url = new URL(raw);
if (url.protocol !== "https:") {
throw new Error("Hanya URL https yang diizinkan");
}
if (/[\r\n]/.test(raw) || /%0d|%0a/i.test(raw)) {
throw new Error("CRLF terdeteksi pada URL");
}
return url.toString();
}new URL(raw) parses the input into separate components, making validation easier. Protections to put in place:
https, reject http and other schemes.localhost, private ranges (e.g. 10.x, 172.16-31.x, 192.168.x), and link-local.\r\n characters (or %0d/%0a encodings) for HTTP header injection.Episode 10 closes the access security side: the OAuth 2.1 model with resource server and authorization server separation, the PKCE flow, the Mcp-Authorization header, refresh tokens, scope accumulation for step-up auth, bound tokens/DPoP, and SSRF and CRLF mitigation.
Key takeaways:
code_verifier must always be filtered from logging output.In the next episode 11 we explore the official MCP Apps & UI Resources extension — tools that don't just return text, but also interactive interfaces rendered by the host in a sandboxed iframe. See you there!