Skip to content

Authorization

Christian Presley edited this page Oct 8, 2026 · 15 revisions

Authorization

MCP authorization is OAuth 2.11 with protected resource metadata2 and authorization server metadata3. The SDK has both sides. The HTTP client transport answers 401 and 403 challenges with the OAuth client or with a provider of an authorization extension. The HTTP server checks bearer tokens with the resource server helpers. The SDK also has an authorization server, see Authorization Server.

Applies to: MCP 2026-07-28, zig-sdk 0.1.0. The authorization extensions, ES384, EdDSA and the JWT signing keys: zig-sdk 0.2.0. The issuer binding of pre-registered credentials, the URL checks, application_type, lastFailure, token_type, the scope hierarchies and the principal binding: the same release. DPoP, workload identity federation, the auth option of the WebSocket server, the refresh tokens and the token storage: the same release. ca_bundle, proxy and the storage that more clients share: zig-sdk 0.4.0. The time limits timeout and prompt_timeout of the keychain storage: zig-sdk 0.5.0.

Use the client

var oauth: mcp.auth.OAuthClient = .init(io, gpa, .{
    .registration = .{ .client_metadata_url = "https://my.example/client-metadata.json" },
    .authorize = .{ .callback = .{ .open = openInBrowser } },
});
defer oauth.deinit();
const http = try mcp.transport.HttpClient.init(io, gpa, .{ .url = url, .auth = &oauth });

registration is one of:

  • pre_registered: a list of OAuthClient.Credentials. Each entry has the issuer of the authorization server, a client_id and an optional client_secret. See Pre-registered credentials.
  • client_metadata_url: a client ID metadata document4. The client uses it when the server advertises support, else it registers dynamically. The URL must use https and have a path other than /. It must not have user information, a fragment, or a . or .. segment. Another URL gives error.InvalidClientMetadataUrl.
  • dynamic: dynamic client registration only5. The registration sends application_type from the option of the same name. The default is native. Set .web for a browser application that a remote host serves.

The redirect_uri option must use https, or http with a loopback host: localhost, an address in 127.0.0.0/8 or [::1]. Another URI gives error.InvalidRedirectUri on the first challenge. The default is http://127.0.0.1:41893/callback.

The client refuses http metadata, registration, authorization and token endpoints with error.InsecureEndpoint. The option allow_http accepts them. Use it for tests only.

When an authorization server rejects a registration or a token request, lastFailure(arena) gives the details. They are the step, the HTTP status, the error code and the error_description of the response. The client also logs a warning. The description is text from the server.

Pre-registered credentials

var oauth: mcp.auth.OAuthClient = .init(io, gpa, .{
    .registration = .{ .pre_registered = &.{
        .{ .issuer = "https://as.example", .client_id = "my-client", .client_secret = secret },
    } },
    .authorize = .{ .callback = .{ .open = openInBrowser } },
});

The client uses the entry with the issuer of the authorization server. It never sends an entry to another authorization server6. An entry without issuer binds to the first authorization server that the client uses. We recommend that you set issuer. When no entry is for the issuer, the flow stops with error.IssuerNotRegistered.

authorize is one of:

  • callback: the application receives the authorization URL and returns the redirect URL it received. Open the URL in the system browser. The SDK never opens it.
  • headless_redirect: the client requests the URL, does not follow the redirect, and reads the code from the Location header. Tests only.

The transport option auth_provider takes a mcp.auth.Provider. It has priority over auth. OAuthClient.provider() gives the same provider that the auth option uses. The providers of the authorization extensions use this option.

What the client does on a challenge

  1. It parses the WWW-Authenticate header for resource_metadata, scope and error.
  2. It fetches the protected resource metadata from the header URL, else from the path-inserted well-known location, else from the root. The resource must be the server URL or a parent of it, else the flow stops. The comparison ignores the case of the scheme and the host.
  3. It fetches the authorization server metadata in the order the specification gives for issuers with and without a path. The issuer must equal the URL of the query, else the flow stops.
  4. It registers once per issuer. A new issuer in the metadata discards the registration and the tokens of the old one6.
  5. It selects the scope: the challenge, else scopes_supported of the resource, else none. On a 403 it unites the scopes it holds with the challenged scope. It adds offline_access when the authorization server lists it.
  6. It sends the authorization request with PKCE S2567, state and the resource indicator8. It refuses a server that does not list S256.
  7. It validates state and iss9. The iss comparison is a string comparison without normalization. When the server advertised iss, a missing iss is an error.
  8. It requests the token with the client authentication method the server supports: client_secret_basic, client_secret_post or none.

The client stops after three challenges for one request. The client sends tokens in the Authorization header only: with the Bearer scheme10, or with the DPoP scheme for a DPoP-bound token.

Private CAs and proxies

OAuthClient.Options.ca_bundle sets the CA certificates that the OAuth client trusts for its https requests. These are the metadata, registration and token requests, and the authorization request of headless_redirect. Null uses the CA store of the system, as before version 0.4.0. Thus a private CA or the CA of a test server works without a change to the CA store of the system. The bundle must stay valid and unchanged until deinit.

OAuthClient.Options.proxy sets the HTTP proxy of the same requests. It takes the values of HttpClient.Options.proxy, see Transport Streamable HTTP. The default reads no environment, thus the client connects directly. Give the same values to the HTTP client transport, so that both use one trust and one proxy.

var oauth: mcp.auth.OAuthClient = .init(io, gpa, .{
    .registration = .dynamic,
    .authorize = .{ .callback = .{ .open = openInBrowser } },
    .ca_bundle = &bundle,
    .proxy = .{ .environment = init.environ_map },
});
defer oauth.deinit();
const http = try mcp.transport.HttpClient.init(io, gpa, .{
    .url = url,
    .auth = &oauth,
    .tls = .{ .trust = .{ .bundle = &bundle } },
    .proxy = .{ .environment = init.environ_map },
});

With a bundle, each request goes on a new connection of http1.Connection. There, the TLS client of the SDK checks the certificates against the bundle only, at the time of the request. Thus these servers must offer TLS 1.3. The client then never uses the CA store of the system, also on Windows. For the CA store of the system and a private CA together, load the store with std.crypto.Certificate.Bundle.rescan and add the private CA to it.

Through a proxy, the client asks for a CONNECT tunnel to the server, also for an http URL. The TLS client speaks to the server through the tunnel, with the same trust as a direct request. With ca_bundle, that is the TLS client of the SDK. Without it, the TLS client of std uses the CA store of the system. The client does not use the proxy fields of std.http.Client. In Zig 0.16.0, that client sends an https request through a CONNECT tunnel without TLS.

When the proxy refuses the tunnel, auth.common.Fetcher.fetch returns error.ProxyRefused. The OAuth client then fails as for a server that it cannot reach. The log scope mcp_proxy has a warning with the proxy, the target and the status. The user and the password of a proxy URL go only to the proxy. The log lines show only the host and the port of the proxy.

ClientCredentials, EnterpriseClient and WorkloadIdentity have no options for the trust and the proxy in this release. Each of them has the field fetcher, an auth.common.Fetcher with the fields ca_bundle and proxy. These fields have the same rules. Set them after init and before the first request.

Refresh tokens

The client keeps the refresh token and the expiry of each token response. A refresh token gives new access tokens without the browser.

  • Before each request, the client checks the expiry. When the access token expires within refresh_margin_seconds (60 by default), the client sends a refresh request first. When the refresh fails, the client sends the old token until it expires.
  • On a 401 challenge, the client tries the refresh first. A step-up challenge (403 or insufficient_scope) needs a new grant, so the client runs the authorization code flow.
  • The refresh request has grant_type=refresh_token, the resource indicator and the granted scope. The client never asks for a wider scope than the grant has.
  • The client authenticates as for the code exchange. With dpop, the request has a DPoP proof, because the refresh token of a public client works only with its key.
  • A new refresh token in the response replaces the old one. Without one, the client keeps the old refresh token.
  • When the server refuses the refresh token with invalid_grant, unauthorized_client, unsupported_grant_type or invalid_scope, the client discards the tokens and runs the authorization code flow. When the server refuses the client with invalid_client, the client also discards the registration.
  • With a storage, the client reads the stored record again before a refresh and after a refusal. Another client with the same storage can have new tokens. See Share one storage.

lastFailure gives the step refresh for a refused refresh request. The authorization server decides if it issues refresh tokens. The client asks for offline_access when the server lists it.

Store tokens

Without a storage, the client keeps the registration and the tokens in memory only. A new process then needs a new authorization. The storage option takes a mcp.auth.TokenStorage. Then a new process uses the stored record and does not open the browser.

var keychain = mcp.auth.KeychainTokenStorage.init(io, gpa, .{ .service = "my-app", .environ_map = init.environ_map }) catch |err| switch (err) {
    error.KeychainUnavailable => return useFileStorage(),
    else => return err,
};
defer keychain.deinit();
var oauth: mcp.auth.OAuthClient = .init(io, gpa, .{
    .registration = .dynamic,
    .authorize = .{ .callback = .{ .open = openInBrowser } },
    .storage = keychain.storage(),
});

The key of a record is the issuer of the authorization server, the resource and the identity of the client. The identity is the client ID of the pre-registered credentials, the client ID metadata document URL, or the client name and the redirect URI. Set storage_identity to keep the records of two accounts apart.

A record has the client registration, the access token, its expiry, the refresh token, the granted scopes and the DPoP binding. The serialization is JSON with a version. The storage does not get the secret of pre-registered credentials. The SDK erases the buffers with secrets after use.

What the client does with a storage:

  1. At the first challenge for an issuer, it loads the record. A valid stored token goes out without registration and authorization.
  2. A stored token that expires soon gets a refresh. The user sees no browser.
  3. It saves the record after each registration, token response and refresh.
  4. It deletes the record when the protected resource names another authorization server.
  5. When the token endpoint refuses the client with invalid_client, it deletes the record. At a refresh, it deletes the record only when no other client changed it.
  6. When the server refuses the refresh token, it removes the tokens from a record that no other client changed.
  7. It skips stored tokens of another DPoP key, because such a token does not work with the new key.
  8. Before a refresh and at a challenge that is not a step-up, it reads the record again. See Share one storage.
Storage Where Protection
MemoryTokenStorage The memory of the process The records go away with the process.
FileTokenStorage One file for each record in a private directory AES-256-GCM with a key of the application and a random nonce for each write. The file name is an HMAC of the key, so it does not tell the issuer. The mode is 0600 in a 0700 directory on POSIX systems. Windows gives the user an owner-only access control list.
KeychainTokenStorage The keychain of the host The keychain encrypts the data for the user.

Share one storage

More clients can share one storage, for example in two windows of an application or in two processes. With refresh token rotation1, a refresh of one client makes the refresh token of the other client invalid. Thus each client reads the stored record again at these times:

  • Before a refresh. When another client changed the record, the client takes its registration and tokens. It sends no refresh when the new access token does not expire soon.
  • After the server refuses the refresh token or the client. When another client changed the record, the client takes the new tokens. When the new access token expires soon, the client sends one more refresh with the new refresh token.
  • At a challenge that is not a step-up. When another client got new tokens, the client tries them before a refresh or the browser.

After a refused refresh, the client does not write over a record that another client changed. Thus it removes the refused tokens from the record, or deletes the record, only when the record did not change. Before version 0.4.0, the client sent its old refresh token, and the server refused it after a rotation. The client then wrote a record without tokens over the newer record, and sent the user to the browser again.

Without pre-registered credentials, a stored record can have another client ID than the registration of the client. Then the client takes the registration of the record with its tokens. The server issued these tokens to the client ID of the record. When the tokens are for another DPoP key, the client cannot use them. It then keeps its own registration and tokens, and refreshes with its own refresh token.

The storage has no lock between processes. Thus the server can refuse one of two refreshes at the same time. When the other client did not save its new tokens yet, the refused client runs the authorization code flow.

File storage

FileTokenStorage.init creates the directory when it is not there. The parent must exist. Other accounts must not be able to add, delete or rename entries in the directory, else init gives error.DirectoryNotPrivate. A write goes to a temporary file, and a rename puts it in place. A changed file, a wrong key and a file under the name of another record give error.InvalidRecord. The client then deletes the record and authorizes again.

Keep the key of the application in a safe location. It protects all records.

Keychain storage

System Backend Notes
Windows Generic credentials of the Credential Manager (CredWriteW, CredReadW, CredDeleteW) A credential holds 2560 bytes or less. The backend splits a larger record into chunks with one random generation. A read accepts only chunks of one generation. persist selects local_machine (the default) or enterprise.
macOS Generic password items of Keychain Services (SecItemAdd, SecItemCopyMatching, SecItemUpdate, SecItemDelete) The SDK loads the Security and CoreFoundation frameworks at run time with std.DynLib. A build for macOS needs no SDK of Apple.
Linux and other POSIX systems The Secret Service API26 on the D-Bus bus27 of the user The SDK has its own small D-Bus client. It reads DBUS_SESSION_BUS_ADDRESS from environ_map, else it uses $XDG_RUNTIME_DIR/bus.

init checks that the keychain answers. A host without a keychain gives error.KeychainUnavailable. Examples are a Linux server without a D-Bus bus or without a Secret Service, and a Windows service logon. Use FileTokenStorage then. The example client_cli shows this fallback.

The Secret Service backend uses the items of the default collection. The secret goes to the service with the algorithm plain, without encryption. The bus is a local Unix socket that only the user and the administrator can reach. An encrypted transfer protects against no extra attacker, because a peer on the bus can ask the service for the secret too. For a locked collection, the service shows its unlock prompt. With allow_prompt = false, the storage gives error.KeychainLocked.

Each answer of the bus and of the Secret Service has the time limit timeout, 25 seconds by default. A service that does not answer in time gives error.KeychainUnavailable. The answer of the user to a prompt has the time limit prompt_timeout, 5 minutes by default. A prompt without an answer in time gives error.KeychainLocked. Null waits without a limit. When the Io cannot run a task concurrently, the storage can wait without a limit.

The SDK does not use the key retention service of the Linux kernel. Its keys go away at a reboot, so it is not a durable storage.

Protect a server

const keys = [_]mcp.auth.jwt.Key{.{ .kid = "k1", .alg = .ES256, .material = .{ .p256 = &public_point } }};
var verifier: mcp.auth.JwtVerifier = .{
    .options = .{ .keys = &keys, .issuer = "https://as.example", .audience = "https://rs.example/mcp" },
    .clock = .{ .io = io },
};
const rs: mcp.auth.ResourceServer = .{
    .resource = "https://rs.example/mcp",
    .resource_metadata_url = "https://rs.example/.well-known/oauth-protected-resource/mcp",
    .authorization_servers = &.{"https://as.example"},
    .scopes_supported = &.{ "mcp:read", "mcp:write" },
    .required_scopes = &.{"mcp:read"},
    .verifier = verifier.verifier(),
};
var transport: mcp.transport.http.Server = .init(io, gpa, &server, .{ .port = 8443, .tls = &tls_server, .auth = &rs });

With the auth option the HTTP server does this:

  • It serves the protected resource metadata at the path-inserted well-known location, /.well-known/oauth-protected-resource/mcp for the endpoint /mcp2.
  • A request without a token gets 401 with WWW-Authenticate: Bearer resource_metadata="...", scope="...".
  • A request with a token that is not a bearer token gets 400 with error="invalid_request".
  • A token that does not verify gets 401 with error="invalid_token".
  • A token without a required scope gets 403 with error="insufficient_scope" and the full scope list.
  • A valid token puts a principal into the request context. A handler reads it with ctx.principal(). The principal has the issuer, the subject, the client id and the scopes of the token.

The gRPC server has the same auth option, and ctx.principal() works there too. The server checks the token before it reads the body. The WebSocket server also has the auth option. It checks the token one time, at the upgrade request, see Transport WebSocket. The raw token never reaches a handler11.

The server binds sealed requestState to the principal: the issuer, the subject and the client id. When another principal sends the state back, the request gets -32602 with the reason invalid_request_state. Thus one user cannot continue the multi round-trip request of another user.

JwtVerifier verifies JSON Web Tokens12 with HS256, ES256, ES384, EdDSA, RS256 or PS256 keys. It checks exp, nbf and iat with a skew of 60 seconds. When the options set issuer and audience, it requires them. The audience comparison ignores the case of the scheme and the host, but not the case of the path.

With token_type, the verifier also checks the typ header, for example at+jwt for the tokens of the Authorization Server. mcp.auth.jwt.parseJwks reads the keys from a JWK set, see Validate an ID-JAG. TokenVerifier is an interface, so an application can plug in token introspection or another scheme.

Scope hierarchies

ResourceServer.scope_hierarchy names broader scopes and the narrower scopes that they imply. A token with a broader scope satisfies a need for each scope that the scope implies, also through more than one rule. The required scopes of the server use the hierarchy.

const rs: mcp.auth.ResourceServer = .{
    // ... the fields of the example above ...
    .required_scopes = &.{"mcp:read"},
    .scope_hierarchy = &.{
        .{ .scope = "mcp:admin", .implies = &.{"mcp:write"} },
        .{ .scope = "mcp:write", .implies = &.{"mcp:read"} },
    },
};

A handler checks the scope of one operation with rs.hasScope(principal, "mcp:write"). mcp.auth.resource_server.scopeSatisfied(hierarchy, held, needed) gives the same rule without a resource server.

Authorization extensions

The SDK provides the two official authorization extensions13 and two draft extensions. Client Credentials, Enterprise-Managed Authorization and Workload Identity Federation replace the interactive flow of OAuthClient for the HTTP client transport. Give the provider of the extension to the transport with the auth_provider option. The client never opens a browser in these flows. DPoP works with each flow.

Extension Identifier Client type
OAuth Client Credentials io.modelcontextprotocol/oauth-client-credentials mcp.auth.ClientCredentials
Enterprise-Managed Authorization io.modelcontextprotocol/enterprise-managed-authorization mcp.auth.EnterpriseClient
DPoP (draft) io.modelcontextprotocol/auth/dpop mcp.auth.DpopProver in the dpop option of a flow
Workload Identity Federation (draft) io.modelcontextprotocol/auth/wif mcp.auth.WorkloadIdentity

The drafts of DPoP22 and Workload Identity Federation23 name no identifier. The SDK uses the identifiers of the official conformance suite. These identifiers can change when the drafts become stable.

Declare the extension in the client capabilities with mcp.auth.withExtension(arena, capabilities, id). The function keeps the other extensions. Declare it in the server capabilities with the authorization_extensions option of mcp.Server. All fields of that option are false by default.

var server = try mcp.Server.init(gpa, io, .{
    .info = .{ .name = "files", .version = "1.0.0" },
    .authorization_extensions = .{ .client_credentials = true, .enterprise_managed = true, .dpop = true, .workload_identity = true },
});

Client authentication

mcp.auth.ClientAuth tells how a client authenticates at a token endpoint. ClientCredentials, EnterpriseClient and WorkloadIdentity use it.

  • none: a public client. The client sends client_id in the request body. ClientCredentials refuses it with error.ClientAuthenticationRequired.
  • client_secret: a client id and a secret. The client uses client_secret_basic when the server lists it or lists no methods, else client_secret_post. Set method to use one method only. When the server does not list the method, the client stops with error.AuthMethodUnsupported.
  • private_key_jwt: a signed JWT assertion14. The iss and sub claims are the client id. The aud claim is the value of audience, else the issuer identifier of the authorization server. The assertion has a random jti, and the default lifetime (lifetime_seconds) is 300 seconds.

With none and client_secret_post, the request body has client_id. With client_secret_basic and private_key_jwt, the body has no client_id.

mcp.auth.jwt.SigningKey.fromPem(gpa, text) loads the first PKCS#8, SEC1 or PKCS#1 private key of a PEM text. SigningKey.fromDer(bytes) loads a DER key. The key signs with ES256, ES384, EdDSA (Ed25519) or RS256. usePss() changes an RS256 key into a PS256 key. A PKCS#8 key with the algorithm id-RSASSA-PSS gives PS256. When its parameters name a hash other than SHA-256, the load gives error.UnsupportedKey.

An RSA key has 2048 to 4096 bits. The client stops with error.SigningAlgorithmUnsupported when the server lists signing algorithms and the algorithm of the key is not one of them.

Use client credentials

The OAuth Client Credentials extension15 is for a machine client without a user. The client has credentials that the administrator of the authorization server issued. The extension does not use dynamic client registration.

const pem = try std.Io.Dir.cwd().readFileAlloc(io, "client.key", gpa, .limited(1 << 16));
defer gpa.free(pem);
var key = try mcp.auth.jwt.SigningKey.fromPem(gpa, pem);
defer key.deinit();
var cc: mcp.auth.ClientCredentials = .init(io, gpa, .{
    .client = .{ .private_key_jwt = .{ .client_id = "reporting-bot", .key = &key, .kid = "2026-09" } },
});
defer cc.deinit();
const http = try mcp.transport.HttpClient.init(io, gpa, .{ .url = url, .auth_provider = cc.provider() });
defer http.deinit();
var client: mcp.Client = .init(gpa, io, .{
    .info = .{ .name = "reporting-bot", .version = "1.0.0" },
    .capabilities = try mcp.auth.withExtension(arena, .{}, mcp.auth.client_credentials.extension_id),
});
defer client.deinit();
client.connect(http.transport());

For a client secret, set .client = .{ .client_secret = .{ .client_id = "reporting-bot", .client_secret = secret } }.

What the client credentials client does on a challenge

  1. It reads the protected resource metadata, as the interactive flow does. The resource must cover the server URL, else the client stops with error.ResourceMismatch. It uses the first authorization server of the list.
  2. It reads the authorization server metadata. The issuer must equal the issuer that the resource metadata names.
  3. It refuses a server that lists grant types without client_credentials. The token endpoint must use https.
  4. It selects the scope. It joins the scope option and the scope of the challenge. Without both, it uses scopes_supported of the resource. On a step-up, it also keeps the scopes it holds.
  5. It sends grant_type=client_credentials, the resource indicator8 and the scope, with the client authentication.
  6. It keeps the token, the granted scope and the expiry from expires_in.

The client stops after max_step_up_attempts (3) challenges for one request.

Before each request, the client checks the expiry. When the token expires within refresh_margin_seconds (60 by default), the client requests a new token first. When that request fails, the client sends the old token until it expires. Without expires_in, the client does not know the expiry and gets a new token only on a challenge. The grant has no refresh token. After a failed token request, last_error has the error code of the server.

Use enterprise-managed authorization

The Enterprise-Managed Authorization extension16 applies the Identity Assertion JWT Authorization Grant (ID-JAG)17. The enterprise identity provider (IdP) decides which users can use which MCP servers. The user signs in to the application one time through the IdP. The application gives the identity assertion, usually an ID token, to EnterpriseClient.

var ema: mcp.auth.EnterpriseClient = .init(io, gpa, .{
    .idp = .{
        .issuer = "https://acme.idp.example",
        .client = .{ .client_secret = .{ .client_id = idp_client_id, .client_secret = idp_secret } },
    },
    .assertion = .{ .callback = .{ .userdata = &app, .obtain = App.currentIdToken } },
    .client = .{ .client_secret = .{ .client_id = mcp_client_id, .client_secret = mcp_secret } },
});
defer ema.deinit();
const http = try mcp.transport.HttpClient.init(io, gpa, .{ .url = url, .auth_provider = ema.provider() });

assertion is one of:

  • static: an assertion that the application got at sign-in.
  • callback: a function that returns the current assertion. The application can get a new ID token from the IdP here, for example with a refresh token.

The type of the assertion is id_token, saml2 or refresh_token. The default is id_token.

What the enterprise client does on a challenge

  1. It finds the MCP authorization server in the protected resource metadata and reads its metadata.
  2. It refuses a server that lists grant types without the JWT bearer grant. With require_grant_profile, it also refuses a server that does not list urn:ietf:params:oauth:grant-profile:id-jag in authorization_grant_profiles_supported.
  3. It sends a token exchange18 to the IdP with requested_token_type=urn:ietf:params:oauth:token-type:id-jag. The assertion is the subject_token, and its type is the subject_token_type. The audience is the issuer identifier of the MCP authorization server. The resource is the resource identifier of the MCP server, when send_resource is true (the default).
  4. It makes sure that issued_token_type is the ID-JAG type. It keeps the ID-JAG with its expiry from expires_in, else from the exp claim.
  5. It sends the ID-JAG to the MCP authorization server with grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer14. It authenticates with client.
  6. It keeps the access token and its expiry.

The client identity in client must be the client that the IdP names in the client_id claim of the ID-JAG. The client does not read that claim.

The client discovers the token endpoint of the IdP from idp.issuer, and the discovered issuer must equal idp.issuer. Set idp.token_endpoint to skip the discovery. Without both, the client stops with error.NoIdpTokenEndpoint. The client authenticates at the IdP with idp.client. The specification requires the same authentication that the client uses for single sign-on at the IdP. The token endpoints must use https.

When the access token expires soon, the client sends the held ID-JAG again while it is valid. When the MCP authorization server refuses that ID-JAG, the client does one new token exchange and tries again. When the ID-JAG expired, the client does a new token exchange with the current assertion. Each challenge also starts a new token exchange. The client never sends the user to the authorization endpoint of the MCP authorization server.

Validate an ID-JAG

An application that runs its own authorization server validates the ID-JAG of a JWT bearer grant with mcp.auth.IdJagValidator. The validator applies the processing rules of the ID-JAG draft17 and of RFC 752119. The option grants.id_jag of the Authorization Server takes such a validator.

const idp_keys = try mcp.auth.jwt.parseJwks(arena, jwks_document);
const validator: mcp.auth.IdJagValidator = .{
    .issuer = "https://auth.chat.example/",
    .trusted_issuers = &.{.{ .issuer = "https://acme.idp.example", .keys = idp_keys }},
    .resources = &.{"https://mcp.chat.example/"},
    .replay = .{ .userdata = &jti_store, .seen = JtiStore.seen },
};
const grant = validator.validate(arena, form.assertion, authenticated_client_id, now) catch |err| {
    return respond(400, try mcp.auth.enterprise.errorResponse(arena, err));
};

The validator does these checks:

  • The typ header is oauth-id-jag+jwt. The comparison ignores the case and an application/ prefix.
  • The iss is a trusted IdP, and a key of that IdP verifies the signature.
  • The claims iss, sub, aud, client_id, jti, exp and iat are present. The token is not expired, with a clock skew of 60 seconds. The lifetime is 600 seconds or less by default (max_lifetime_seconds).
  • The aud is the issuer identifier of the authorization server: one string, or an array with exactly one element.
  • The client_id claim is the client that authenticated the request.
  • When the ID-JAG has a resource claim and resources is not empty, the claim names at least one resource in resources. Grant.resources keeps only those resources.
  • The optional replay check did not see the jti before. This check runs last.

Each failure is an invalid_grant error. errorResponse gives the body of the token error response. The Grant has the issuer, the subject, the client, the jti, the times, the resources, the scopes, the email claim and all claims. The authorization server applies its own policy to them. The authorization server must restrict the access token to the MCP server that the resource claim names.

mcp.auth.jwt.parseJwks(arena, text) reads a JWK set20 with the key types EC (P-256, P-384), OKP (Ed25519) and RSA. It skips keys with a use other than sig, keys with an unknown alg and other curves. An RSA key without alg gives two keys, one for RS256 and one for PS256.

Use DPoP-bound tokens

DPoP21 binds an access token to a key pair of the client. A stolen token is of no use without the private key. The DPoP extension of MCP22 applies RFC 9449 without changes. Each request to the MCP server carries a new proof: a JWT that the private key signs.

var prover = try mcp.auth.DpopProver.generate(io, gpa);
defer prover.deinit();
var oauth: mcp.auth.OAuthClient = .init(io, gpa, .{
    .registration = .{ .client_metadata_url = "https://my.example/client-metadata.json" },
    .authorize = .{ .callback = .{ .open = openInBrowser } },
    .dpop = &prover,
});

DpopProver.generate makes a new random P-256 key (ES256). DpopProver.init takes an ES256, ES384, EdDSA, RS256 or PS256 key of mcp.auth.jwt.SigningKey. An HS256 key gives error.SymmetricKey. The option dpop of OAuthClient, ClientCredentials, EnterpriseClient and WorkloadIdentity takes a prover. One prover can serve more than one flow.

What the client does with DPoP

  1. It sends a proof in the DPoP header of each token request. The htu claim is the URL of the token endpoint.
  2. It stops with error.DpopAlgorithmUnsupported when dpop_signing_alg_values_supported does not list the algorithm of the key.
  3. When the token endpoint answers use_dpop_nonce with a DPoP-Nonce header, the client keeps the nonce. Then it sends the request again.
  4. The token_type DPoP gives a DPoP-bound token. Another token_type gives a bearer token from a server without DPoP.
  5. The HTTP client transport sends Authorization: DPoP and a new proof with each request. The proof has the ath of the token and a new jti.
  6. When the MCP server answers 401 with use_dpop_nonce, the transport keeps the nonce. It sends the request again with the same token.

The prover keeps the last nonce of each origin. A DPoP-Nonce header of any response replaces the nonce. A DPoP-bound token never goes out with the Bearer scheme. OAuthClient also sends dpop_jkt in the authorization request, so that the authorization server can bind the code to the key. Set dpop_bound_access_tokens to register a client that uses DPoP only.

Protect a server with DPoP

const nonces = try mcp.auth.dpop.NonceIssuer.init(io);
const policy: mcp.auth.DpopPolicy = .{ .io = io, .required = true, .verify = .{ .nonce = &nonces } };
const rs: mcp.auth.ResourceServer = .{
    // ... the fields of the example above ...
    .dpop = &policy,
};

With the dpop option the HTTP server does this:

  • It accepts Authorization: DPoP with exactly one DPoP header. With required, it refuses bearer tokens. Without required, it accepts both schemes.
  • It checks the proof with the rules of RFC 9449 section 4.3: typ, an asymmetric alg, a public jwk, the signature, htm, htu and ath. The iat must be within 5 minutes of the clock, as the extension tells.
  • The thumbprint of the proof key24 must be the jkt of the cnf claim of the token.
  • The htu must be resource. Set resource to the URL that clients send requests to, also behind a reverse proxy.
  • A failed check gets 401 with WWW-Authenticate: DPoP error="invalid_dpop_proof".
  • A token with cnf.jkt and the Bearer scheme gets 401 with invalid_token. Thus a stolen token does not work as a bearer token.
  • With verify.nonce, a proof without a valid nonce gets 401 with use_dpop_nonce and a new nonce in the DPoP-Nonce header.
  • A request without a token gets a challenge for each scheme that the server accepts. The protected resource metadata has dpop_signing_alg_values_supported.

The token verifier must give the jkt of the cnf claim in Principal.confirmation. JwtVerifier reads it. A custom TokenVerifier must set it, else the server cannot refuse a bound token with the Bearer scheme.

A nonce of NonceIssuer is an encrypted timestamp, so the server keeps no state. Each nonce is valid for 5 minutes (lifetime_seconds). Servers that share the key accept the nonces of each other. Without nonces, the window of 5 minutes limits the replay of a proof. For more protection, verify.replay takes a store that refuses a jti that it saw before.

The gRPC binding accepts bearer tokens only. The Streamable HTTP transport and the WebSocket transport accept DPoP-bound tokens. On WebSocket, the proof is for the upgrade request, with the htm claim GET.

An authorization server of the application checks the proof of a token request with mcp.auth.dpop.verifyProof. It puts Proof.jkt into the cnf.jkt claim of the token and answers with the token_type DPoP. The Authorization Server of the SDK does these steps for each grant.

Use workload identity federation

The Workload Identity Federation extension23 is for a client that runs as a workload, for example in Kubernetes or on a cloud platform. The platform issues a JWT to the workload, for example a projected service account token or a SPIFFE JWT. The client gives this JWT to the authorization server as a JWT authorization grant14. The client needs no client ID, no secret and no registration.

var wif: mcp.auth.WorkloadIdentity = .init(io, gpa, .{
    .assertion = .{ .file = "/var/run/secrets/tokens/mcp-token" },
});
defer wif.deinit();
const http = try mcp.transport.HttpClient.init(io, gpa, .{ .url = url, .auth_provider = wif.provider() });

assertion is one of:

  • static: a JWT that the application got.
  • file: a file that the platform writes. The client reads the file for each token request, so it always sends the current JWT.
  • callback: a function that gets the issuer and the token endpoint of the authorization server and returns a JWT. Use it when the platform issues a JWT for an audience on request.

What the workload identity client does on a challenge

  1. It reads the protected resource metadata and the authorization server metadata, as the client credentials client does.
  2. It refuses a server that lists grant types without urn:ietf:params:oauth:grant-type:jwt-bearer.
  3. It gets the workload JWT from the source.
  4. It sends grant_type, assertion, resource and the scope. It sends client authentication only when you set client.
  5. It keeps the token and gets a new one before the token expires.

When the server refuses the JWT, the flow stops with error.TokenRequestFailed. Then last_error has the error code. The client does not change the grant type, and it does not send the same JWT again. The next challenge gives error.AssertionRefused until the source gives a new JWT.

Validate a workload JWT

An application that runs its own authorization server validates the workload JWT of a JWT bearer grant with mcp.auth.WorkloadJwtValidator. The option grants.workload of the Authorization Server takes such a validator.

var discovery: mcp.auth.workload_identity.KeyDiscovery = .init(io, gpa, .{});
defer discovery.deinit();
const validator: mcp.auth.WorkloadJwtValidator = .{
    .audiences = &.{"https://auth.example.com/token"},
    .trusted_issuers = &.{.{ .issuer = "https://issuer.example.org" }},
    .discovery = &discovery,
    .policy = .{ .userdata = &allow_list, .allow = AllowList.allow },
};
const grant = validator.validate(arena, form.assertion, now) catch |err| {
    return respond(400, try mcp.auth.workload_identity.errorResponse(arena, err));
};

The validator does these checks:

  • The iss is a trusted issuer, and a key of that issuer verifies the signature. The algorithm is asymmetric.
  • The claims iss, sub, aud, exp, iat and jti are present. The token is not expired, with a clock skew of 60 seconds. The lifetime is 3600 seconds or less by default (max_lifetime_seconds).
  • The aud names one of audiences. The extension requires the URL of the token endpoint.
  • The policy accepts the workload. Without a policy, each workload of a trusted issuer gets a token. We recommend an allow list of subjects.
  • The optional replay check did not see the jti before.

Each failure is an invalid_grant error. The sub of the Grant names the workload. It is not a client ID.

KeyDiscovery gets the keys of an issuer without static keys with OpenID Connect Discovery25. It reads {issuer}/.well-known/openid-configuration, checks its issuer, and reads the JWK set at jwks_uri. Both URLs must use https. The keys stay in a cache for one hour. A JWT with an unknown kid starts a new fetch, at most once in a minute.

Conformance

The official conformance suite runs 25 scored auth/* scenarios for revision 2026-07-28. The client passes all of them. The suite also runs six scenarios for the authorization extensions that it does not score. The client passes all six: auth/client-credentials-jwt, auth/client-credentials-basic, auth/enterprise-managed-authorization, auth/dpop, auth/dpop-nonce and auth/wif-jwt-bearer. The authorization server of the SDK passes the two authorization server scenarios. See Conformance.

References

  1. OAUTH21-DRAFT: The OAuth 2.1 Authorization Framework.
  2. RFC9728: OAuth 2.0 Protected Resource Metadata.
  3. RFC8414: OAuth 2.0 Authorization Server Metadata.
  4. OAUTH-CIMD-DRAFT: Client ID Metadata Documents.
  5. RFC7591: OAuth 2.0 Dynamic Client Registration Protocol.
  6. MCP-AUTH-CLIENTREG-2026-07-28: Client registration.
  7. RFC7636: Proof Key for Code Exchange.
  8. RFC8707: Resource Indicators for OAuth 2.0.
  9. RFC9207: OAuth 2.0 Authorization Server Issuer Identification.
  10. RFC6750: The OAuth 2.0 Authorization Framework: Bearer Token Usage.
  11. MCP-AUTH-SECURITY-2026-07-28: Security requirements of authorization, with the token passthrough rule.
  12. MCP-AUTH-2026-07-28: Authorization overview.
  13. MCP-EXT-AUTH: The MCP authorization extensions.
  14. RFC7523: JSON Web Token (JWT) Profile for OAuth 2.0 Client Authentication and Authorization Grants.
  15. MCP-EXT-AUTH-CLIENT-CREDENTIALS: OAuth Client Credentials Extension.
  16. MCP-EXT-AUTH-ENTERPRISE: Enterprise-Managed Authorization.
  17. OAUTH-ID-JAG-DRAFT: Identity Assertion JWT Authorization Grant.
  18. RFC8693: OAuth 2.0 Token Exchange.
  19. RFC7521: Assertion Framework for OAuth 2.0 Client Authentication and Authorization Grants.
  20. RFC7517: JSON Web Key (JWK).
  21. RFC9449: OAuth 2.0 DPoP.
  22. MCP-EXT-AUTH-DPOP: DPoP Profile for MCP.
  23. MCP-EXT-AUTH-WIF: Workload Identity Federation.
  24. RFC7638: JSON Web Key (JWK) Thumbprint.
  25. OIDC-DISCOVERY: OpenID Connect Discovery 1.0.
  26. FDO-SECRET-SERVICE: Secret Service API Draft.
  27. FDO-DBUS-SPEC: D-Bus Specification.

Clone this wiki locally