Skip to main content
This guide adds per-tool authorization on top of a working mcpresource validator. It covers which signing algorithms the validator accepts, scope guards, step-up authentication, structured denial bodies, securitySchemes metadata for tools/list, and URL mode elicitation. For the validator itself, see Resource Server. All code on this page uses the github.com/glincker/theauth-go/mcpresource module and the standard library only.

Signing algorithms and audience

The validator accepts access tokens signed with EdDSA, ES256 or RS256. The list is fixed. none and HS* are always rejected, and there is no option to widen it. The key type must match the header alg. A token that claims RS256 but resolves to an Ed25519 key is rejected, as is a JWK that pins a different alg. The aud claim may be a string or an array. The token is accepted when the array contains the resource URI passed to mcpresource.New. After validation, Principal.Audience holds that resource URI and Principal.Audiences holds every value the token carried.

Wire the validator

v.Middleware authenticates the token and attaches the Principal. RequireScope and RequireStepUp read that principal, so they must run after Middleware. A request that reaches either guard with no principal is answered 401 invalid_token.

Scope guards

RequireScope(scopes...) admits a request only when the token carries every listed scope. Scopes are compared as exact strings. A principal that lacks a scope gets 403:
The scope parameter lists the scopes the route needs, so a client can request exactly those on re-authorization. A token bound with DPoP is answered with the DPoP scheme instead of Bearer. When all tools share one JSON-RPC endpoint, wrap each tool handler and dispatch by tool name. Authentication still runs once, in v.Middleware, in front of the dispatcher.

Step-up authentication

Some tools need a stronger or fresher authentication than the one the user did to get the token. RequireStepUp implements the resource server side of RFC 9470.
When the token does not satisfy the policy, the response is 401:
The client is expected to send the user through a new authorization request with the same acr_values and max_age, then retry with the new token. A missing acr or auth_time claim fails a requirement that depends on it. The resource server only reads these claims. The authorization server has to put them in the access token.

Check the policy yourself

StepUp.Satisfied(p, now) is the same check as a plain method. Use it when the decision depends on the request body, for example only transfers above a threshold need MFA:

Structured denials

Every 401 and 403 that the validator and the guards write carries a JSON body next to the WWW-Authenticate header. The header is the normative signal. The body lets a client or an agent branch on the same facts without parsing it.
WriteDenial(w, status, d) writes the body with Content-Type: application/json and Cache-Control: no-store. It does not set WWW-Authenticate. Set that header before you call it, as in the example above.

Tool security metadata in tools/list

MCP clients that follow the OpenAI Apps SDK convention read a securitySchemes list on each tool to decide whether to start authorization before calling it. ToolSecurity and PublicToolSecurity build that metadata, and Apply writes it into a tool descriptor map.
For read_file the descriptor gains the list at the top level and again under _meta. Other _meta members already on the tool are kept.
ping gets [{"type": "noauth"}]. ToolSecurity() with no scopes declares oauth2 with no scopes, meaning any valid token. SecuritySchemes(scopes...) returns just the list, with duplicates and empty strings dropped, if you build the descriptor by another route. This metadata is advisory. It describes what the tool expects. Enforcement stays in RequireScope and RequireStepUp, so keep the two in step.

URL elicitation

When a tool cannot proceed until the user does something in a browser, for example authorizing a downstream service, the server can ask the client to open a URL. NewURLElicitation builds the request and URLElicitationRequiredError wraps it in the JSON-RPC error with code -32042.
The error object on the wire:
NewURLElicitation returns an error when the message is empty, the URL is not absolute, the URL embeds credentials, or the scheme is not https. http is accepted only for a loopback host such as localhost. Do not put tokens or other secrets in the URL: clients show it to the user and may log it.

Read authorization details and authentication context

After Middleware, the principal carries the authentication context claims and any RFC 9396 authorization_details:
AuthorizationDetails() returns a copy. A nil result means the token carries none. For tokens that trigger introspection (an act chain or a delegation grant), details missing from the JWT are taken from the introspection response. A malformed value, or an element without type, fails the request with 401 invalid_token.

Limits

  • max_age=0 cannot be expressed. A zero MaxAge means no age requirement, not “always re-authenticate”. Use a small positive value such as time.Second.
  • Only three algorithms. EdDSA, ES256 (P-256) and RS256 are accepted. PS256, ES384, RS384 and RS512 tokens are rejected, and the allowlist is not configurable.
  • Opaque tokens are not authenticated. The validator verifies JWT access tokens (RFC 9068). A token that is not a three segment JWT is rejected. Introspection is used to walk delegation chains, not to accept opaque tokens.
  • securitySchemes is advisory. Clients may ignore it, and nothing checks that it matches your guards.
  • The authorization server decides acr and auth_time. The resource server cannot raise authentication strength. If your issuer does not put these claims in access tokens, a step-up policy that depends on them can never be satisfied.
  • No certification claims. These controls implement the named RFC behaviors. They are not a statement of conformance or compliance with any program.

Next steps

Last modified on October 13, 2026