Skip to main content

DPoP: Sender-Constrained Tokens

DPoP (Demonstrating Proof of Possession, RFC 9449) binds an access token to a key held by the browser that logged in. A token that is copied off that browser stops working, because presenting it also means proving you hold the key it was bound to.

TideCloak supports standard DPoP and extends it: the key is proven during authentication, not only when the token is later used. That is what the DPoP verification page is for, and it is the part that needs a step in your app.

DPoP is opt-in. See enabling it for why it is not on by default.


The Stolen Token Problem​

A normal OAuth access token is a bearer token. Whoever holds it can use it. If one leaks through a log file, a browser extension, a proxy, or a cross-site scripting bug, the attacker gets everything the token grants until it expires.

Shortening the lifetime helps a little and costs a lot. The token is still a password-equivalent for as long as it lives.

What DPoP Changes​

With DPoP, the browser generates a keypair at login and keeps the private key non-extractable, so no script can read it out. The token is issued bound to the public key, carrying cnf.jkt, a thumbprint of that key.

Every request that uses the token also carries a DPoP: proof header signed by the private key. The resource server checks that the proof matches the thumbprint in the token. A copied token fails that check, because the attacker cannot produce the signature.

Two things follow from this:

  • A leaked token is not enough on its own.
  • The binding is only as good as the resource server's willingness to check it. A server that accepts the token without validating the proof gets no benefit.

What Tide Adds​

Standard DPoP proves possession when the token is used. Tide also proves it when the token is issued, during authentication, so the token is never bound to a key nobody demonstrated control of.

This is where the verification page comes in, and it is worth understanding rather than treating as boilerplate.

The DPoP private key lives in IndexedDB under your app's origin. Browsers isolate storage by origin, so the Tide node running the login flow cannot read it. It sits on a different origin, and that separation is the point: nothing outside your app can reach the key.

But the node still has to confirm the browser really holds the key before it issues a bound token. So it asks your app to prove it, by loading a small page from your origin and sending it a challenge:

  1. During login, the node embeds tide_dpop_auth.html from your app in a hidden iframe, or opens it in a popup if the iframe is blocked.
  2. Because the page is served by your app, it runs with your app's origin and can open your app's IndexedDB.
  3. It reads the DPoP key and waits for a challenge.
  4. The node sends a random challenge. The page signs it and posts back the signature and the public key.
  5. The node hashes the returned public key and checks it against the dpop_jkt the login request declared. If they match, the browser has proved possession and the login continues.

The page never sends the private key anywhere. It sends a signature and a public key, and it only talks to the origin that opened it.

Why the URL carries your realm and client​

The page is served at a path that spells out which issuer and client it is being asked about:

/tide_dpop/iss/<issuer-as-hex>/aud/<client-id-as-hex>/tide_dpop_auth.html

The page reads those two values back out of its own URL and uses them to pick which key to look up, since an app can hold keys for more than one realm or client.

There is nothing inside the file to fill in. It is identical for every app and every realm. Everything specific to you is in the URL, which is why setting it up is a matter of routing a path, never of editing the file. Your app should also check that the issuer and client in the URL are the ones it expects, and refuse anything else.


Enabling DPoP​

DPoP is off unless you ask for it, and the SDK will not turn it on for you.

Turning it on changes the shape of the issued token. The realm returns a token with typ: "DPoP" and cnf.jkt, and such a token is invalid if it is later sent as a plain Authorization: Bearer .... The server answers a bare 401 with nothing explaining why. So every call carrying the token has to attach a proof. IAMService.secureFetch does this for you.

// Enforced: init fails if the realm does not support DPoP
await IAMService.initIAM({ ...config, useDPoP: { mode: "strict" } });
// Use DPoP when the realm supports it, otherwise fall back to bearer
await IAMService.initIAM({ ...config, useDPoP: { mode: "auto" } });

Your app also has to serve the verification page. That is one step, and the SDK provides it:

When the Page Is Missing​

This is worth knowing because the symptom does not look like a missing file.

If the page is not served, or is served without the headers it needs, the node's iframe never loads. It waits about four seconds, gives up, and falls back to a popup. Login still completes, so nothing looks broken, but every sign-in stalls first and some users get a popup prompt they did not expect.

If you have DPoP on and logins feel slow, this is the first thing to check. Request the page yourself at the path above and confirm it returns 200 with both headers.


Limits​

Worth being clear about what this does not do.

  • The resource server has to check the proof. If it accepts the token without validating the DPoP: header, the binding buys nothing. Use a library that supports DPoP validation.
  • It binds the browser, not the person. Someone with control of the browser profile is inside the boundary.
  • It needs the page served correctly. A missing or misconfigured page degrades the login experience, and the four second stall is the only outward sign.