A Bearer access token (RFC 6750) is a password: whatever can read the bytes can spend them. DPoP binds the token to a key — the token carries cnf.jkt, the RFC 7638 thumbprint of a public key — and every request presenting it must carry a fresh signature from the matching private key over that request’s method and URI. The stolen bytes are then worthless.
Where it applies, and where it deliberately does not. OID4VCI 1.0 names DPoP exactly three times: its Security Considerations say the use of DPoP is RECOMMENDED for sender-constrained access tokens (mTLS being impractical for a native-app wallet), and its Nonce Response section says the Credential Issuer MAY return a DPoP-Nonce for use “when presenting an access token at the Credential Endpoint”. So it covers the Token Endpoint and every protected endpoint the issuer publishes — Credential, Deferred Credential, Notification. OID4VP 1.0 names it zero times, and that is structural rather than an omission: in its own words “the result of an OpenID4VP interaction is one or more Verifiable Presentations … instead of an Access Token”. There is no token in that exchange to sender-constrain, and the presentation’s own proof of possession is the Key Binding JWT. Presentation step 2 therefore carries a pane that says so, with a table comparing the two proofs side by side (typ, what possession is proved of, where freshness comes from, htu vs aud, ath vs sd_hash) rather than a switch that would do nothing. DPoP is also indifferent to the credential format — it binds an OAuth token, not a credential — so it works unchanged for dc+sd-jwt, jwt_vc_json and ldp_vc, and nothing in the implementation reads the format.
Two checkboxes on issuance step 2, answering two different questions. vc_dpop_enabled asks whether the access token is sender-constrained; off by default, so a wallet that ignores the pane behaves exactly as before and the Bearer path stays the one the workflow demonstrates first. vc_dpop_holder_of_key asks whether the credential is bound to that same key (Holder of Key) or to a holder key of its own (what the workflow has always done and what SD-JWT VC assumes). Neither is more correct: HoK means one key to protect instead of two and lets the issuer see that whoever presents the token is who the credential will be bound to; a key of its own keeps the credential’s lifetime independent of the token’s, which matters because the credential outlives the token by months and a key rotated for OAuth reasons should not invalidate it. Neither mode is the one that “has a proof of possession” — both send an openid4vci-proof+jwt key proof in the Credential Request body, and the checkbox only decides whose key signs it. Calling the unchecked mode Proof of Possession, which the pane did until 2026-08-19, read as though the key proof were a DPoP artifact that a Bearer flow would not send; the pane now names the row Key proof (openid4vci-proof+jwt) and says of the mode only that the credential gets a holder key of its own. The second checkbox depends on the first — there is nothing to reuse without a DPoP key — so with DPoP off the pane says the credential will be bound to a holder key of its own rather than leaving a ticked box that does nothing; credentialBindingMode() returns the effective mode while credentialBindingPreference() returns what the checkbox says, because collapsing those two would make the box reset itself.
A third checkbox, on the OAuth2 / OIDC workflow, and the reason it had to exist. oauth2_oidc_2.html now carries its own DPoP switch (dpop_enabled, in the Token Request pane, off by default) backed by client/src/oauth_dpop.js — the same shape as the VC workflow’s state but under oauth_dpop_* keys and with its own key pair. Until 2026-08-05 there was no such switch, and both DPoP touchpoints on the shared debugger pages read the VC workflow’s flag with no check that the VC workflow was running: recalculateAuthorizationRequestDescription() in oauth2_oidc_1.js appended dpop_jkt to every code-family authorization request, and dpopTokenRequestHeaders() in oauth2_oidc_2.js put a proof on every browser-direct Token Request. The two workflows share one localStorage, so ticking the box once on VC issuance step 2 sender-constrained every OAuth2/OIDC token afterwards, with nothing on those pages able to turn it off — DPoP was, for that workflow, mandatory. Both reads are now sdJwtVc.isFlowActive() ? <VC state> : <this workflow's state>.
Three things about that split are deliberate. The two workflows share the mechanism (dpop.js) and the wire (vci_wallet.dpopHeadersFor()) and nothing else — separate keys mean turning DPoP off here cannot destroy the key a credential was bound to, and turning it on there cannot bind a token the user of this page never asked to have bound. The switch lives with the request and the verdict with the response: successfulInternalTokenAPICall() collapses #token_fieldset on success, so a verdict rendered into the pane would be true and invisible — it is appended to the Token Endpoint Results instead (#dpop_result_status), and it is read off the token’s own cnf.jkt rather than from the fact that a proof was sent, because an authorization server that ignores DPoP answers with a perfectly ordinary Bearer token. And dpop_jkt can only travel on the authorization request, which oauth2_oidc_1.html assembles synchronously from storage — so the checkbox generates the key pair the moment it is ticked rather than at first use, and the natural order is to switch DPoP on before starting the authorization request. tests/oidc_dpop_optional.js covers all of it, including a phase that sets the old state exactly (the VC switch on, this one off) and requires a Bearer token; it is mutation-tested against both the whole pre-fix behaviour and against the cross-over alone.
Three modules, one seam each. client/src/dpop.js is the mechanism — thumbprint, htu, ath, jti, the proof, the nonce handshake — with no DOM and no storage in it, which is what lets tests/dpop.js check it against the RFCs’ own published vectors. sd_jwt_vc.js is the wallet’s memory (the DPOP_* keys, and dpopContext(), which hands the wire layer a key and a nonce rather than letting it read storage). vci_wallet.js is the wire: fetchProtected() is the one implementation every protected call goes through, and that matters because DPoP’s whole value is being on every call — this workflow has six such call sites across two pages, and a hand-written "Bearer " + token at each is how one of six ends up wrong. sts/oauth-oidc/dpop.js is the server’s checker.
The private key is gated by the existing holder-key opt-out, and sdjwtvc_dpop_private_jwk is on HOLDER_PRIVATE_KEYS for the same reason the others are — doubly so under Holder of Key, where it is the holder key. The consequence is deliberate and the pane states it: with saving off the DPoP key does not survive a page load, so a token bound to it stops working, which is a sender-constrained token behaving exactly as intended. Switching DPoP off also discards the key pair, because a key nothing will use again is one a later session could pick up and bind a credential to by accident.
Four things in the client that were easy to get wrong, and all four were wrong at least once:
proof() and generateKeyPair() must REJECT, not throw. A function whose contract is “returns a promise” but which throws before returning one cannot be handled with .catch(), so a misconfigured algorithm took the page down instead of filling in a status line. tests/dpop.js caught it on its first run.cnf lives in the credential’s payload, not in parsed.claims. parsed.claims is the disclosed claim set; looking for cnf there returns undefined, and boundPrivateJwk() then fell back to “the first key in storage” — which under Holder of Key is the holder key, i.e. the wrong one. The presentation is refused with a complaint about the KB-JWT’s signature, which reads as a broken wallet rather than as the wrong key. vc_issuance_3.js reads payload.cnf, which is the precedent.cnf.jwk, not by remembering which mode issuance used: the mode may have been switched since, and the credential may have come from another Credential History generation. boundPrivateJwk() compares public coordinates (x/y, or n) — the same test step 4 uses on a pasted key — and reports which key it chose so the pane can say “the DPoP key” rather than “a key”.DPoP: beside Authorization: DPoP. A pane showing the second without the first describes a request every conforming server refuses, and on a page whose product is the request that is worse than no pane. The proof shown there is a described placeholder rather than a real one, because a proof is single-use and covers its own jti/iat, so any proof rendered in advance would not be the one sent.On the server. sts/oauth-oidc/dpop.js implements all twelve RFC 9449 section 4.3 checks, labelled by number, plus jti replay detection; oauth2.js binds the access and refresh tokens (section 5 — a wallet is a public client, so an unbound refresh token would be a bearer credential that mints bound access tokens for whoever holds it, which is worse than not binding because token_type would claim a guarantee nothing checked), advertises dpop_signing_alg_values_supported (section 5.1 — the only signal that DPoP is on offer, so a server that supports it silently is never asked), honours dpop_jkt on the authorization request (section 10, which closes the window PKCE does not: a thief holding the code and the code_verifier still cannot sign for the key), and reports DPoP rather than Bearer from introspection. The three protected endpoints had three copies of a Bearer-only check and now share one presentedAccessToken(), because a per-endpoint copy is how one of three ends up not demanding the proof — and the one that forgot is the one an attacker would use. Note a limitation stated in that function: this issuer accepts tokens from a foreign authorization server and cannot verify them, so for such a token cnf.jkt is a claim anyone could have written; the binding is real only for tokens this service issued. There is deliberately no “DPoP required” mode — nonce mode makes proofs fresher, not mandatory — and the two nonce-request shapes are not shared code, because an authorization server asks with a 400 JSON body while a resource server asks with a 401 WWW-Authenticate, and getting that wrong leaves a conforming wallet with no way forward. POST /dpop/nonce-mode is a non-spec runtime switch so the handshake can be exercised without a restart; it is listed as non-spec on /admin/sts-metadata.
Three tests, and they are not interchangeable. tests/dpop.js (node only, never skipped) checks the arithmetic against RFC 9449’s own worked example and RFC 7638 section 3.1 — the two documents print a key and the thumbprint of the token bound to it, which is an input and its expected output written down by the people who defined the mechanism — and checks the three silent ways a thumbprint goes wrong (extra members, member order, whitespace). tests/sts_dpop.js (STS only, no browser) is almost entirely negatives, because a DPoP server that issues bound tokens and accepts good proofs looks finished and can be worth nothing: omit the signature check and any client claims any key; omit htm/htu and the token endpoint’s proof works at the credential endpoint; omit ath and a proof captured with one token is presented with another; omit the cnf.jkt comparison and the token is bound to nothing; omit typ and some other JWT the client signed with the same key is accepted — and this workflow signs one, the openid4vci-proof+jwt. Its happy paths are interleaved with the refusals so that a server which simply stopped answering could not pass. It writes its own DPoP client rather than importing client/src/dpop.js, so a shared misunderstanding cannot make both sides agree and interoperate with nobody. tests/dpop_workflow.js (STS + client, no IdP) checks what neither can reach: that the pages really send the proofs, that the token really came back bound — asserted against the access token’s own cnf.jkt, recomputed in the browser from the displayed JWK rather than read off the pane — and that under Holder of Key the credential’s cnf.jwk is that same key.
One gap, stated rather than papered over. oauth2_oidc_2.html’s Token Request carries a proof only on the browser-direct call. The proxied call goes to the api, which then calls the token endpoint itself, so a proof built in the browser would either name the api as its htu and be refused or name an endpoint the browser is not calling. Forwarding proofs through the api is not implemented, and the page says so in its banner rather than sending an unbound token onward as though it were bound — which is precisely the failure DPoP exists to prevent.