Sender-constrained tokens — Versola Docs
/docs
Pre-1.00.7.5versola.ioGitHub

Sender-constrained tokens

How DPoP and mTLS certificate-bound access tokens work in Versola, which fields and config keys control them, and how edge enforces them.

A sender-constrained access token is bound to a key or a certificate that the client holds. A thief who copies the token cannot use it without that key or certificate. Versola supports two mechanisms: DPoP (RFC 9449) and mTLS certificate-bound tokens (RFC 8705).

Why bearer tokens are risky

A bearer token works for whoever presents it. If it leaks from a log, a proxy, a browser extension or a crash report, the holder can call your API until it expires. Binding the token to a key moves the proof from “I have the string” to “I have the string and the private key”.

Tenants on the fapi2 security profile require every client to use one of the two mechanisms.

DPoP

With DPoP the client holds a key pair and signs a short proof JWT for each request.

What the client sends:

RequestHeaders
Token request to authDPoP: <proof>. The access token in the response is bound to the proof’s key, as cnf.jkt
API call through edgeAuthorization: DPoP <access_token> and DPoP: <proof>. The proof carries htm, htu and ath (hash of the access token)

How it is checked:

  • Algorithms. ES256 and PS256 by default. RS256 is supported but not in the default set. RSA keys must be at least 2048 bits.
  • htu. auth builds the expected URL from the configured issuer, and edge from edge-url. The Host header is never used. On the mTLS alias listener, htu must match the alias address.
  • Replay. A proof’s jti is remembered. Reusing it is refused.
  • Nonce. When a nonce is required, a proof without a fresh one gets use_dpop_nonce and a DPoP-Nonce header. The client retries with the nonce in the proof. auth requires it per tenant (requireDpopNonce, default false, on Security, Tokens & keys, DPoP). edge requires it when its Edges record has Require DPoP nonce enabled.
  • Where. auth verifies proofs at /token and /userinfo. edge verifies them on proxied calls.

Configuration keys:

WhereKeyDefaultMeaning
authdpop.iat-leeway60sAllowed distance between a proof’s iat and now
authdpop.nonce-ttl300sHow long an issued nonce is accepted
edgedpop.iat-leewayset in edge configSame, at the proxy
edgedpop.nonce-ttlset in edge configSame, at the proxy
edgedpop.nonce-saltset in edge configSecret input for nonce generation

Client fields (Central API, Clients in the console):

FieldMeaning
dpopBoundAccessTokensThe client always uses DPoP. A token request without a proof is refused with invalid_dpop_proof
dpopSigningAlgsNarrows the proof algorithms for this client. An empty list adds no narrowing. It can only remove algorithms the server advertises in dpop_signing_alg_values_supported
dpopMinRsaKeySizeMinimum RSA modulus in bits. Values below 2048 are refused at registration

mTLS certificate-bound tokens

With mTLS the client authenticates with an X.509 certificate, and the access token carries the certificate’s SHA-256 thumbprint as cnf.x5t#S256. Introspection returns the same claim.

  • Listener. auth serves /token, /introspect, /revoke, /par and /userinfo a second time on a separate TLS listener on MPORT (default 8083) that requires a client certificate. The listener starts only when auth config has a mutual-tls block with certificate, private-key, trusted-certificates and external-url. trusted-certificates has no default: without it, no certificate would be trusted.
  • Discovery. When the listener is on, the discovery document adds mtls_endpoint_aliases pointing to external-url. Clients must call the alias endpoints for certificate-authenticated requests.
  • Proxy-terminated mTLS. If a reverse proxy terminates mTLS instead, auth reads the certificate from a header. Configure it per tenant on Security, Tokens & keys, Mutual TLS: mtlsCertificateHeader and mtlsCertificateEncoding (urlEncodedPem or base64Der). Both are set or cleared together. A client that registers certificate authentication on a tenant without this setting is refused.

Client fields:

FieldMeaning
authMethodtls_client_auth (certificate subject matched against the registered value) or self_signed_tls_client_auth (certificate key matched against the client’s jwks). Immutable after creation
mtlsAuthFor tls_client_auth: subjectType (subject_dn, san_dns, san_uri, san_ip, san_email) and subjectValue
certificateBoundAccessTokensBinds tokens to the certificate for a client that authenticates another way. A client that authenticates with a certificate always gets bound tokens

Exception: for a native client fronted by edge, the certificate belongs to edge and is shared by every installation, so its tokens are bound to the device DPoP key only and certificateBoundAccessTokens must be false.

How to choose

SituationUse
Mobile or desktop appDPoP. The key lives in the device keystore
Service calling your API from a backend you controltls_client_auth with certificate-bound tokens, or private_key_jwt with DPoP
Service or bank calling an API behind edgeA certificate-bound token on the edge mutual-TLS listener, or DPoP on the plain port. See the how-to
Browser app behind edgeNothing to configure on the client. Tokens stay inside edge, which holds them in an EDGE_SESSION cookie session
You cannot run a client-certificate listener or proxyDPoP

How edge treats them

edge decides from the token’s cnf claim, not from the scheme the caller picked.

TokenPresented asResult
cnf.jktAuthorization: DPoP with a valid proofAccepted
cnf.jktAuthorization: BearerRefused (dpop_downgrade). This holds even when edge has no dpop block
cnf.x5t#S256EDGE_SESSION cookieAccepted. The certificate is edge’s own
cnf.x5t#S256Authorization: Bearer on the mutual-TLS listener, certificate thumbprint equals the claimAccepted
cnf.x5t#S256Authorization: Bearer on the mutual-TLS listener, another certificateRefused (certificate_bound_token_outside_session)
cnf.x5t#S256Authorization header on the plain portRefused (certificate_bound_token_outside_session). edge cannot see the caller’s certificate there
cnf.jkt and cnf.x5t#S256DPoP proofRefused. A valid proof is not enough when the certificate half cannot be shown
no cnfAuthorization: DPoPRefused. A proof says nothing about an unbound token

Removing the dpop block from edge config disables DPoP verification, but key-bound tokens over Bearer are still refused. A native app that goes through edge also requires the dpop block.

A token bound to a certificate is accepted from the caller only when edge runs the mutual-TLS listener (the mutual-tls block, MPORT), which reads the certificate off the handshake. The plain port keeps refusing it. Setup is on Calling an API with a certificate-bound token.

How auth treats edge at /userinfo

When an endpoint has fetchUserInfo on, edge calls auth’s /userinfo with the caller’s token. auth enforces the token’s binding there as strictly as edge does, and edge can supply neither the caller’s DPoP proof (it was addressed to edge) nor the caller’s certificate. So edge says that it has already verified the holder, and says it with its own certificate on the mutual-TLS listener of auth (native.auth-mutual-tls-url), not with a header.

auth takes such a call as an edge’s, and skips the DPoP and holder-certificate checks for that call only, when the certificate of the TLS session:

  1. names exactly one edge, as the URI subject alternative name urn:versola:edge:<id>,
  2. was signed by a certificate listed in mutual-tls.edge-trusted-certificates of auth (certificate authorities, valid now, checked at startup), and is a leaf, not a CA,
  3. belongs to an edge that central still lists (auth reads edge ids and their assigned tenants with the rest of its configuration), and
  4. belongs to an edge assigned to the tenant of the token’s client.

If any of these fails, the call is judged as any client’s.

The name alone proves nothing, because an issuer of client certificates can put any name in a certificate it signs. That is why the issuer must be a dedicated CA that issues edge certificates and nothing else, and why edge-trusted-certificates must also be among the listener’s trusted-certificates, so the handshake succeeds. Without edge-trusted-certificates, no certificate is an edge’s and /userinfo behaves as for any caller.

What is still checked for such a call: the token’s signature, its revocation and the openid scope. A token bound to both a key and a certificate is refused (invalid_token) even then. A certificate that arrives in a forwarded header is never taken for an edge’s. Every other caller, including every client, is held to the full binding checks.

The previous mechanism, a short-lived edge assertion in the Versola-Edge-Assertion header, is removed. central still serves the edge registry to auth, but with ids and assigned tenants only, no keys.

Security. The private key of an edge’s certificate is the most sensitive key in the deployment. Whoever holds it can call /userinfo as that edge, for any token of the tenants assigned to that edge, without proof of possession: that is the blast radius of a stolen edge certificate. What limits it:

  • The edge CA must be dedicated, never the CA of your clients. If edge-trusted-certificates names a CA that also issues client certificates (docker-local, or a client-certificates backend that shares a CA), the only thing that keeps a client from presenting an edge name is central refusing urn:versola:edge: names in client certificates. auth logs a warning at startup when the listener trusts nothing else.
  • Short lifetimes. A certificate’s own validity cannot be shortened after issuance. The vps bundle issues edge certificates 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 in auth.conf: one minute in docker-local, five elsewhere), not at expiry. If central is unreachable auth keeps the last registry it had; with none loaded no certificate is an edge’s.
  • Make the mutual-TLS listener of auth reachable only from the network the edges run in, and rotate the edge CA like any other.

Configuration of the auth side is under mutual-tls. For a token that works behind edge, see DPoP or certificate-bound tokens for an API behind edge.