An agent that calls real systems has to answer four questions on every request: who is the agent, on whose behalf is it acting, which resource is the credential for, and how does the resource know the caller holds the credential legitimately. Static API keys answer none of them well. A key in an environment variable identifies a deployment, not a user; it works on every resource that accepts it; and anyone who reads it from a log, a trace or a prompt-injected tool output can replay it.
The protocols that answer those questions already exist, and the Model Context Protocol has standardised how agents should use them. This article walks the handshake an agent runtime performs against a protected MCP server, using the 2026-07-28 MCP authorization specification; then covers what to do when the user is not present, how to store tokens so the model never touches them, and how sender-constrained tokens make a leaked token useless. Delegation chains and the token-exchange act claim are covered in the confused deputy article and are only referenced here.
Four questions, six mechanisms
Map each question to a protocol before writing code. Most agent auth bugs come from using one mechanism to answer a question it was never designed for.
| Question | Mechanism | Notes |
|---|---|---|
| Who is the agent? | Client authentication: client ID plus private_key_jwt or mTLS, or a workload identity | See workload identity; never a shared secret in the prompt context |
| On whose behalf? | Authorization code with PKCE, user present | The user's consent defines the ceiling of what the agent may do |
| User not present? | OpenID CIBA: backchannel request, approval on the user's device | For background and long-running agents |
| For which resource? | Resource indicators (RFC 8707), audience checks | A token for the calendar server must fail at the mail server |
| Across services? | Token exchange (RFC 8693) | Downscoped, audience-bound tokens per hop |
| Proof of possession? | DPoP (RFC 9449) or mTLS-bound tokens | A stolen bearer token alone is not enough |
The MCP authorization handshake
Under the MCP specification, authorization applies to HTTP transports; stdio servers take credentials from the environment instead. The MCP server is an OAuth 2.1 resource server, the agent runtime is the OAuth client, and a separate authorization server issues tokens. The flow, step by step:
- The agent calls the server without a token, or with an expired one, and gets HTTP 401 with a
WWW-Authenticate: Bearerheader carryingresource_metadata(the URL of the server's Protected Resource Metadata, RFC 9728) and usually ascopeparameter. The challenged scopes are authoritative for this operation. - The client fetches that metadata, which lists the authorization servers, then fetches the authorization server's metadata through RFC 8414 or OpenID Connect discovery. Clients must support both.
- The client obtains a client ID. The spec's priority order is: a pre-registered ID if it has one; else a Client ID Metadata Document if the authorization server advertises
client_id_metadata_document_supported; else Dynamic Client Registration, which the 2026-07-28 spec marks deprecated and keeps for older servers. - It generates a PKCE verifier and challenge, records the authorization server's issuer next to the verifier, and sends the user to the authorization endpoint with
resourceset to the MCP server's canonical URI. - On the callback it validates the
issparameter against the recorded issuer (RFC 9207) before sending the code anywhere. This stops mix-up attacks where one authorization server's code is sent to another's token endpoint. - It redeems the code with the verifier and the same
resource, stores the tokens, and retries the original call withAuthorization: Bearer. Tokens never go in query strings.
Client identity without pre-registration
Client ID Metadata Documents solve a real agent problem: an MCP client may meet thousands of servers it has never seen, and pre-registering with each is impossible. With a metadata document the client ID is itself an HTTPS URL, and the authorization server fetches the JSON at that URL to learn the client's name and redirect URIs. Trust then rests on the domain hosting the document.
GET https://agent.example.com/oauth/client.json
{
"client_id": "https://agent.example.com/oauth/client.json",
"client_name": "Example Ops Agent",
"redirect_uris": ["http://127.0.0.1:33418/callback"],
"grant_types": ["authorization_code", "refresh_token"],
"response_types": ["code"],
"token_endpoint_auth_method": "none"
}Two operational notes. The authorization server shows the user something derived from this document on the consent screen, so a look-alike domain can impersonate your agent; servers should show the domain prominently, and users should be taught to read it. And because the document is fetched by the server, host it on a path you control tightly; anyone who can edit it can change where codes are delivered.
Client logic, including step-up
The client logic fits in a page. The parts people skip are the scope union on step-up, which keeps previously granted scopes, the retry cap, and the issuer check. Note that vault is keyed by user and resource: one user's token must never be served to another user's session, and a token for one MCP server must never be sent to another.
MAX_STEP_UPS = 2
def call_tool(server, request, user):
granted = vault.scopes(user, server.canonical_uri)
for attempt in range(MAX_STEP_UPS + 1):
token = vault.access_token(user, server.canonical_uri) # refreshes if needed
resp = http.post(server.url, request, auth=bearer(token))
if resp.status == 401:
ch = parse_www_authenticate(resp) # resource_metadata, scope
granted = granted | (ch.scope or prm_scopes(ch.resource_metadata))
authorize(user, server, scopes=granted)
continue
if resp.status == 403 and ch_error(resp) == "insufficient_scope":
need = parse_www_authenticate(resp).scope
granted = granted | need # union, never replace
authorize(user, server, scopes=granted) # user sees what is added
continue
return resp
raise PermanentAuthFailure(server.url, request.method)
def authorize(user, server, scopes):
prm = fetch_json(server.resource_metadata)
asm = discover(prm["authorization_servers"][0]) # RFC 8414 or OIDC
verifier, challenge = pkce_s256()
state = store_pending(user, verifier, expected_iss=asm["issuer"])
code, iss = ask_user(asm, challenge, state, scopes,
resource=server.canonical_uri)
if iss_required_or_present(asm, iss) and iss != state.expected_iss:
raise MixUpDetected()
tokens = token_request(asm, code, verifier, resource=server.canonical_uri)
vault.put(user, server.canonical_uri, tokens)Two rules from the specification are easy to violate in agent frameworks. Clients must not send a server any token other than one issued by that server's authorization server, so a gateway that forwards the user's upstream token is non-compliant. Servers must validate that the token's audience is themselves and must not pass the token through to downstream APIs; a downstream call needs its own token, typically obtained by token exchange.
What the server must check
On the MCP server, validation is the same as for any resource server, done strictly: verify the signature against the issuer's published keys or introspect the token; check iss, exp and aud against this server's canonical URI; check the scopes cover the requested tool, honouring any scope hierarchy; and when they do not, return 403 with error="insufficient_scope", every scope the operation needs in one challenge, and the resource_metadata URL. Challenging one scope at a time forces repeated consent screens.
Map tools to scopes at design time, coarse enough that consent screens are readable and fine enough that a read-only workflow never holds write access. A server with forty tools and one scope gives the user no meaningful choice.
When the user is not there: CIBA
Background agents break the browser-redirect model: a nightly reconciliation agent cannot open a browser. OpenID Connect Client-Initiated Backchannel Authentication (CIBA) lets the client ask the authorization server to get approval on the user's own device. The client posts to the backchannel authentication endpoint with a hint identifying the user, the scopes, and a short binding_message that appears on both sides so the user can match the request. It receives an auth_req_id, then either polls the token endpoint with the CIBA grant type, or is notified (ping mode) or sent the tokens directly (push mode).
For agents this is the right shape for high-impact, user-absent actions: the agent pauses, the user sees "Approve transfer of 120 USD to vendor 4471" on their phone, and only approval yields a token scoped to that action. Keep the request expiry short, show specific binding messages rather than generic ones, and treat a timeout as a denial. Rate-limit requests, because repeated prompts train users to approve without reading. The UX side of such prompts is covered in agent permission prompts.
Keeping tokens away from the model, and binding them
Where tokens live decides whether a prompt injection can steal them. The rule: the model never sees a credential. Tokens sit in a vault keyed by user, agent and resource, encrypted at rest. The tool runtime, not the model, attaches them to outbound requests after the model has chosen a tool and arguments. Refresh tokens stay server-side, are rotated on use when the authorization server supports rotation, and are revoked when the user disconnects the integration. Logs and traces redact Authorization headers and token-endpoint bodies. For secret-handling basics, see secrets management.
Sender-constrained tokens close the remaining gap. With DPoP (RFC 9449) the client holds a private key and sends, with each request, a short-lived signed proof JWT of type dpop+jwt containing the HTTP method, the target URI, an issue time, a unique ID and, at resource servers, a hash of the access token in ath. The token is bound to the key's thumbprint, so a token copied out of a log is useless without the key, which can live in an HSM or platform keystore the agent process cannot export. Servers may require a fresh nonce to limit pre-generated proofs. mTLS-bound tokens give the same property where you control the client certificates.
Worked example: a calendar assistant
An internal calendar assistant uses an MCP server at https://cal.example.com/mcp with tools to list events (calendar:read) and create them (calendar:write).
- First use: list_events returns 401 with
scope="calendar:read". The runtime discovers the authorization server, identifies itself with its metadata document URL, and the user consents to read access. The token has audiencehttps://cal.example.com/mcp. - The user asks to book a meeting. create_event returns 403
insufficient_scopewithscope="calendar:write". The runtime requests read and write together, and the consent screen shows only the added permission. - The same token presented to a mail MCP server fails the audience check, as intended.
- Overnight, a scheduling agent wants to move a meeting while the user sleeps. It sends a CIBA request with binding message "Move 1:1 with Priya to Thursday 10:00"; the user approves in the morning; the agent receives a token and acts. Had the request expired, the agent would have left a note instead.
Failure modes
- Token passthrough. A gateway forwards the user's token to downstream APIs; any compromised hop gains full access. Exchange for audience-bound tokens instead.
- Missing resource parameter. Tokens without audience binding work everywhere the issuer is trusted.
- Skipping the iss check. Enables mix-up between authorization servers.
- Scope replacement on step-up. Requesting only the new scope drops old ones and causes consent loops.
- Unbounded retries. An agent stuck in re-authorization spams the user; cap and fail permanently.
- Tokens in context. Any credential in the prompt, tool output or memory is one injection away from exfiltration.
Trade-offs
Every layer adds latency and moving parts. Discovery adds round trips on first contact; cache metadata with a sensible expiry. DPoP adds a signature per request and key management. CIBA adds a human in the loop and minutes or hours of delay, so reserve it for actions that warrant it. Fine-grained scopes give users real choices but more consent screens. The pragmatic baseline is authorization code with PKCE, resource indicators and a vault for every user-delegated integration; workload credentials for the agent itself; then DPoP and CIBA where the risk justifies them.
What to do next
- Inventory every credential your agents use and map each to the four questions; replace static shared keys first.
- Implement the MCP client flow: 401 parsing, RFC 9728 and RFC 8414 or OIDC discovery, PKCE, resource parameter, iss validation, scope-union step-up with a retry cap.
- Publish a Client ID Metadata Document on a domain you control and lock down who can edit it.
- On servers, validate audience, issuer, expiry and scopes; return complete 403 challenges; never pass tokens through.
- Move tokens into a vault keyed by user and resource; keep them out of prompts, tool outputs and logs.
- Pilot DPoP for high-value servers and CIBA for user-absent, high-impact actions.
- Read on: capability tokens for agents for attenuating authority below scopes.