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.
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.