Skip to content

Community-maintained FDE reference. Not an official Vercel or Anthropic project. About this project

Where the principal comes from

Audience:securityengineerarchitectMCP spec 2026-07-28

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

  1. withMcpAuth extracts the bearer token and calls your verifyToken(req, bearerToken).
  2. verifyToken validates the token (signature, issuer, expiry, audience) and returns an AuthInfo: { 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 into extra.
  3. The wrapper attaches it to the request and createMcpHandler forwards it into the SDK, which delivers it to every tool handler as ctx.http.authInfo on 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:

Safe: identity from the verified tokenctx.http.authInfoprincipal in toolargs (ignored)verifyToken:AuthInfoHandlerAuthorizationClient / ModelUnsafe: identity read from the requestprincipal in toolargs (forgeable)Client / ModelHandlerAuthorization
Safe: identity from the verified tokenctx.http.authInfoprincipal in toolargs (ignored)verifyToken:AuthInfoHandlerAuthorizationClient / ModelUnsafe: identity read from the requestprincipal in toolargs (forgeable)Client / ModelHandlerAuthorization
Mermaid flowchartOpen in Mermaid Live Editor
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
    end

Three rules keep the flow honest:

  • verifyToken decides, handlers consume. Handlers never re-derive identity from headers or payloads; they read ctx.http.authInfo or refuse. A missing authInfo on a supposedly protected route means the gate was miswired (required: false is the default; see Authorization flows); fail closed.
  • Scopes are not the principal. requiredScopes on withMcpAuth gates the route; per-principal decisions (which rows, which tenant, which tools are even listed) key off the verified claims inside AuthInfo. See Authorization & scoping.
  • AuthInfo.extra carries claims, not conclusions. Store the verified sub and 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: verifyToken is a fixed in-process table from bearer token to AuthInfo (in secure-tools-server, demo-token verifies to the authorized subject and other-token to a valid but different user; in least-privilege-server, auditor-token, treasury-token, and stranger-token verify 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 AuthInfo through the in-memory transport’s send options, the same path withMcpAuth populates 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.

  • 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