Community-maintained FDE reference. Not an official Vercel or Anthropic project. About this project
Where the principal comes from
TL;DR: every authorization decision in an MCP server keys off a principal, and there is exactly one safe source for it: the verified access token, established by the flow in Authorization flows. Tool arguments are generated by a model, and a model can be steered by anything it has read, so an identity read from arguments is an identity chosen by whoever last influenced the prompt. On Vercel the plumbing is concrete: verifyToken returns an AuthInfo, withMcpAuth attaches it to the request, and your handler reads it from ctx.http.authInfo. Nothing the client or model sends in a payload should ever be able to change who a request acts as.
The rule
A client or model must never assert its own identity. A server that trusts a client-supplied principal, userId, email, or role field is wide open: any caller can claim any principal by putting that string in the request. Nothing stops a low-privilege caller from sending userId: "admin" and inheriting that principal’s grants.
This is a textbook privilege-escalation and confused-deputy failure: the server is tricked into acting with authority the caller does not hold (see Trust boundaries). It also breaks least privilege at the root: every scope check downstream is only as trustworthy as the principal it keys off, and a forgeable principal makes all of them meaningless. In an MCP system, tool arguments are LLM-generated and therefore untrusted input; identity is exactly the kind of value that must not be read from an untrusted payload. An attacker does not even need to compromise your client: a prompt-injection payload in a document the model summarized earlier is enough to steer the next tool call’s arguments.
The AuthInfo flow into handlers
Production derives the principal from the verified token, fixed at authentication time, and ignores any identity-shaped field in the arguments. On Vercel with mcp-handler the path is short and worth knowing end to end (verified against mcp-handler 2.1.1 and @modelcontextprotocol/server 2.0.0):
withMcpAuthextracts the bearer token and calls yourverifyToken(req, bearerToken).verifyTokenvalidates the token (signature, issuer, expiry, audience) and returns anAuthInfo:{ token, clientId, scopes, expiresAt?, resource?, extra? }. This return value is the trust decision; put the verified subject and any tenant or role claims you need intoextra.- The wrapper attaches it to the request and
createMcpHandlerforwards it into the SDK, which delivers it to every tool handler asctx.http.authInfoon the context object (the second callback argument).
server.registerTool( "read_invoice", { description: "Read one invoice", inputSchema: z.object({ id: z.string() }) }, async ({ id }, ctx) => { const auth = ctx.http?.authInfo; // set by withMcpAuth if (!auth) throw new Error("unauthenticated"); requireScope(auth, "invoices:read"); // default deny const owner = auth.extra?.userId as string; // from verifyToken, not from args return readInvoiceFor(owner, id); },);The principal is fixed when the token is verified; every subsequent decision is attributed to that principal. If the arguments happen to contain a userId, the handler never reads it.
The two sourcing models side by side; the only difference is where the value the authorization check trusts comes from:
Diagram source (Mermaid)
flowchart TB
subgraph unsafe["Unsafe: identity read from the request"]
direction LR
cm1[Client / Model] -->|"principal in tool args (forgeable)"| h1[Handler] --> z1[Authorization]
end
subgraph safe["Safe: identity from the verified token"]
direction LR
vt["verifyToken: AuthInfo"] -->|"ctx.http.authInfo"| h2[Handler] --> z2[Authorization]
cm2[Client / Model] -. "principal in tool args (ignored)" .-> h2
endThree rules keep the flow honest:
verifyTokendecides, handlers consume. Handlers never re-derive identity from headers or payloads; they readctx.http.authInfoor refuse. A missingauthInfoon a supposedly protected route means the gate was miswired (required: falseis the default; see Authorization flows); fail closed.- Scopes are not the principal.
requiredScopesonwithMcpAuthgates the route; per-principal decisions (which rows, which tenant, which tools are even listed) key off the verified claims insideAuthInfo. See Authorization & scoping. AuthInfo.extracarries claims, not conclusions. Store the verifiedsuband tenant; compute “may this principal call this tool” fresh at call time, default deny.
The teaching simplification in this repo
No example server in this repo takes the principal from a tool argument any more. The two servers that authorize per principal, examples/secure-tools-server (in the repository) (the house template) and examples/least-privilege-server (in the repository), both derive it from ctx.http.authInfo: the route wraps the handler in withMcpAuth with a stub verifyToken from src/auth.ts, and every handler calls principalFromAuthInfo(ctx.http?.authInfo) (the verified subject first, the OAuth clientId as fallback, and the empty string when no verified token reached the handler, which is never authorized, so a route that lost its auth wrapper degrades to denials, not to an open server). A principal argument is stripped by the schema and never consulted; the tests prove it can neither grant nor revoke access. examples/auth-server (in the repository) shows the same principal source with the RFC 9728 metadata route alongside.
The simplification that remains is in the verifier, and it is deliberate, for two reasons:
- Clarity:
verifyTokenis a fixed in-process table from bearer token toAuthInfo(insecure-tools-server,demo-tokenverifies to the authorized subject andother-tokento a valid but different user; inleast-privilege-server,auditor-token,treasury-token, andstranger-tokenverify to a read-only principal, a principal with both grants, and a correctly scoped user with no grants at all), so you can see exactly which claims the check keys off without tracing a JWKS fetch. - Offline testability: the vitest suites inject
AuthInfothrough the in-memory transport’ssendoptions, the same pathwithMcpAuthpopulates in production, and drive both the allowed and the denied paths with no IdP and no network.
Only the verifier is stubbed; the principal source and the check itself (default deny, scope-set comparison inside every handler, per-principal listing in least-privilege-server) are production-shaped. A real deployment replaces the table with JWT verification against its authorization server (signature via JWKS, issuer, audience per RFC 8707, expiry) and puts the verified subject in AuthInfo.extra.sub; nothing else changes. Never ship the stub table, and never reintroduce an argument-sourced principal.
Related
- Authorization flows - the OAuth 2.1 flow that produces the verified token this page keys off.
- Security checklist - the authorization and scoping checkboxes this rule underwrites.
- Least privilege - per-tool scope declarations that assume an unforgeable principal.
- Trust boundaries - the confused deputy, drawn at the architecture level.
- Consent UX - the human half of attribution: the user approving what runs as them.
Bibliography
- Model Context Protocol Specification, Authorization, version 2026-07-28 - https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization
- Model Context Protocol, Security Best Practices (confused deputy, token passthrough) - https://modelcontextprotocol.io/docs/2026-07-28/tutorials/security/security_best_practices
- Vercel Documentation, Deploy MCP servers to Vercel - https://vercel.com/docs/mcp/deploy-mcp-servers-to-vercel
- Vercel, mcp-handler (source and API) - https://github.com/vercel/mcp-handler
- OWASP Top 10 for Large Language Model Applications - https://owasp.org/www-project-top-10-for-large-language-model-applications/