{
"$type": "site.standard.document",
"content": {
"$type": "at.markpub.markdown",
"flavor": "gfm",
"renderingRules": "marked",
"text": {
"$type": "at.markpub.text",
"markdown": "# DPoP on the server side\n\natproto OAuth requires DPoP (proof-of-possession tokens). the client side is\nwell documented; these are the server-side mechanics, from\n[zds](https://tangled.org/zat.dev/zds), a PDS implementation in zig\n(`src/atproto/oauth/dpop.zig`).\n\n## nonces can be stateless\n\nthe server must issue and rotate DPoP nonces, but nothing requires storing\nthem. zds derives the nonce as\n`base64url(HMAC-SHA256(secret, big_endian(time / rotation_secs)))` and accepts\nthe counter−1 / counter / counter+1 windows on verification (`dpop.zig:199-230`).\nrotation and validation with zero storage; the only thing persisted is JTI\nreplay protection (`recordDpopJti(jti, expires_at)`).\n\n## the use_dpop_nonce dance needs both channels\n\nwhen a proof arrives without (or with a stale) nonce, respond with the fresh\nnonce in a `DPoP-Nonce` header *and*\n`WWW-Authenticate: DPoP error=\"use_dpop_nonce\"` (`dpop.zig:30-45`) — real\nclients key their retry off the www-authenticate error code, and sending only\nthe header strands them.\n\n## proof verification, field by field\n\n- `htm` — matches the http method\n- `htu` — matches `publicUrl + path` with query and fragment stripped before\n comparison (`dpop.zig:183-197`); proxies that rewrite paths break this first\n- `ath` — hash of the access token; must be present when a token is bound and\n **absent when there is none** (`dpop.zig:127`) — accepting a spurious `ath`\n is a spec violation that most test suites miss\n- `jkt` — the key thumbprint binding, computed per RFC 7638 with the canonical\n member order `{crv, kty, x, y}` (`dpop.zig:169-180`); any other member order\n produces a different thumbprint and every token check fails\n- the EC public key is reconstructed from the JWK's `x`/`y` with the parity\n byte taken from `y[31] & 1` (`dpop.zig:145-160`)\n\n## sources\n\n- [zds](https://tangled.org/zat.dev/zds) — `src/atproto/oauth/dpop.zig`\n- [atproto OAuth spec](https://atproto.com/specs/oauth)\n- [RFC 9449 (DPoP)](https://datatracker.ietf.org/doc/html/rfc9449), [RFC 7638 (JWK thumbprint)](https://datatracker.ietf.org/doc/html/rfc7638)\n"
}
},
"path": "/protocols/atproto/oauth/dpop",
"publishedAt": "2026-07-09T05:07:30.962Z",
"site": "at://did:plc:xbtmt2zjwlrfegqvch7fboei/site.standard.publication/notes",
"textContent": "DPoP on the server side\n\natproto OAuth requires DPoP (proof-of-possession tokens). the client side is well documented; these are the server-side mechanics, from zds, a PDS implementation in zig (src/atproto/oauth/dpop.zig).\n\nnonces can be stateless\n\nthe server must issue and rotate DPoP nonces, but nothing requires storing them. zds derives the nonce as base64url(HMAC-SHA256(secret, big_endian(time / rotation_secs))) and accepts the counter−1 / counter / counter+1 windows on verification (dpop.zig:199-230). rotation and validation with zero storage; the only thing persisted is JTI replay protection (recordDpopJti(jti, expires_at)).\n\nthe use_dpop_nonce dance needs both channels\n\nwhen a proof arrives without (or with a stale) nonce, respond with the fresh nonce in a DPoP-Nonce header and WWW-Authenticate: DPoP error=\"use_dpop_nonce\" (dpop.zig:30-45) — real clients key their retry off the www-authenticate error code, and sending only the header strands them.\n\nproof verification, field by field\nhtm — matches the http method\nhtu — matches publicUrl + path with query and fragment stripped before comparison (dpop.zig:183-197); proxies that rewrite paths break this first\nath — hash of the access token; must be present when a token is bound and absent when there is none (dpop.zig:127) — accepting a spurious ath is a spec violation that most test suites miss\njkt — the key thumbprint binding, computed per RFC 7638 with the canonical member order {crv, kty, x, y} (dpop.zig:169-180); any other member order produces a different thumbprint and every token check fails\nthe EC public key is reconstructed from the JWK's x/y with the parity byte taken from y[31] & 1 (dpop.zig:145-160)\nsources\nzds — src/atproto/oauth/dpop.zig\natproto OAuth spec\nRFC 9449 (DPoP), RFC 7638 (JWK thumbprint)",
"title": "DPoP on the server side",
"updatedAt": "2026-07-21T19:27:28.082Z"
}