{
  "$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"
}