Bài 3 nói về transport. Bạn đã biết cách stdio và Streamable HTTP đẩy JSON-RPC qua lại giữa client và server. Câu hỏi tiếp theo gần như chắc chắn sẽ tới: nếu server expose data nhạy cảm (database công ty, GitHub repo private, Slack workspace) thì làm sao kiểm soát “ai được gọi cái gì”.
Tôi đã thấy đủ project tự nghĩ ra cơ chế auth riêng cho MCP server: API key trong header, basic auth, custom token JSON. Mỗi cách đều có lỗ hổng kinh điển: token bị log, token bị reuse, token không có scope, token không expire. Spec MCP 2025-06-18 chốt một câu trả lời rõ ràng: MCP server là OAuth 2.1 Resource Server, không phải Authorization Server. Mọi thứ khác xoay quanh boundary đó.
Bài này dài hơn các bài trước 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, một flow diagram đầy đủ, code TypeScript chạy được, và checklist trước khi deploy.
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.
Tại sao cấm? Spec liệt kê:
Security control bypass: MCP server hoặc downstream API thường có rate limiting, request validation, traffic monitoring dựa trên token audience. Token passthrough vô hiệu hoá những control đó.
Accountability vỡ: log của downstream service thấy request đến từ “MCP server” hay đến từ “user trực tiếp”? Nếu token passthrough, log mất ngữ nghĩa, incident investigation khó.
Trust boundary vỡ: downstream API tin “request từ MCP server” với assumption nhất định. Token từ nguồn khác vào sẽ phá assumption.
Confused deputy: kẻ tấn công có token cấp cho service X (qua phishing hoặc leak) gửi cho MCP server, MCP server forward sang service X, service X tin tưởng vì token hợp lệ. Attacker dùng MCP server làm proxy data exfiltration.
Pattern đúng: nếu MCP server cần gọi downstream API, nó phải act as OAuth client to downstream API với token RIÊNG (cấp riêng cho MCP server với audience là downstream API). Token client gửi cho MCP server là một token, token MCP server gửi cho downstream là một token khác. Không reuse.
[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.
Bước tiếp
Bài 4 đã đặt nền tảng auth. Bốn điểm cốt lõi cần nhớ trước khi đi tiếp:
- MCP server là Resource Server, không phải Authorization Server.
- Audience binding (RFC 8707) là chỗ tách MCP khỏi OAuth phổ thông, đừng quên
resourceparameter. - Token passthrough cấm tuyệt đối, downstream luôn cần token riêng.
- DCR + PKCE + per-client consent là combo chuẩn cho client dynamic.
Bài 5 sẽ qua resource design: cách thiết kế URI scheme, pagination với cursor, MIME type, large payload streaming. Một MCP server có resource thiết kế tốt sẽ giảm cuộc gọi tool, giảm token consumption, và dễ cache. Khác với tool (LLM chủ động gọi), resource là chỗ bạn chủ động expose data cho LLM đọc, design quan trọng hơn nhiều người tưởng.
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