Calling an API with a certificate-bound token — Versola Docs
/docs
Pre-1.00.7.5versola.ioGitHub

Calling an API with a certificate-bound token

The optional mutual-TLS port of edge for /resources. When a token bound to the caller's certificate (cnf.x5t#S256) is accepted, how edge reaches auth for userinfo, the configuration block, and what the plain port still refuses.

An API client such as a bank can hold an access token bound to its own client certificate (RFC 8705 §3). On the plain edge port the certificate of the TLS connection is not visible, so such a token is refused there. edge can run a second listener that terminates mutual TLS itself, reads the caller’s certificate from the handshake and honours the token for the holder of that certificate. This page explains how that works and how to turn it on. You need a running edge (Edge overview), a client registered with tls_client_auth and certificate-bound tokens (Client certificates and their renewal), and a server certificate for the public name your callers use.

What the listener does

  • It serves /resources/{resourceId}/{path} and nothing else. /login, /logout, /native, /permissions/me and the rest stay on the plain port.
  • It requires a client certificate in the handshake. A caller with no certificate, or one that does not chain to trusted-certificates, never reaches the HTTP layer.
  • The rest of the pipeline is the one the plain port runs: revocation, resource and endpoint lookup, permission, audience, fetchUserInfo, the allow rule, step-up, injection, forward. See Endpoint policies.

When a token is accepted

The token’s cnf claim decides, as everywhere in edge. On this listener:

TokenPresented asResult
cnf.x5t#S256Authorization: Bearer, certificate whose SHA-256 thumbprint equals the claimAccepted
cnf.x5t#S256Authorization: Bearer, any other certificate, even one the listener trusts401, certificate_bound_token_outside_session
cnf.x5t#S256the same token on the plain port401, certificate_bound_token_outside_session
cnf.jktAuthorization: BearerRefused (dpop_downgrade)
cnf.jkt and cnf.x5t#S256anyRefused. A token bound to both is refused on every presentation
no cnfAuthorization: BearerAccepted. The certificate was still required to connect, but nothing binds the token to it

The comparison is of the SHA-256 of the certificate’s DER encoding, base64url, with the claim, made in constant time. Trusting the issuer is not enough: a certificate from a trusted CA but not the one the token names is refused.

A DPoP scheme request on this listener is checked against edge-url for htu; the listener’s own address is not accepted as htu yet. Send DPoP-bound tokens to the plain port.

Turning it on

The listener is opt-in. Without a mutual-tls block edge opens no second port. Add the block, in the same shape as the auth listener:

mutual-tls {
  certificate          = "/certs/edge-mtls-server.crt"  # what the listener presents
  private-key          = "/certs/edge-mtls-server.key"  # unencrypted PKCS#8
  trusted-certificates = "/certs/callers-ca.crt"        # CAs that issue your callers' certificates
  external-url         = "https://api.example.com:8083"
}
KeyRequiredMeaning
certificateyesPEM path of the server certificate for the public name callers use
private-keyyesPEM path of its key, unencrypted PKCS#8
trusted-certificatesyesPEM path of the certificate authorities a caller’s chain is validated against. There is no default: falling back to the JDK trust store would let every public CA vouch for your callers
external-urlyesThe address callers use. For your own documentation and tooling: a deployment behind NAT or a TCP-passthrough balancer binds on one address and publishes another

The port is not a key. It comes from MPORT (default 8083) and the bind address from MBIND_HOST. The listener needs ClientAuth.Required, so there is no mode where the certificate is optional.

Only the local development target writes this block. versola configure for vps and docker-local, and the Helm chart, write none and issue no server certificate for it: you provide both.

The CA that issued the caller’s certificate must be in trusted-certificates here, and it is normally the CA that is in auth’s mutual-tls.trusted-certificates for the same client.

How edge gets the user’s claims

A caller’s token bound to a certificate is checked at auth’s /userinfo as strictly as at the proxy. edge holds neither the caller’s private key nor a way to forward the caller’s certificate to auth. So for an endpoint with fetchUserInfo on, edge calls auth over its own certificate, the one it also presents to central, on the mutual-TLS listener of auth (native.auth-mutual-tls-url). auth serves that call without the DPoP and holder-certificate checks only when the certificate is an edge’s and was issued by the dedicated edge CA. The rule, and what it is worth as a security boundary, is on Concepts: Sender-constrained tokens.

Two consequences:

  • edge needs the native block even when it serves no native app, because that block holds the address and pins of auth’s listener. Without it, an endpoint with fetchUserInfo answers 500 for a certificate-bound token. See Edge configuration.
  • auth needs mutual-tls.edge-trusted-certificates. Without it no certificate is an edge’s and /userinfo refuses the call.

Endpoints that do not read user and have fetchUserInfo off do not call /userinfo at all.

Security notes

  • The edge certificate’s private key is the most sensitive key of the deployment. Whoever holds it can call /userinfo as an edge, for any token, with no proof of possession.
  • Whoever holds it can call /userinfo as that edge for any token of the tenants assigned to the edge. auth trusts an edge certificate only for an edge central still lists and only for its assigned tenants.
  • Use a dedicated CA for edge certificates. Never the one that issues clients’ certificates: a CA that can sign urn:versola:edge:<id> for a client could let that client pose as an edge. This is why auth checks the issuer as well as the name, and logs a warning at startup when one CA serves both roles.
  • Keep edge certificates short-lived and rotate them: a certificate’s own validity cannot be shortened. The vps bundle issues them for 30 days and edge renews them. Deleting an edge in central, or unassigning its tenants, cuts it off at auth after the next configuration refresh (configuration-cache-refresh-interval), not at expiry.
  • Make auth’s mutual-TLS listener reachable only from the network the edges run in.
  • A certificate sent in a forwarded header is never taken for an edge’s. Only the certificate of the TLS session counts.

Operational notes

  • Upgrade auth and edge together. A new edge sends no assertion and a new auth ignores one. The older assertion mechanism is gone, together with GET /configuration/edges/registry and the dpop_edge_assertion_* metrics.
  • Monitor. A refusal on this listener is logged like any other edge request, with the error key certificate_bound_token_outside_session for a wrong certificate. A handshake that fails produces no request line: look at the TLS or load-balancer log.

Failure modes

SymptomCauseFix
Handshake refusedNo client certificate, or it does not chain to trusted-certificatesAdd the caller’s CA to the file and restart edge
401 certificate_bound_token_outside_session on the mutual-TLS portThe token was issued for another certificate than the one in the handshake. The caller renewed its certificate and kept an old tokenGet a new token with the current certificate. A renewed certificate has a new thumbprint
401 certificate_bound_token_outside_session on the plain portWrong portUse the mutual-TLS address
500 on an endpoint with fetchUserInfoedge has no native block, or auth has no edge-trusted-certificates, or the certificate is not from that CASee How edge gets the user’s claims
/login answers 404 on the mutual-TLS portBy designUse the plain port

Next steps