Tuần đầu tôi setup OAuth resource server cho một MCP server private, tôi đã mất hai buổi tối chỉ vì redirect_uri sai. AS reject silent, Claude Desktop hiện popup auth rồi hang, không log gì cụ thể. Lúc nhận ra mình thiếu encode : trong URL, tôi vừa cười vừa muốn đập laptop. OAuth khó không phải vì concept khó, mà vì 12 chỗ nhỏ nhỏ phải chính xác cùng lúc.
Tôi đã thấy nhiều project tự nghĩ ra cơ chế auth riêng cho MCP server: API key trong header, basic auth, custom token JSON nhét vào body. Cách nào cũng dẫn tới một trong các lỗ hổng cổ điển: token bị log, token reuse, token không có scope, token không expire. Spec MCP 2025-06-18 chốt câu trả lời rất rõ. MCP server là OAuth 2.1 Resource Server, không phải Authorization Server. Mọi quyết định khác xoay quanh boundary đó. Tôi tin spec đúng ở chỗ này, và đây là bài tôi giải thích vì sao.
Bài này dài vì auth là chỗ dễ làm sai nhất. Tôi sẽ đi qua model role, discovery flow, DCR, resource indicator, token validation, lý do cấm token passthrough, code TypeScript chạy được, và checklist trước khi ship.
Vì sao MCP cần OAuth, không phải API key
MCP server theo định nghĩa expose tool có side effect (gửi message, tạo issue, query DB) và resource có thể nhạy cảm. Có ba khác biệt cốt lõi giữa “API key tự build” và OAuth Resource Server theo spec:
- Identity của user vs identity của client. Một API key đơn lẻ không phân biệt được “Claude Desktop của Alice” với “Cursor của Bob”. OAuth tách rõ resource owner (user) và client application.
- Scope per token. API key thường all-or-nothing. OAuth token có thể giới hạn
read:issuesthay vìrepo:admin. - Audience binding. API key chấp nhận ở bất kỳ endpoint nào biết key. OAuth token gắn cứng vào một resource server cụ thể, dùng nhầm endpoint sẽ bị reject.
Trước spec 2025-06-18, MCP có một bản auth sơ khai trong đó server vừa là Authorization Server (cấp token) vừa là Resource Server (validate token). Cộng đồng phản hồi rằng việc một server tự build authorization endpoint là quá khó để làm đúng. Spec 2025-06-18 tách hai vai trò: MCP server chỉ là Resource Server, authorization là việc của một Authorization Server riêng (có thể tự host, có thể dùng Auth0, Okta, Logto, Stytch, Curity, hoặc bất kỳ provider chuẩn nào).
Ba role trong model spec 2025-06-18
Trích thẳng từ spec authorization section:
A protected MCP server acts as an OAuth 2.1 resource server, capable of accepting and responding to protected resource requests using access tokens.
Ba role bạn cần nhớ:
| Role | Trách nhiệm | Ví dụ thực tế |
|---|---|---|
| MCP Client | Đại diện user, gọi resource request với access token | Claude Desktop, Claude Code, custom agent |
| MCP Server (Resource Server) | Validate token, serve tool/resource/prompt | Server bạn đang build |
| Authorization Server | Authenticate user, issue access token | Auth0, Logto, Keycloak, custom AS |
Authorization Server có thể nằm cùng host với Resource Server hoặc là entity hoàn toàn riêng biệt. Spec không bắt cố định, chỉ yêu cầu Resource Server phải chỉ ra (advertise) cho client biết Authorization Server nằm ở đâu.
Một điểm quan trọng: spec authorization chỉ áp dụng cho HTTP-based transport (tức Streamable HTTP). Với stdio transport, spec ghi rõ: “Implementations using an STDIO transport SHOULD NOT follow this specification, and instead retrieve credentials from the environment.” Lý do hợp lý: stdio chạy local subprocess, không có concept “request từ xa cần authenticate”.
Flow tổng quát: từ unauthenticated request tới token có audience
Đây là flow đầy đủ spec mô tả, vẽ lại bằng ASCII để bạn nắm tổng thể trước khi vào chi tiết từng bước:
+---------+ +-------------+ +-----------------+
| Client | | MCP Server | | Authorization |
| | | (RS) | | Server (AS) |
+----+----+ +------+------+ +--------+--------+
| | |
| 1. MCP request | |
| (no token) | |
+------------------------->| |
| | |
| 2. 401 Unauthorized | |
| WWW-Authenticate: | |
| resource_metadata=... | |
|<-------------------------+ |
| | |
| 3. GET /.well-known/ | |
| oauth-protected- | |
| resource | |
+------------------------->| |
| | |
| 4. Resource metadata | |
| (authorization_ | |
| servers: [AS_URL]) | |
|<-------------------------+ |
| | |
| 5. GET /.well-known/oauth-authorization-server |
+-------------------------------------------------------->|
| |
| 6. AS metadata (token_endpoint, registration, ...) |
|<--------------------------------------------------------+
| |
| 7. POST /register (DCR, optional) |
+-------------------------------------------------------->|
| |
| 8. client_id (+ client_secret nếu confidential) |
|<--------------------------------------------------------+
| |
| 9. Authorization request |
| + code_challenge (PKCE) |
| + resource=https://mcp.example.com |
+-------------------------------------------------------->|
| |
| (user authorize trên browser) |
| |
| 10. Redirect with authorization code |
|<--------------------------------------------------------+
| |
| 11. Token request + code_verifier + resource |
+-------------------------------------------------------->|
| |
| 12. Access token (aud=https://mcp.example.com) |
|<--------------------------------------------------------+
| | |
| 13. MCP request | |
| Authorization: | |
| Bearer <token> | |
+------------------------->| |
| | |
| | 14. Validate token |
| | (signature, aud, exp) |
| | |
| 15. MCP response | |
|<-------------------------+ |
| | |
Mười bốn bước. Nhiều, nhưng từng bước có vai trò rõ ràng. Chia ra theo cụm:
- Bước 1-4: Resource Server discovery. Client biết MCP server tồn tại nhưng chưa biết AS nào.
- Bước 5-8: Authorization Server discovery + DCR (Dynamic Client Registration).
- Bước 9-12: OAuth 2.1 authorization code flow với PKCE và Resource Indicator.
- Bước 13-15: Resource access, lặp lại với mỗi request.
Cụm 1-4 và cụm 5-8 thường chỉ chạy một lần per session. Cụm 13-15 lặp mỗi tool call.
RFC nào nằm dưới spec
Spec không reinvent. Nó pin một subset RFC sẵn có:
| RFC | Vai trò |
|---|---|
| OAuth 2.1 (draft-ietf-oauth-v2-1-13) | Base protocol, thay OAuth 2.0 + PKCE mandatory |
| RFC 8414 | Authorization Server Metadata (/.well-known/oauth-authorization-server) |
| RFC 9728 | OAuth 2.0 Protected Resource Metadata (/.well-known/oauth-protected-resource) |
| RFC 7591 | Dynamic Client Registration |
| RFC 8707 | Resource Indicators for OAuth 2.0 |
Hai cái mới nhất (9728, 8707) là chỗ MCP khác các use case OAuth phổ thông như “login with Google”. Phần lớn developer chưa quen 9728 và 8707 vì web app truyền thống ít dùng. Bài này tập trung giải thích đúng hai cái đó.
RFC 9728: Protected Resource Metadata
Spec MCP yêu cầu MCP servers MUST implement OAuth 2.0 Protected Resource Metadata. Cụ thể: server expose endpoint /.well-known/oauth-protected-resource trả JSON metadata chỉ ra Authorization Server nào hợp lệ cho resource này.
Ví dụ response:
{
"resource": "https://mcp.example.com",
"authorization_servers": [
"https://auth.example.com"
],
"scopes_supported": [
"mcp:tools:read",
"mcp:tools:write",
"mcp:resources:read"
],
"bearer_methods_supported": ["header"]
}
Client gọi endpoint này khi nhận WWW-Authenticate header trong response 401. Header có format:
WWW-Authenticate: Bearer resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource"
Spec ghi rõ: WWW-Authenticate là cách MCP server MUST dùng để báo client biết metadata URL. Đây là điểm khác biệt với cách “configure trước trong file config” mà nhiều OAuth flow legacy hay dùng. Client không cần biết trước URL, chỉ cần thử request không token, nhận 401, đọc header, lấy metadata.
RFC 8707: Resource Indicator, hay là chỗ token bị bind audience
Đây là phần nhiều người miss khi implement OAuth lần đầu cho MCP. Trích thẳng spec:
MCP clients MUST implement Resource Indicators for OAuth 2.0 as defined in RFC 8707 to explicitly specify the target resource for which the token is being requested.
Cụ thể: trong authorization request và token request, client phải thêm parameter resource chỉ vào canonical URI của MCP server muốn dùng token.
Ví dụ authorization URL:
https://auth.example.com/authorize?
response_type=code&
client_id=abc123&
redirect_uri=https%3A%2F%2Fclient.app%2Fcallback&
code_challenge=...&
code_challenge_method=S256&
state=xyz&
resource=https%3A%2F%2Fmcp.example.com&
scope=mcp%3Atools%3Aread
Authorization Server sẽ bind token vào audience https://mcp.example.com. Khi MCP server nhận token, nó MUST validate audience claim trùng với chính nó. Nếu không trùng, reject.
Canonical URI tuân RFC 8707 section 2: lowercase scheme và host, không có fragment, ưu tiên không có trailing slash. Ví dụ hợp lệ:
https://mcp.example.com/mcphttps://mcp.example.comhttps://mcp.example.com:8443
Không hợp lệ:
mcp.example.com(thiếu scheme)https://mcp.example.com#fragment(có fragment)
Tại sao audience binding quan trọng? Trả lời ở section “cấm token passthrough” dưới.
RFC 7591: Dynamic Client Registration
Truyền thống, OAuth client phải register trước với Authorization Server, được cấp client_id (và client_secret nếu confidential). Với hệ sinh thái MCP, điều này gãy: user cài Claude Desktop, gắn server mới https://mcp.example.com, client (Claude Desktop) chưa từng biết tới AS của server này. Bắt user đi đăng ký thủ công sẽ giết trải nghiệm.
RFC 7591 cho phép client tự register runtime: POST /register với metadata cơ bản (redirect_uris, grant_types, token_endpoint_auth_method), AS trả về client_id.
Spec MCP ghi: Authorization Server SHOULD support DCR. Không bắt buộc cứng, nhưng AS không support DCR sẽ buộc user phải tự register, friction tăng.
Ví dụ DCR request:
POST /register HTTP/1.1
Host: auth.example.com
Content-Type: application/json
{
"redirect_uris": ["https://claude.ai/oauth/callback"],
"client_name": "Claude Desktop",
"grant_types": ["authorization_code", "refresh_token"],
"response_types": ["code"],
"token_endpoint_auth_method": "none"
}
Response:
{
"client_id": "f8aac3d2",
"client_id_issued_at": 1716345600,
"client_name": "Claude Desktop",
"redirect_uris": ["https://claude.ai/oauth/callback"],
"token_endpoint_auth_method": "none"
}
Public client (như Claude Desktop, không có secure storage cho secret) dùng token_endpoint_auth_method: "none" và bù bằng PKCE mandatory.
Token validation: server MUST làm những gì
Phần này là chỗ developer thường làm thiếu. Spec liệt kê rõ:
- Validate signature: token JWT phải hợp lệ chữ ký từ AS đã được advertise.
- Validate audience (
audclaim): trùng với canonical URI của MCP server. - Validate expiration (
exp): token chưa hết hạn. - Validate issuer (
iss): trùng với AS đã advertise. - Validate scope: scope đủ cho operation đang request.
Nếu thiếu hoặc fail, MUST trả 401. Spec ghi: “Invalid or expired tokens MUST receive a HTTP 401 response.”
Ngoài ra, “MCP servers MUST NOT accept or transit any other tokens.” Câu này dẫn vào section quan trọng nhất.
Cấm token passthrough, lý do và hệ quả
Spec gọi đây là anti-pattern cấm tuyệt đối. Trích nguyên văn:
“Token passthrough” is an anti-pattern where an MCP server accepts tokens from an MCP client without validating that the tokens were properly issued to the MCP server and passes them through to the downstream API.
MCP servers MUST NOT accept any tokens that were not explicitly issued for the MCP server.
Cụ thể, có hai tình huống bị cấm:
- MCP server nhận token có
audkhông phải nó. Ví dụ token cấp chohttps://api.github.commà MCP server vẫn accept rồi process request. - MCP server forward nguyên token nhận từ client sang downstream API. Ví dụ Claude Desktop gửi token GitHub cho MCP server, server forward y nguyên token đó vào
https://api.github.com.
Vì sao cấm? Spec đưa ra bốn lý do, và mỗi lý do tự đứng vững. Security control bypass đầu tiên: MCP server hoặc downstream API có rate limiting, request validation, traffic monitoring dựa trên token audience; passthrough làm vô hiệu hoá toàn bộ phần này. Accountability vỡ tiếp theo, vì log của downstream service không biết request đến từ “MCP server” hay “user trực tiếp”, incident investigation thành mò kim đáy bể. Trust boundary tan rã vì downstream tin “request từ MCP server” với một set assumption nhất định, token từ nguồn khác phá vỡ assumption đó. Cuối cùng và nguy hiểm nhất là confused deputy: attacker có token cấp cho service X (qua phishing hoặc leak), gửi cho MCP server, server forward sang service X. X tin tưởng vì token hợp lệ. Attacker biến MCP server thành proxy data exfiltration không mất công.
Pattern đúng đơn giản hơn nghe có vẻ. Nếu MCP server cần gọi downstream API, nó phải act như OAuth client với downstream, có token RIÊNG cấp cho audience downstream. Token client gửi cho MCP server là token A. Token MCP server gửi cho downstream là token B. Không reuse, không forward, không “tiện thể”.
[Client] -- token A (aud=MCP server) --> [MCP Server] -- token B (aud=GitHub) --> [GitHub API]
Token A và token B là hai token độc lập, có thể cấp bởi cùng AS hoặc khác AS.
Code TypeScript example: MCP server làm Resource Server
Ví dụ minimal cho Express + @modelcontextprotocol/sdk. Đây là Streamable HTTP transport (theo bài 3). Logic auth được tách ra thành middleware trước MCP handler.
import express from "express";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import { createRemoteJWKSet, jwtVerify } from "jose";
import { z } from "zod";
const AS_ISSUER = "https://auth.example.com";
const RS_CANONICAL_URI = "https://mcp.example.com";
const JWKS = createRemoteJWKSet(
new URL(`${AS_ISSUER}/.well-known/jwks.json`)
);
// 1. Protected Resource Metadata endpoint (RFC 9728)
const app = express();
app.get("/.well-known/oauth-protected-resource", (_req, res) => {
res.json({
resource: RS_CANONICAL_URI,
authorization_servers: [AS_ISSUER],
scopes_supported: [
"mcp:tools:read",
"mcp:tools:write",
"mcp:resources:read"
],
bearer_methods_supported: ["header"]
});
});
// 2. Token validation middleware
async function requireToken(
req: express.Request,
res: express.Response,
next: express.NextFunction
) {
const authz = req.header("Authorization");
if (!authz?.startsWith("Bearer ")) {
return res
.status(401)
.set(
"WWW-Authenticate",
`Bearer resource_metadata="${RS_CANONICAL_URI}/.well-known/oauth-protected-resource"`
)
.end();
}
const token = authz.slice("Bearer ".length);
try {
const { payload } = await jwtVerify(token, JWKS, {
issuer: AS_ISSUER,
audience: RS_CANONICAL_URI
});
// Validate scope nếu route yêu cầu
(req as any).tokenClaims = payload;
next();
} catch (err) {
console.error("Token validation failed:", err);
return res
.status(401)
.set(
"WWW-Authenticate",
`Bearer error="invalid_token", resource_metadata="${RS_CANONICAL_URI}/.well-known/oauth-protected-resource"`
)
.end();
}
}
// 3. MCP server setup
const mcp = new McpServer({ name: "example-rs", version: "1.0.0" });
mcp.tool(
"list_issues",
{ repo: z.string() },
async ({ repo }, ctx) => {
const claims = (ctx as any).tokenClaims;
const scopes = (claims?.scope ?? "").split(" ");
if (!scopes.includes("mcp:tools:read")) {
throw new Error("insufficient_scope: mcp:tools:read required");
}
// Gọi downstream với token RIÊNG (token B), không reuse token A
return { content: [{ type: "text", text: `issues for ${repo}` }] };
}
);
// 4. Wire transport behind auth middleware
app.use("/mcp", requireToken, async (req, res) => {
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: () => crypto.randomUUID()
});
// Pass token claims vào MCP context
(transport as any).context = { tokenClaims: (req as any).tokenClaims };
await mcp.connect(transport);
transport.handleRequest(req, res);
});
app.listen(3000);
Vài điểm cần chú ý trong code trên:
createRemoteJWKSettừjosetự fetch JWKS từ AS, cache, rotate. Không hardcode key.audience: RS_CANONICAL_URItrongjwtVerifylà chỗ enforce audience binding. Token cấp cho server khác sẽ fail ở đây.WWW-Authenticateheader trong cả 401 ban đầu (no token) và 401 sau (invalid token), kèmresource_metadatalink. Bắt buộc theo spec.- Scope check ngay trong tool handler. Token có thể hợp lệ nhưng thiếu scope cho operation cụ thể, trả
403 Forbidden(hoặc throw lỗi để MCP layer convert thành error response). - Downstream API call dùng token B riêng (không show trong code), tuyệt đối không reuse
tokencủa client.
PKCE: tại sao mandatory cho cả public client
Spec yêu cầu PKCE (RFC 7636) cho mọi client, không chỉ public client như OAuth 2.0 cũ. Trích spec:
MCP clients MUST implement PKCE according to OAuth 2.1 Section 7.5.2. PKCE helps prevent authorization code interception and injection attacks.
Flow PKCE:
- Client tạo
code_verifier: random 43-128 ký tự. - Tính
code_challenge = SHA256(code_verifier), base64url encode. - Gửi
code_challenge+code_challenge_method=S256trong authorization request. - AS lưu
code_challengegắn với authorization code. - Khi exchange code lấy token, client gửi
code_verifier. AS verifySHA256(code_verifier) == code_challengeđã lưu.
Nếu attacker chặn được code (qua malicious redirect), không có code_verifier (chỉ client gốc biết) thì không exchange được. PKCE = “proof of possession” cho client mà không cần client secret.
Code TypeScript phía client (snippet):
import { createHash, randomBytes } from "crypto";
function base64url(buf: Buffer): string {
return buf.toString("base64")
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/, "");
}
const codeVerifier = base64url(randomBytes(32));
const codeChallenge = base64url(
createHash("sha256").update(codeVerifier).digest()
);
// Lưu codeVerifier vào session, dùng khi exchange code
Scope: nguyên tắc least privilege
Spec section “Scope Minimization” trong best practices nói rõ: tránh wildcard scope (*, all, full-access), không bundle scope không liên quan. Pattern khuyến khích:
- Baseline scope discovery/read low-risk, request từ đầu. Ví dụ:
mcp:tools:list,mcp:resources:read. - Elevation per operation khi user trigger thao tác nhạy cảm. Server trả 401 với
WWW-Authenticate: Bearer scope="mcp:tools:write:dangerous", client init flow mới xin scope đó.
Đặt tên scope theo namespace ổn định:
| Pattern | Ví dụ |
|---|---|
| Resource type | mcp:tools:*, mcp:resources:*, mcp:prompts:* |
| Operation | *:read, *:write, *:delete |
| Risk level | :basic, :elevated, :dangerous |
Kết hợp: mcp:tools:write:elevated.
Server enforce scope ở từng tool handler (như code example trên). Không tin token “có scope X” thay cho “đã được phép làm X” mà không kiểm tra logic phía server.
Confused deputy: pattern đặc biệt khi MCP là proxy
Một biến thể nguy hiểm xảy ra khi MCP server làm proxy tới third-party API có static client ID. Spec mô tả chi tiết:
- User chính danh consent một lần với MCP proxy server, AS bên thứ ba set consent cookie cho
client_idstatic của MCP proxy. - Attacker dynamic register một MCP client mới với
redirect_urimalicious tới MCP proxy server. - Attacker gửi link cho user, browser user vẫn còn cookie consent.
- AS bên thứ ba skip consent screen (vì có cookie), redirect authorization code về MCP proxy.
- MCP proxy redirect code tới
redirect_uricủa attacker. - Attacker exchange code lấy token, impersonate user.
Mitigation spec yêu cầu: MCP proxy server MUST implement per-client consent trước khi forward sang third-party AS. Tức là user phải approve “MCP client X được phép gọi third-party API qua proxy này” mỗi khi có client mới, kể cả khi đã có cookie consent ở AS bên thứ ba.
Pattern này áp dụng cho MCP server bridge tới Slack, GitHub, Notion, etc. Nếu bạn không build proxy thì có thể skip.
Security checklist trước khi ship
Cô đọng từ spec authorization + security best practices. Đi qua trước khi expose MCP server có auth:
Resource Server
- Endpoint
/.well-known/oauth-protected-resourcetrả metadata vớiauthorization_serversvàscopes_supported - Mọi protected request trả 401 +
WWW-Authenticate: Bearer resource_metadata="..."khi không có token - Validate token: signature (JWKS từ AS),
iss,aud(= canonical URI),exp,nbf - Reject token có
audkhông trùng canonical URI - Enforce scope ở từng tool/resource handler, không tin blindly
- HTTPS only cho mọi endpoint production
- Không log raw token vào application log
Khi MCP server gọi downstream API
- Dùng token RIÊNG cho downstream (token B), không passthrough token A
- Đăng ký MCP server như OAuth client với AS của downstream
- Nếu downstream cần consent user, implement per-client consent ở MCP layer
Session
- Không dùng session ID làm cơ chế authentication
- Session ID tạo bằng CSPRNG, bind với user identity (
<user_id>:<session_id>) - Token re-check ở mỗi request, không cache “đã auth rồi”
Client integration
- Test với Claude Desktop, Claude Code, MCP Inspector
- Hỗ trợ DCR (POST
/registerở AS, nếu bạn cũng vận hành AS) - Document scope catalog rõ ràng cho client developer
Hardening
- Rate limit per token + per client_id
- Short-lived access token (15-30 phút), refresh token rotation
- Audit log token validation event (success + failure) với correlation ID
- Monitor: spike of 401, scope elevation pattern bất thường
Khi nào dùng auth, khi nào skip
Không phải MCP server nào cũng cần OAuth. Quyết định dựa vào transport và surface:
| Tình huống | Có cần OAuth không |
|---|---|
| stdio server local, đọc filesystem user | Không. Spec ghi rõ stdio dùng env credential |
Streamable HTTP server expose ở localhost cho dev | Không bắt buộc, có thể bỏ qua trong dev |
| Streamable HTTP server expose public, single tenant của bạn | Có, nhưng AS có thể tự host minimal |
| Streamable HTTP server SaaS multi-tenant | Có, full DCR + scope minimization |
| MCP server làm proxy tới third-party API có user data | Có, kèm per-client consent (confused deputy) |
Quy tắc đơn giản: bất kỳ MCP server nào có request từ network và xử lý dữ liệu của user phải có auth. Skip chỉ ở local dev hoặc stdio.
Một câu cuối
Nếu chỉ nhớ một câu từ bài này: audience binding là chỗ tách MCP khỏi OAuth phổ thông. RFC 8707 và parameter resource là phần dễ quên nhất, và là phần đau nhất khi quên. Token passthrough thì đừng bao giờ.
Phần khó nhất của MCP production không phải transport, không phải tool design. Là phần này. Auth làm đúng một lần, các bài sau gần như tự nhiên rơi vào chỗ.
Tham khảo
- MCP Authorization Specification 2025-06-18
- MCP Security Best Practices
- RFC 8707: Resource Indicators for OAuth 2.0
- RFC 9728: OAuth 2.0 Protected Resource Metadata
- RFC 7591: OAuth 2.0 Dynamic Client Registration
- OAuth 2.1 IETF Draft
- Auth0: MCP Spec Updates June 2025
- Logto: MCP server auth implementation guide