DPoP Enforcement¶
DPoP (RFC 9449) binds an access token to a key pair: the token carries the
key's thumbprint in its cnf.jkt claim, and every request must carry a proof
signed by that key. A stolen DPoP-bound token is useless without the private
key, and so is a token moved to another system. SystemForge uses DPoP by
default: SystemAuth and the identity/oauth provider issue DPoP-bound
tokens, and the middlewares require them. Unbound bearer tokens are a legacy
exception that must be switched on explicitly, per client at the
authorization server and per API in the middleware. SCIM tenant tokens are
the one deliberate exception by design; see
SCIM tokens use the Bearer scheme.
SystemAuth issues DPoP-bound tokens¶
Every token request to /oauth/token must carry a DPoP header with a proof
for POST at the token endpoint URL (the issuer plus /oauth/token, as
discovery advertises it), signed with ES256, ES384 or ES512, fresh (iat
within dpop.proof_max_age, default 5 minutes, plus dpop.clock_skew), and
with a jti not used before. On success:
- the access token is bound to the proof's key: a JWT access token carries
cnf.jkt, introspection returnscnf.jktandtoken_type: DPoP, and the token response saystoken_type: DPoP; - the refresh token is bound to the same key: a refresh needs a proof by that key (RFC 9449 §5), and a refresh token presented without one, or with another key's, is refused without being spent;
- an authorization request may name the key in
dpop_jkt(RFC 9449 §10): the code is then redeemed only with a proof by that key. A token request with no proof or another key's is refused before the code is touched, so the code can still be redeemed with the right proof, whatever the storage.
This applies to every grant SystemAuth supports: authorization_code,
refresh_token and client_credentials. A request without a valid proof
gets 400 with error=invalid_dpop_proof; a refresh or code bound to
another key gets invalid_grant.
| Setting | Default | Meaning |
|---|---|---|
dpop.proof_max_age |
5m |
How old a proof's iat may be |
dpop.clock_skew |
30s |
How far in the future iat may be |
dpop.require_nonce |
false |
Require server nonces (RFC 9449 §8): a proof without a current nonce gets use_dpop_nonce and a DPoP-Nonce header to retry with |
dpop.nonce_secret |
Key of the nonces, at least 32 bytes, the same on every instance; required with require_nonce |
|
dpop.nonce_lifetime |
5m |
How long a nonce is accepted |
dpop.replay_cache_redis_url |
Redis shared by every instance for replay detection (systemauthsvc); embedded servers pass systemauth.WithDPoPReplayCache |
Without a shared replay cache SystemAuth keeps an in-memory one, which only suits a single instance.
The bearer-token exception¶
A client that cannot do DPoP yet may be registered with
allow_bearer_tokens: true (ClientConfig.AllowBearerTokens, the
sf_oauth_apps.allow_bearer_tokens column). It is a legacy exception:
such a client may call the token endpoint without a proof and gets unbound
tokens with token_type: Bearer; if it does send a proof, its tokens are
bound as usual. Every other client has RFC 9449's
dpop_bound_access_tokens set (Client.DPoPBoundAccessTokens). Prefer
moving the client to DPoP: a bearer token works for whoever holds it.
SystemAuth's own resources¶
The userinfo endpoint, the memberships endpoint, the access-changes
endpoint, the introspection endpoint's token callers, Server.Middleware,
BearerAccess/BearerPrincipal and TokenAuthenticator accept a
DPoP-bound token only with Authorization: DPoP <token> and a proof by its
key for the request's method and URL (SystemAuth's own endpoints are
checked against the issuer URL; other routes against dpop.resource_base_url,
by default the issuer's origin, plus the request path; see
The proof's URL behind a proxy), carrying
the token's hash (ath). An unbound token
is accepted only with the Bearer scheme, and never in a form body when it is
bound. Errors carry a WWW-Authenticate: DPoP challenge with
error="invalid_dpop_proof", invalid_token or use_dpop_nonce.
Discovery advertises dpop_signing_alg_values_supported.
The identity/oauth provider¶
identity/oauth, the fosite-based provider over the same applications
table (sf_oauth_apps), applies the same rules as SystemAuth:
- its token endpoint requires a proof for
POSTat the issuer plus/oauth/tokenand binds the access and refresh tokens to its key (token_type: DPoP,cnf.jktin introspection and in JWT access tokens withConfig.JWTAccessTokens), honoursdpop_jkt, and needs a proof by the bound key to refresh; - a client whose
sf_oauth_apps.allow_bearer_tokensis set may call it without a proof and gets unbound tokens (Client.DPoPBoundAccessTokensreports false), the same legacy exception; API.Middlewareand the introspection endpoint's token callers accept a bound token only with the DPoP scheme and a proof by its key, and an unbound token only as Bearer;- discovery advertises
dpop_signing_alg_values_supported.
Config.DPoP sets the proof age, clock skew, replay cache (in memory by
default; pass a shared one, such as middleware.NewRedisReplayCache, when
several instances serve the provider) and optional server nonces
(dpopproof.NewNonces). The signing key rotates N / N-1 like SystemAuth's:
Config.KeyID names the signing key and Config.VerificationKeys holds the
previous key N-1 or a pre-staged next key; the JWKS publishes them all, and a
token with any other kid is refused.
SCIM tokens use the Bearer scheme¶
SCIM tenant tokens (sfscim_…, see SCIM) are the
deliberate exception to DPoP: SCIM requests carry them with the Bearer
scheme, because the identity providers that call SCIM endpoints do not
support DPoP. A SCIM token is 256 random bits, stored only as a hash,
rotated N / N-1 so at most two are valid, and scoped to one tenant. Serve
SCIM only over TLS and behind a rate limiter.
The proof's URL behind a proxy¶
A proof names the URL it is for (htu), and the server compares it with the
URL it expects. For SystemAuth's own endpoints that is the issuer URL plus the
endpoint path, as discovery advertises it. For every other route it is a base
URL plus the request path as this server sees it:
| Component | Base URL setting | Default |
|---|---|---|
SystemAuth Middleware, BearerAccess, BearerPrincipal |
dpop.resource_base_url |
the issuer's origin |
identity/oauth API.Middleware |
WithResourceBaseURL |
the issuer's origin |
session/middleware.HTTPAuth and NewDPoPAuth, multiapp.AuthMiddleware |
DPoPConfig.BaseURL |
the request's scheme and Host |
identity/relyingparty.BearerMiddleware |
DPoPConfig.BaseURL |
the request's scheme and Host |
The default is the origin only, with no path. Behind a proxy that maps the
external path to another one, for example one that receives
https://example.com/api/items and forwards /items, set the base URL to
the external URL including the stripped prefix (https://example.com/api):
otherwise a proof for the URL the client called never matches. Forwarded
headers are never trusted.
Relying parties and APIs¶
| Component | Behavior |
|---|---|
session/middleware.HTTPAuth, HTTPAuthForAudience, HTTPAuthOptional, ChiAuth, NewDPoPAuth |
Accept a token with cnf.jkt only with the DPoP scheme and one proof that matches the request method and URI, carries the token hash (ath), is fresh, is signed by the bound key, and has a jti not seen before (DPoPConfig.ReplayCache). Unbound tokens are refused unless DPoPConfig.AllowBearer is set. |
session/middleware.HTTPAuthBearer, HTTPAuthBearerForAudience, HTTPAuthBearerOptional |
Legacy, explicit opt-in: accept unbound bearer tokens only, and refuse any token with cnf.jkt; they verify no proofs. |
multiapp.AuthMiddleware |
Accepts a token with cnf.jkt only with the DPoP scheme and a valid proof (MiddlewareConfig.DPoP, whose ReplayCache is required), as HTTPAuth does, on top of the app audience check. Unbound tokens are refused unless DPoP.AllowBearer is set. |
identity/relyingparty.BearerMiddleware and RequestAuthenticator |
Accept a SystemAuth access token with cnf.jkt only with the DPoP scheme and a valid proof (BearerConfig.DPoP, required when Tokens is set). A bound token presented as Bearer is always refused. Unbound tokens are refused unless BearerConfig.AllowBearerTokens is set. |
identity/relyingparty.Client |
Sends a proof by Config.DPoPKey with every token request (code exchange, refresh, client credentials), so its tokens are bound to that key, and presents them to SystemAuth's userinfo, memberships and access-changes endpoints with the DPoP scheme. It retries once with a server nonce. Give every instance that shares sessions the same key; the default is a key generated per process. |
session/bff |
Holds a DPoP key pair per session. The proxy sends a token issued with token_type: DPoP (Session.TokenType) with the DPoP scheme and a proof, and a token issued as Bearer with the Bearer scheme. The refresher proves possession of the session's existing key, since a bound refresh token needs it. |
identity/token.DefaultService |
Binds tokens only to a dpopproof.Proof, which only a dpopproof.Checker yields after verifying a proof, never to a thumbprint its caller supplies. A bound refresh token needs a proof by the same key. |
identity/dpopproof is the proof checker SystemAuth, identity/oauth and
identity/token use: signature, typ, algorithm, htm, htu, iat, ath, replay, server
nonces, and a refusal of proofs whose jwk carries a private key.
Protecting an API¶
For tokens minted by session/jwt:
auth, err := middleware.HTTPAuth(jwtService, middleware.DPoPConfig{
ReplayCache: middleware.NewRedisReplayCache(redisClient, "dpop-jti:"),
BaseURL: "https://api.example.com", // the scheme and host clients use
})
For SystemAuth access tokens in a relying party:
mw := relyingparty.BearerMiddleware(relyingparty.BearerConfig{
Tokens: client,
Principals: store,
DPoP: &relyingparty.DPoPConfig{
ReplayCache: middleware.NewRedisReplayCache(redisClient, "dpop-jti:"),
BaseURL: "https://api.example.com",
},
})
Use a replay cache shared by every instance that serves the API
(NewRedisReplayCache); NewMemoryReplayCache only suits a single
instance. Set BaseURL behind a proxy that terminates TLS or strips a
path prefix: the proof's htu is compared with BaseURL plus the request
path, and forwarded headers are never trusted (see
The proof's URL behind a proxy).
A relying party cannot forward a caller's DPoP-bound token to SystemAuth
(for example to read memberships): only the caller holds the key.
RemoteMemberships then fails with relyingparty.ErrForeignDPoPToken, and
ClaimsOrRemoteMemberships falls back to the token's claims.