Repository navigation
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.
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 ofOAuthClient.Credentials. Each entry has theissuerof the authorization server, aclient_idand an optionalclient_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 usehttpsand have a path other than/. It must not have user information, a fragment, or a.or..segment. Another URL giveserror.InvalidClientMetadataUrl. -
dynamic: dynamic client registration only5. The registration sendsapplication_typefrom the option of the same name. The default isnative. Set.webfor 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.
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 theLocationheader. 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.
- It parses the
WWW-Authenticateheader forresource_metadata,scopeanderror. - It fetches the protected resource metadata from the header URL, else from the path-inserted well-known location, else from the root. The
resourcemust be the server URL or a parent of it, else the flow stops. The comparison ignores the case of the scheme and the host. - It fetches the authorization server metadata in the order the specification gives for issuers with and without a path. The
issuermust equal the URL of the query, else the flow stops. - It registers once per issuer. A new issuer in the metadata discards the registration and the tokens of the old one6.
- It selects the scope: the challenge, else
scopes_supportedof the resource, else none. On a403it unites the scopes it holds with the challenged scope. It addsoffline_accesswhen the authorization server lists it. - It sends the authorization request with PKCE S2567,
stateand theresourceindicator8. It refuses a server that does not listS256. - It validates
stateandiss9. Theisscomparison is a string comparison without normalization. When the server advertisediss, a missingissis an error. - It requests the token with the client authentication method the server supports:
client_secret_basic,client_secret_postornone.
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.
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.
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
401challenge, the client tries the refresh first. A step-up challenge (403orinsufficient_scope) needs a new grant, so the client runs the authorization code flow. - The refresh request has
grant_type=refresh_token, theresourceindicator 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_typeorinvalid_scope, the client discards the tokens and runs the authorization code flow. When the server refuses the client withinvalid_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.
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:
- At the first challenge for an issuer, it loads the record. A valid stored token goes out without registration and authorization.
- A stored token that expires soon gets a refresh. The user sees no browser.
- It saves the record after each registration, token response and refresh.
- It deletes the record when the protected resource names another authorization server.
- 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. - When the server refuses the refresh token, it removes the tokens from a record that no other client changed.
- It skips stored tokens of another DPoP key, because such a token does not work with the new key.
- 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. |
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.
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.
| 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.
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/mcpfor the endpoint/mcp2. - A request without a token gets
401withWWW-Authenticate: Bearer resource_metadata="...", scope="...". - A request with a token that is not a bearer token gets
400witherror="invalid_request". - A token that does not verify gets
401witherror="invalid_token". - A token without a required scope gets
403witherror="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.
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.
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 },
});mcp.auth.ClientAuth tells how a client authenticates at a token endpoint. ClientCredentials, EnterpriseClient and WorkloadIdentity use it.
-
none: a public client. The client sendsclient_idin the request body.ClientCredentialsrefuses it witherror.ClientAuthenticationRequired. -
client_secret: a client id and a secret. The client usesclient_secret_basicwhen the server lists it or lists no methods, elseclient_secret_post. Setmethodto use one method only. When the server does not list the method, the client stops witherror.AuthMethodUnsupported. -
private_key_jwt: a signed JWT assertion14. Theissandsubclaims are the client id. Theaudclaim is the value ofaudience, else the issuer identifier of the authorization server. The assertion has a randomjti, 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.
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 } }.
- It reads the protected resource metadata, as the interactive flow does. The
resourcemust cover the server URL, else the client stops witherror.ResourceMismatch. It uses the first authorization server of the list. - It reads the authorization server metadata. The
issuermust equal the issuer that the resource metadata names. - It refuses a server that lists grant types without
client_credentials. The token endpoint must usehttps. - It selects the scope. It joins the
scopeoption and the scope of the challenge. Without both, it usesscopes_supportedof the resource. On a step-up, it also keeps the scopes it holds. - It sends
grant_type=client_credentials, theresourceindicator8 and the scope, with the client authentication. - 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.
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.
- It finds the MCP authorization server in the protected resource metadata and reads its metadata.
- 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 listurn:ietf:params:oauth:grant-profile:id-jaginauthorization_grant_profiles_supported. - It sends a token exchange18 to the IdP with
requested_token_type=urn:ietf:params:oauth:token-type:id-jag. The assertion is thesubject_token, and its type is thesubject_token_type. Theaudienceis the issuer identifier of the MCP authorization server. Theresourceis the resource identifier of the MCP server, whensend_resourceis true (the default). - It makes sure that
issued_token_typeis the ID-JAG type. It keeps the ID-JAG with its expiry fromexpires_in, else from theexpclaim. - It sends the ID-JAG to the MCP authorization server with
grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer14. It authenticates withclient. - 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.
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
typheader isoauth-id-jag+jwt. The comparison ignores the case and anapplication/prefix. - The
issis a trusted IdP, and a key of that IdP verifies the signature. - The claims
iss,sub,aud,client_id,jti,expandiatare 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
audis the issuer identifier of the authorization server: one string, or an array with exactly one element. - The
client_idclaim is the client that authenticated the request. - When the ID-JAG has a
resourceclaim andresourcesis not empty, the claim names at least one resource inresources.Grant.resourceskeeps only those resources. - The optional replay check did not see the
jtibefore. 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.
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.
- It sends a proof in the
DPoPheader of each token request. Thehtuclaim is the URL of the token endpoint. - It stops with
error.DpopAlgorithmUnsupportedwhendpop_signing_alg_values_supporteddoes not list the algorithm of the key. - When the token endpoint answers
use_dpop_noncewith aDPoP-Nonceheader, the client keeps the nonce. Then it sends the request again. - The
token_typeDPoPgives a DPoP-bound token. Anothertoken_typegives a bearer token from a server without DPoP. - The HTTP client transport sends
Authorization: DPoPand a new proof with each request. The proof has theathof the token and a newjti. - When the MCP server answers
401withuse_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.
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: DPoPwith exactly oneDPoPheader. Withrequired, it refuses bearer tokens. Withoutrequired, it accepts both schemes. - It checks the proof with the rules of RFC 9449 section 4.3:
typ, an asymmetricalg, a publicjwk, the signature,htm,htuandath. Theiatmust be within 5 minutes of the clock, as the extension tells. - The thumbprint of the proof key24 must be the
jktof thecnfclaim of the token. - The
htumust beresource. Setresourceto the URL that clients send requests to, also behind a reverse proxy. - A failed check gets
401withWWW-Authenticate: DPoP error="invalid_dpop_proof". - A token with
cnf.jktand the Bearer scheme gets401withinvalid_token. Thus a stolen token does not work as a bearer token. - With
verify.nonce, a proof without a valid nonce gets401withuse_dpop_nonceand a new nonce in theDPoP-Nonceheader. - 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.
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.
- It reads the protected resource metadata and the authorization server metadata, as the client credentials client does.
- It refuses a server that lists grant types without
urn:ietf:params:oauth:grant-type:jwt-bearer. - It gets the workload JWT from the source.
- It sends
grant_type,assertion,resourceand the scope. It sends client authentication only when you setclient. - 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.
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
issis a trusted issuer, and a key of that issuer verifies the signature. The algorithm is asymmetric. - The claims
iss,sub,aud,exp,iatandjtiare 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
audnames one ofaudiences. The extension requires the URL of the token endpoint. - The
policyaccepts 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
jtibefore.
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.
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.
- OAUTH21-DRAFT: The OAuth 2.1 Authorization Framework.
- RFC9728: OAuth 2.0 Protected Resource Metadata.
- RFC8414: OAuth 2.0 Authorization Server Metadata.
- OAUTH-CIMD-DRAFT: Client ID Metadata Documents.
- RFC7591: OAuth 2.0 Dynamic Client Registration Protocol.
- MCP-AUTH-CLIENTREG-2026-07-28: Client registration.
- RFC7636: Proof Key for Code Exchange.
- RFC8707: Resource Indicators for OAuth 2.0.
- RFC9207: OAuth 2.0 Authorization Server Issuer Identification.
- RFC6750: The OAuth 2.0 Authorization Framework: Bearer Token Usage.
- MCP-AUTH-SECURITY-2026-07-28: Security requirements of authorization, with the token passthrough rule.
- MCP-AUTH-2026-07-28: Authorization overview.
- MCP-EXT-AUTH: The MCP authorization extensions.
- RFC7523: JSON Web Token (JWT) Profile for OAuth 2.0 Client Authentication and Authorization Grants.
- MCP-EXT-AUTH-CLIENT-CREDENTIALS: OAuth Client Credentials Extension.
- MCP-EXT-AUTH-ENTERPRISE: Enterprise-Managed Authorization.
- OAUTH-ID-JAG-DRAFT: Identity Assertion JWT Authorization Grant.
- RFC8693: OAuth 2.0 Token Exchange.
- RFC7521: Assertion Framework for OAuth 2.0 Client Authentication and Authorization Grants.
- RFC7517: JSON Web Key (JWK).
- RFC9449: OAuth 2.0 DPoP.
- MCP-EXT-AUTH-DPOP: DPoP Profile for MCP.
- MCP-EXT-AUTH-WIF: Workload Identity Federation.
- RFC7638: JSON Web Key (JWK) Thumbprint.
- OIDC-DISCOVERY: OpenID Connect Discovery 1.0.
- FDO-SECRET-SERVICE: Secret Service API Draft.
- FDO-DBUS-SPEC: D-Bus Specification.
zig-sdk uses the Apache License 2.0. The wiki uses the project profile of ASD-STE100 Simplified Technical English. The Bibliography lists the sources.
zig-sdk
- Home
- Getting Started
- Concepts
- Server Guide
- Client Guide
- Transports
- Transport stdio
- Transport Streamable HTTP
- Transport gRPC
- Transport Unix Socket
- Transport WebSocket
- TLS and Certificates
- Deploy Reverse Proxy
- Authorization
- Authorization Server
- JSON Schema Support
- Resource Templates
- Tasks Extension
- Skills Extension
- Apps Extension
- Conformance
- Examples
- API Reference
- Interoperability
- Threat Model
- Limits and Security
- Deprecated Features
- Roadmap
- Release Notes
- Versioning
- Contributing
- FAQ
Style and words