Skip to main content
Dejan Demonjić brand logoDejan Demonjić
Decision log
Work
About
Contact

Decision log / Decision record

Mutual TLS and certificate-bound tokens for the links that matter

Client credentials alone let a token travel. For the links between a site and its cloud, and between an assistant's tool servers and the API, every token is bound to the client's certificate (RFC 8705), tokens are exchanged instead of forwarded (RFC 8693), and resources announce their issuer (RFC 9728). The decision record, with the parts that only building it revealed.

Filed
2026-10-16
Topic
Identity and security
Reading
8 min
Tags
#oauth#mtls#openiddict#security

Part 5 of 5 Identity and access

  1. One identity server for the cloud and for every site
  2. Decide from the endpoint, not from the path
  3. When the token store is down, the honest answer is 503
  4. A Content Security Policy for an identity server
  5. Mutual TLS and certificate-bound tokens for the links that matter
Previous A Content Security Policy for an identity server

The claim

For a machine client whose token would be worth stealing, client credentials are the start, not the end. The token should be bound to the client's TLS certificate, so that holding the token without the key is worthless; a service that receives a token should exchange it for its own rather than forward it; and a protected resource should say who its issuer is. OAuth has a standard for each. This is how Hexavera applies them, and what only came to light while building it.

Context: three tiers of trust

Hexavera connects access-control hardware at a site to a site runtime (the gateway, in the tier names below), and the site runtime to a cloud. In 2026 the long argument about "mutual TLS or client credentials" was settled by separating the tiers instead of picking one:

Tier Link Posture
1 panel to device service, panel to gateway the customer's choice on the customer's network: certificates where the hardware allows them, weaker where it does not
2 device service to gateway as protected as possible, one posture, always on
3 gateway to cloud as protected as possible, one posture, always on

Tier 1 has one rule of its own: a weaker mode is permitted and explicit, never the silent default. The ruling asks for a named per-deployment mode, visible at startup and in a status surface, and for events that arrive over an unauthenticated path to be marked so a forged one stays distinguishable from a real one. The ruling is recorded; the mechanism is not built yet. "We allow it" has to show up in the product, not only in a design document.

For tier 3, and for the tool servers' link to the API, mutual TLS and client credentials are not alternatives. RFC 8705 composes them. Tier 2 carries no token at all: the device service authenticates with mutual TLS alone, on a certificate the site's own authority issued.

The standards

RFC 8705, "OAuth 2.0 Mutual-TLS Client Authentication and Certificate-Bound Access Tokens" (February 2020). Two client authentication methods: PKI, where the server validates the certificate chain and matches the subject, and self-signed, where the client registers its certificates as its key set and "the client's certificate chain is not validated by the server" (section 2.2). Certificate binding puts a cnf claim in the token whose x5t#S256 member is the base64url SHA-256 hash of the certificate (section 3.1); the protected resource reads the certificate from its own TLS layer, compares, and answers 401 invalid_token on a mismatch (section 3).

RFC 8693, "OAuth 2.0 Token Exchange" (January 2020). A grant type, urn:ietf:params:oauth:grant-type:token-exchange, under which a client presents a subject token and receives a token of its own. Delegation keeps the acting party visible: "the act (actor) claim provides a means within a JWT to express that delegation has occurred" (section 4.1). Impersonation hides it. Hexavera uses delegation.

RFC 9728, "OAuth 2.0 Protected Resource Metadata" (April 2025). A resource publishes a document at /.well-known/oauth-protected-resource naming its authorization servers, and a 401 may point a client at it with WWW-Authenticate: Bearer resource_metadata="..." (sections 3 and 5.1).

OWASP ASVS 5.0.0 puts sender-constrained tokens at level 3: the authorization server "issues only sender-constrained (Proof-of-Possession) access tokens" (10.4.14) and the resource server "prevents the use of stolen access tokens or replay" through them (10.3.5). For the tier 3 link, and for the tool servers' calls to the API, the level 3 requirement is applied on purpose.

The decisions

Self-signed certificates, registered as the client's key set. Each service client holds one or two certificates; two at a time so one replaces the other without a moment in which the client has neither. A certificate is accepted only self-issued, with the clientAuth extended key usage and the digitalSignature key usage, and with an RSA key of at least 2048 bits, which the decision record cites as NIST SP 800-57's floor through 2030; the certificates the sites make are RSA 3072, where ASVS 11.2.3 puts 128-bit security. The issuer's own signing keys are ES256; the reason the clients are RSA is below.

Mutual TLS on a listener of its own. The browser-facing listener never asks for a client certificate. Service clients dial a second listener that requires one, and the issuer's discovery document names that listener's token endpoint under mtls_endpoint_aliases.

Every token issued on a certificate is bound to it. In OpenIddict 7.7.1 this is two calls on the server options, plus one removal: private_key_jwt is switched off, because a certificate's key in a client's key set would otherwise also sign an assertion and obtain a token bound to nothing.

options.EnableSelfSignedTlsClientAuthentication()
       .UseClientCertificateBoundAccessTokens();

options.Configure(server =>
{
    server.ClientAuthenticationMethods.Remove(ClientAuthenticationMethods.PrivateKeyJwt);
    server.ClientAssertionTypes.Clear();
});

The resulting access token carries the binding:

{
  "iss": "https://identity.example",
  "aud": "example-api",
  "client_id": "example-site",
  "cnf": { "x5t#S256": "bwcK0esc3ACC3DB2Y5_lESsXE8o9ltc05O89jdN-dg2" }
}

The API refuses an unbound token from a service principal. A person's token is not bound (the browser-facing listener never asks for a client certificate), so the check is: a token whose subject is a service client must carry cnf, and its hash must match the certificate on the request.

No token is forwarded. The assistant reaches the platform through tool servers, and a tool server that received a person's token used to pass it on to the API. Now it exchanges it: the person's access token is the subject token, no actor token is sent, and the tool server, authenticated by its own certificate, is written into the result as the actor. The API admits an actor token only when the projection of clients shows that actor active, and still requires the binding.

Resources announce their issuer. Each tool server answers an unauthenticated request with 401 and the resource_metadata pointer, so a client discovers the issuer and the scopes from the resource itself, as RFC 9728 describes, instead of from configuration.

A disabled client's tokens stay revoked. A token issued at or before the second of the disable is refused even if the client is enabled again later; iat is in whole seconds, so the comparison is inclusive on purpose.

What only building it revealed

Why the client certificates are RSA. OpenIddict 7.7.1 trusts a certificate in a client's key set only as an X509SecurityKey, which Microsoft.IdentityModel 8.19.2 produces for an RSA key carrying the certificate in x5c; an EC key becomes an ECDsaSecurityKey, so a client presenting one would be refused. The issuer signs with ES256 and the clients prove themselves with RSA, and that asymmetry is documented where someone will go looking for it.

A certificate forwarded by an ingress is accepted under four conditions. Behind a proxy that terminates TLS, the client certificate arrives in a header. The host reads it only when the connection's own address is a configured known proxy; only when the header holds exactly one value (an ingress that appends would put the caller's own value first, so several values count as none, as does a value with unbalanced quotes or two PEM blocks); never over a certificate the connection itself presented; and it places the certificate on the request's TLS feature, not the connection's, because on a kept-alive hop from the proxy a connection-level certificate would carry over to the next request from a different caller. The header is removed from every request before anything else sees it.

Expiry is judged while a connection is open. A SignalR hub that admitted a service client once would keep serving it after the token expired or the client was disabled, so the host judges open connections again on a schedule; the second note of the realtime series describes how.

What it cost

  • Certificates to generate, register, rotate and back up, for every service client and every site.
  • A second listener on each host that accepts certificates, and proxy configuration that must name itself.
  • RSA for clients while the issuer uses EC, and a sentence explaining why wherever the two meet.
  • An exchange call on every tool invocation, in place of a forwarded header.

What a team can take from this

Bind tokens for every machine client whose token would matter in the wrong hands; the standard has been final since 2020 and the libraries have it. Never forward a caller's token through a service, exchange it and keep the actor visible. Treat a forwarded client certificate as hostile input with a short allowlist of conditions. And when you discover that a library constrains a choice (the key algorithm, here), write the constraint next to the configuration, because the next engineer will try to "fix" it.

DD

Dejan Demonjić

Principal software engineer, Belgrade

Designs and builds secure, multi-tenant .NET platforms. Questions about this entry, or a different view on it, are welcome by email.

  • Email
  • LinkedIn
  • GitHub
Previous Server-Sent Events in ASP.NET Core 10, and when to pick them over SignalRWhen a server talks and a client listens for the length of one request, Server-Sent Events is the right transport: plain HTTP, one direction, a text protocol a proxy carries and a browser reconnects by itself. .NET 10 made it a first-class result. The parts that bite are on the client: the protocol has rules, and some servers decide per response whether to answer with JSON or with a stream.Next A schema per tenant: the database owns the nameWhen every tenant's rows live in a PostgreSQL schema of their own, the mapping from identifier to schema name is a security boundary. So the mapping moved into the database: a stored generated column, a unique index, COLLATE "C", and a plain CREATE SCHEMA as the claim on a new name. With the two error codes that mean 'taken', the one-at-a-time gate, and an EF Core pooling detail that only production-shaped tests found.

In this entry

  1. The claim
  2. Context: three tiers of trust
  3. The standards
  4. The decisions
  5. What only building it revealed
  6. What it cost
  7. What a team can take from this
Dejan Demonjić

Designing and building secure, multi-tenant .NET platforms, and writing down the decisions behind them.

Read

  • Decision log
  • Work
  • RSS feed

Contact

  • dejan@demonjic.com
  • Write to me
  • About

Elsewhere

© Dejan Demonjić. Written in Belgrade.