Sitemap

Making a Stolen OAuth Token Useless: DPoP in Practice

Sender-constrained access tokens with Keycloak, Spring Boot, and Next.js.

--

Part 4 of the Next.js + Spring Boot + Keycloak series.

In the Part 3 we taught one login to yield two assurance levels: a base session for /hello, and a TOTP-elevated acr=pro session for /server-details. The login got smarter. The token did not. It was still a bearer token, and bearer means exactly what it says: whoever bears it, uses it.

That is the quiet problem underneath every OAuth demo. Copy the Authorization header out of a log line, a leaked error report, a compromised proxy, and you can replay it against the backend until it expires. Step-up decides how hard it was to mint the token. It does nothing about what happens after the token exists. This article closes that gap with DPoP (Demonstrating Proof-of-Possession at the Application Layer, [RFC 9449]): the token stops being something you hold and becomes something you must prove you own, on every single request.

The Big Picture

DPoP adds one asymmetric key pair and one extra header. The frontend generates an ES256 key, and on every call it signs a short-lived proof JWT with the private key. Keycloak stamps the public key’s thumbprint into the access token as a cnf.jkt (“confirmation, JWK thumbprint”) claim. The backend then refuses any request whose proof was not signed by the key the token is bound to.

Press enter or click to view image in full size

The scheme changes too. Authorization: Bearer <token> becomes Authorization: DPoP <token>, and a DPoP: <proof-jwt> header rides along on every request. A stolen token, on its own, is now inert: without the private key, you cannot mint the proof that matches its cnf.jkt.

The Vocabulary: proof, cnf.jkt, and ath

Three terms carry the whole design, and it is worth pinning them down before any code.

A DPoP proof is a tiny JWT, signed by the private key, that says “I am making this request right now.” Its header carries typ: dpop+jwt and the full public JWK; its body carries htm (the HTTP method), htu (the HTTP URI), iat (issued-at, for freshness), and jti (a unique id, for replay detection). It lives for seconds and is thrown away.
The cnf.jkt claim is the binding. When Keycloak issues the access token, it hashes the public JWK from the proof and writes that thumbprint into the token as cnf.jkt. From then on the token and the key are married: the token names the key that is allowed to speak for it.
The ath claim (access-token hash) is the piece that stops a proof from being reused across endpoints. On every resource call the proof adds ath = base64url(https://medium.com/SHA-256(access%20token)), so a proof is valid only for the exact token it was minted alongside. The token-endpoint proof omits ath (there is no access token yet); every backend proof includes it.

Put together: the token proves who you are (the JWT claims), and the proof proves you still hold the key the token was bound to (the signature over htm/htu/ath). Both must pass, on every request.

Keycloak: require DPoP-bound tokens

On the 26.6 image this stack uses, enabling DPoP is a single per-client capability toggle, “Require DPoP bound tokens”, which surfaces in the realm export as one attribute on the nextjs-frontend client:

"attributes": {
"dpop.bound.access.tokens": "true"
}

That is the entire Keycloak change. The client stays confidential (its secret never reaches the browser, exactly as before), PKCE stays on, and the step-up flow is untouched. Once the toggle is set, the token endpoint refuses any token request that does not arrive with a valid DPoP proof, and every access token it issues for this client carries a cnf.jkt.

The confidential-client nuance that is easy to assume wrong

There is one detail here that is easy to guess wrong. For a confidential client, Keycloak 26.6 binds only the access token to the key. The refresh token is protected by the client secret instead, so it is not cnf-bound. But, and this is the counterintuitive part, the token endpoint still requires a DPoP proof on the refresh_token grant, because the new access token it mints needs to be bound to the same key. Forget the proof on refresh and your 5-minute session dies silently mid-use. In this stack the refresh path signs a proof on every renewal, and an e2e test asserts that cnf.jkt stays stable across the refresh cycle.

Spring Boot: validate the proof, and refuse the downgrade

The backend is where the sender-constraint is actually enforced, and Spring Security 7 does more of the work than you might expect. The moment DPoPProofJwtDecoderFactory is on the classpath, the resource server auto-enables DPoP: it accepts the DPoP scheme, checks the proof (signature, htm/htu, iat freshness, jti replay via a built-in cache, ath, and the cnf.jkt thumbprint match), and, crucially, decodes the access token through the same JWT authentication manager. That last point is what keeps iterations 1 and 2 alive: the issuer, audience, and acr step-up checks all still run for a DPoP-scheme request.

So the SecurityConfig reads almost like the step-up version. The endpoint rules and the acr step-up handler are unchanged; the DPoP wiring is implicit:

http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/server-details").hasAuthority(stepUpAuthority)
.anyRequest().authenticated())
.oauth2ResourceServer(oauth2 -> oauth2
.jwt(jwt -> jwt.jwtAuthenticationConverter(acrAuthenticationConverter()))
.accessDeniedHandler(new StepUpAccessDeniedHandler(requiredAcr, stepUpAuthority, debug)));

Note what is not here: there is no .dpop() call to switch DPoP on. It is on because the factory is on the classpath. That leaves two ways an attacker might try to skip the proof, and this project closes both: a bound token sent as plain Authorization: Bearer to sidestep the proof check, and a token that has no cnf.jkt at all, so there is no proof to demand.

The one guarantee the framework does not give you for free

Sender-constraining is only real if a cnf-bound token cannot be used as a plain bearer token. Half of that is handled for you: Spring’s own BearerTokenAuthenticationFilter already rejects a cnf-bound token presented under the Bearer scheme (RFC 9449). Good. But that only covers a bound token under the wrong scheme.

The other half is the one to worry about: a token that lacks cnf.jkt entirely. If a misconfigured client sharing the same audience mints an unbound token, or if someone flips the Keycloak toggle off, that token would sail through the ordinary bearer path with full access, silently reopening the exact downgrade DPoP exists to prevent. So the resource server checks it itself, in a tiny validator wired into the decoder alongside the audience check:

Object cnf = token.getClaim("cnf");
if (cnf instanceof Map<?, ?> confirmation
&& confirmation.get("jkt") instanceof String thumbprint
&& !thumbprint.isBlank()) {
return OAuth2TokenValidatorResult.success();
}
return OAuth2TokenValidatorResult.failure(MISSING_CNF);

Now every accepted token must carry cnf.jkt, under any scheme. The two guards are complementary: the framework refuses a bound token under Bearer, and this validator refuses an unbound token under anything. The guarantee no longer rests on the IdP configuration alone. This one validator is the difference between “DPoP is on” and “DPoP cannot be turned off from the outside.”

Get Hernán Cussi’s stories in your inbox

Join Medium for free to get updates from this writer.

The jti replay cache matters just as much and is worth confirming rather than assuming. Without a server-side cache keyed on jti + cnf.jkt within a tight iat window, an observed token and its proof could be replayed verbatim, which downgrades the whole property from “needs the key” to “needs one captured request.” Spring Security 7 ships this cache; the tests replay a proof with the same jti twice and assert the second call gets a 401.

Next.js: the key that must never leave the server

Here is the design decision the entire frontend hangs on, and it is subtler than “generate a key in the browser.”
The confidential client exchanges the authorization code for tokens server-side, inside next-auth, so the secret never reaches the browser. That means the thing that signs the token-endpoint proof is the Next.js server. And because the same key must sign the resource-endpoint proof (the backend checks both against one cnf.jkt), the key has to live server-side too. A browser-held WebCrypto key would force us to abandon the confidential client and move token handling into the browser, which is a step backwards. So the token and the key both stay in the Next.js tier, and the browser reaches the backend only through a same-origin BFF proxy.

This is a net security improvement: the browser now holds neither the access token nor the key. But it creates two problems worth naming.

1. Where do you store a raw private key across a stateless session?

next-auth v4 uses a stateless, encrypted (JWE), httpOnly session cookie. You could stuff the raw private JWK into that cookie, and it would be encrypted, but the key would round-trip the browser and its safety would rest entirely on NEXTAUTH_SECRET. Instead, the private key stays a Node key object in a server-side in-memory store, and only an opaque reference (a random id) goes into the cookie:

export function putDpopKey(key: DpopKey): string {
const ref = randomUUID();
store.set(ref, key);
return ref;
}

The token-exchange, refresh, and proxy paths resolve that reference back to the key server-side. The raw key never crosses the wire. The honest trade-off, documented as a POC limitation: an in-memory store is per-process, so a server restart or a second replica forces re-login.
A real deployment would swap in a shared secret store; the interface would not change.

Binding the very first token (the sharp edge)

This is the one genuinely version-sensitive piece. The key has to exist and sign a proof before the authorization-code-to-token exchange, but that exchange happens deep inside next-auth’s Keycloak provider, and the callback where you would normally persist things runs after it. The trap is assuming you can bolt the proof on afterwards. You cannot; the proof has to be part of the token request itself.

The clean way through is to override the provider’s token request, generate the key there, and hand it to the underlying openid-client (which next-auth uses) to sign the proof for you:

token: {
async request({ client, params, checks, provider }) {
const key = await generateDpopKeyPair();
const tokenSet = await client.callback(provider.callbackUrl, params, checks, {
DPoP: key.privateKey, // openid-client signs the token-endpoint proof
});
const dpopKeyRef = putDpopKey(key);
return { tokens: { ...tokenSet, dpopKeyRef } };
},
},

That DPoP: key.privateKey argument is the whole trick: openid-client already knows how to sign a token-endpoint proof. Everything downstream (refresh, resource calls) is in code we already own, so it is just a matter of adding a signed DPoP header.

The BFF proxy: sign the proof, forward nothing extra

The browser calls a same-origin route like /api/backend/hello with only its session cookie. The proxy reads the token and key server-side, signs a fresh resource proof (with ath), and forwards under the DPoP scheme:

const proof = await signDpopProof({ key, htm: "GET", htu: url, accessToken });
const backendResponse = await fetch(url, {
method: "GET",
headers: { Authorization: `DPoP ${accessToken}`, DPoP: proof },
cache: "no-store",
});

The rule that keeps this safe is the one that is easy to forget: do not relay the browser’s incoming headers. The session cookie carries the key-store reference and must never transit to (or be logged by) the backend. So the outbound request is built from a strict allow-list (Authorization, DPoP, Content-Type), and the response comes back through an allow-list too, deliberately including WWW-Authenticate so the browser can still read the RFC 9470 step-up challenge relayed from the backend. Because the proxy is same-origin, that challenge reaches the browser with no CORS gymnastics at all, and the step-up UX from the previous article keeps working unchanged.

Bringing It Up

The whole thing runs from docker compose up, and the end-to-end story is worth walking once:
1. Log in as testuser. next-auth’s token hook generates the ES256 key, signs the token-endpoint proof, and Keycloak returns an access token stamped with cnf.jkt. The key lands in the server-side store; only its opaque reference rides in the session cookie.
2. Load /hello. The browser calls the same-origin proxy with just its cookie. The proxy signs a fresh proof (htm=GET, htu, ath) and forwards under Authorization: DPoP. The backend validates the JWT and the proof, and returns the greeting.

3. Click “Load server details.” The base token lacks acr=pro, so the backend answers with the RFC 9470 insufficient_user_authentication 401, which the proxy relays untouched. The frontend catches it and begins step-up.
4. Complete TOTP. Keycloak re-issues an elevated token, still cnf.jkt-bound to the same key. The retry succeeds and /server-details renders, the level badge flips to pro.

Press enter or click to view image in full size
Press enter or click to view image in full size

5. Watch the refresh. Five minutes in, the token expires; the refresh grant carries a DPoP proof so the new token stays bound to the same key. cnf.jkt is stable across the whole session.
6. Try to cheat. Take that valid token and replay it with no proof, or a proof signed by a different key, or under Authorization: Bearer, or the same proof twice. Every one of them is a 401. That row of negatives is the entire point of the iteration, and the headless e2e asserts all of them.

Press enter or click to view image in full size

Wrapping Up

DPoP sounds like it should be a major re-architecture. It is not. It is one key pair, one extra header, and one claim (cnf.jkt) that turns a token from a password into a certificate you have to prove you hold. The frontend signs a proof, Keycloak stamps the thumbprint, the backend checks the marriage on every request. A leaked token, alone, buys the attacker nothing.

Be precise about what that buys you, though, and do not oversell it. Because the key and the token co-locate in the Next.js tier, DPoP here defeats token-only leakage: a token in a log, a capture on the proxy-to-backend hop, an error tracker, browser XSS (already closed once the token stops reaching the browser). It does not defend a full compromise of the Next.js tier, which hands over both artifacts at once. That narrower guarantee is exactly the guarantee, and knowing its edges is part of using it well.

Everything is reproducible. Clone the repository], add the one 127.0.0.1 keycloak line to /etc/hosts, run docker compose up, and log in. Then open the network tab and confirm the thing that makes it all worthwhile: the browser makes only same-origin calls, and the access token and private key are nowhere to be found.

--

--