OAuth
Chapter 13 handed clients a pair of HS256 JWTs whenever they typed the right password. That worked, it works today, every test in the suite still exercises it. But the protocol is moving — and has been for a while — away from "send your password to every app you trust" toward a proper OAuth flow with browser-mediated consent, per-client keys, and DPoP-bound tokens that are useless to anyone who steals them in transit.
This chapter walks through both halves of OAuth on this PDS. The original session shipped the back half (the surface that lets us be a resource server once a refresh token is in hand); a follow-on session shipped the front half (the surface that mints the first refresh token through a real browser-mediated consent flow). Concretely:
- The OAuth discovery documents at
/.well-known/oauth-authorization-serverand/.well-known/oauth-protected-resource. - A JWKS endpoint at
/oauth/jwks. - The PAR endpoint at
/oauth/par(RFC 9126) — clients push their full authorize-request parameters over the back channel and get a short-livedrequest_uriopaque handle in return. - The user-facing authorization endpoint at
/oauth/authorize— looks up the PAR row, renders a login + consent screen, verifies the user's password, mints a one-shot authorization code, and 302s back to the client'sredirect_uriwith the code. - The token endpoint at
/oauth/token, implementing both theauthorization_codegrant (first-issue) and therefresh_tokengrant (rotation). - The revocation endpoint at
/oauth/revoke. - DPoP proof verification per RFC 9449.
- Client-metadata fetching + validation (
src/pds/oauth/clients.ts). - PKCE verifier ↔ challenge check (
src/pds/oauth/pkce.ts). - A new PDS-wide OAuth signing key (separate from the per-account repo keys we've been carrying since chapter 7).
- An extended
refresh_tokenstable that holds both legacy session refreshes and OAuth refreshes side by side, plus two new short-lived stores:oauth_par(PAR handles) andoauth_codes(authorization codes).
OAuth is additive. The password flow from chapter 13 continues to mint HS256 access + refresh JWTs and works on every endpoint exactly as it did before. OAuth is what we hand to a third-party client when the user wants to grant the client narrower-than-password access without ever telling the client what their password is.
The three roles
OAuth is a vocabulary problem before it's a code problem. Three roles collaborate on every flow:
- The Authorization Server is the thing that issues tokens. It owns the signing key, the consent UI, the user's identity. For atproto, the Authorization Server is the user's PDS — this one.
- The Protected Resource is the thing tokens grant access to. For atproto, every authenticated XRPC endpoint is a protected resource — so the Protected Resource is also this PDS.
- The Client is the third-party app that wants to act on the user's
behalf. It's not us. It might be an iOS reader app, a CLI tool, a
scheduled-poster bot. Each client identifies itself with a
client_idURL that points at a JSON metadata document; the AS fetches that document the first time it sees the client and trusts it from there on.
The fact that the AS and the RS roles are the same machine, in this deployment, is a convenience of the architecture. Conceptually they're distinct — and the metadata documents announce them separately so a client can confirm the AS is the one this RS trusts.
📖 Why is the PDS its own AS? Because the PDS is what holds the user's keys, their handle, their account state. Splitting the AS off would mean some other service holds the user's identity and the PDS trusts it, which is a totally different deployment shape. atproto's design keeps everything user-controllable on the same hop the user moves when they migrate. See chapter 20.
What's shipped, and what's still missing
The full first-issue → rotation loop now works end-to-end: a client can
push parameters via PAR, redirect the user through /oauth/authorize,
exchange the returned code at /oauth/token for a DPoP-bound access +
refresh pair, then keep rotating that pair via the refresh_token grant.
The integration test at tests/integration/oauth-front-half.test.ts
exercises exactly that path.
Everything else from the original 🚧 list has shipped, including the
resource-server enforcement (requireOauthAccess + requireEitherAuth,
covered below in Plumbing OAuth tokens into XRPC handlers) and the
pluggable DPoP replay store (covered below in Plumbing the DPoP
replay store):
✅/oauth/authorize— login + consent UI✅/oauth/par— Pushed Authorization RequestsClient metadata fetching and validation✅PKCE verification (S256-only)✅Authorization-code lifetime + rotation policy✅ (60 s, single-use, markedusedatomically)
DPoP — proof of possession
A bearer token is, by definition, useful to anyone who's bearing it. That's the whole point and also the whole problem. A stolen access token from chapter 13 is a 2-hour all-access pass to the account. The clock is the only thing limiting damage.
OAuth's answer is DPoP (RFC 9449). Every OAuth token we mint is bound
to a public key the client generates and holds privately. The
binding is a cnf.jkt claim in the access token: the SHA-256 thumbprint
of the client's public JWK. To use the token, the client signs a proof
JWT with their private key — and we, the resource server, refuse the
request unless the proof's key thumbprint matches the token's cnf.jkt.
So a stolen access token by itself is useless. To present it, the attacker would also need the client's private key, which never leaves the device that minted it.
A DPoP proof is a tiny JWT in the DPoP: header on every request. It
carries:
{
"typ": "dpop+jwt",
"alg": "ES256",
"jwk": { "kty": "EC", "crv": "P-256", "x": "...", "y": "..." }
}
.
{
"htm": "POST",
"htu": "https://pds.example.com/oauth/token",
"iat": 1735689600,
"jti": "5JZk2v..."
}
htm and htu bind the proof to this exact request. iat keeps the
proof fresh (we reject anything outside ±60 seconds). jti is a random
identifier we cache in memory for the same 60-second window — replay of
a previously-accepted proof fails. The signature confirms the client
holds the private half of the embedded jwk.
The atproto profile mandates ES256K (secp256k1, the same curve used for
repo signing) on every DPoP proof. We accept that and also accept ES256
(P-256) — they're cryptographically equivalent at the same security
level, and most existing OAuth client libraries default to ES256. The
verifier in src/pds/oauth/dpop.ts is alg-aware and dispatches to
@noble/curves's secp256k1 or p256 accordingly. Real-world interop > spec
purity.
⚠️ The default replay store is in-process. A multi-process deployment doesn't share a
Map, so a proof accepted on process A can be replayed against process B within the 60-second window. The store is pluggable behindDpopReplayStore(see Plumbing the DPoP replay store below) — swap in a Redis-backed implementation for multi-replica.
The PDS's OAuth signing key
The chapter-13 tokens were HS256: same key signs and verifies, no third-party verification needed, no key-management story. That works when the only thing reading our tokens is us.
OAuth tokens are read by clients — to figure out their expiry, to bind
them to their DPoP key, to know which scopes the user granted. So we
need an asymmetric key: we sign with the private half, anyone with the
public half can verify. The public half goes on /oauth/jwks.
The key lives in a single new env var:
PDS_OAUTH_SIGNING_KEY=<64 hex chars / 32 bytes>
Generate one with:
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
It's a k256 (secp256k1) private scalar, hex-encoded — exactly the same
shape as the per-account signing_key_priv column we've been writing
since chapter 12. That's not an accident: we already have all the
primitives. What's different is whose key it is. The account-level keys
sign Merkle-tree commits on the user's behalf. The OAuth signing key
signs JWTs on the PDS's behalf as an authorization server. One key per
deployment, lifetime = deployment lifetime; you don't rotate it casually
because every issued token has its kid baked into the header.
src/pds/oauth/keys.ts loads the hex scalar, derives the uncompressed
public key (X and Y separately), and builds the JWK:
{
kty: 'EC',
crv: 'secp256k1',
x: <base64url(X)>,
y: <base64url(Y)>,
alg: 'ES256K',
use: 'sig',
kid: <RFC 7638 thumbprint>
}
kid is the RFC 7638 thumbprint of the canonical JWK (the SHA-256 of
the JSON with members sorted by name, encoded as base64url). Clients
that fetch our JWKS index entries by kid, so making it the
self-describing thumbprint means a client that already knows our key
doesn't even need to fetch the JWKS — they already know the kid will
match.
📖 Why does it have to be ES256K? It doesn't. The OAuth spec is alg-agnostic; ES256 would work just as well, and most generic OAuth libraries default to ES256. atproto's profile picks ES256K for consistency with the rest of the protocol (the user's repo key is secp256k1; the PLC operations are signed with secp256k1; using a different curve on a third surface would be a footgun). We follow the profile.
Token shapes
Access token
{
"iss": "https://pds.example.com",
"aud": "did:web:pds.example.com",
"sub": "did:plc:alice",
"scope": "atproto transition:generic",
"cnf": { "jkt": "uTuw...iWcA" },
"iat": 1735689600,
"exp": 1735691400,
"jti": "BqMs..."
}
Header: { "alg": "ES256K", "typ": "at+jwt", "kid": "<our kid>" }.
The load-bearing differences from a chapter-13 access token:
issis the public URL of the PDS, not the service DID. (Chapter 13 used the DID. OAuth issuers are URLs.)cnf.jktis the SHA-256 thumbprint of the client's DPoP key. The RS middleware on every authenticated request will require an accompanying DPoP proof whose key has the same thumbprint.scopeis the OAuth scope string the user granted.- The signature is ES256K with the PDS's OAuth signing key, not HS256
with
PDS_JWT_SECRET.
Default TTL: 30 minutes. Shorter than chapter 13's 2 hours, because the DPoP binding limits the damage from an exposed token in flight, but the issuance loop (refresh → access) is cheap enough that we trade off in the safer direction.
Refresh token
Same shape, with two differences in the body:
{
"iss": "https://pds.example.com",
"aud": "did:web:pds.example.com",
"sub": "did:plc:alice",
"scope": "atproto transition:generic",
"cnf": { "jkt": "uTuw...iWcA" },
"token_kind": "refresh",
"iat": 1735689600,
"exp": 1740873600,
"jti": "9pZL..."
}
token_kind: "refresh"is our own claim. It's the same defense chapter 13'sscope: "com.atproto.refresh"provides: the verifier refuses to honour a refresh token where the call is asking for an access token, and vice versa. Without it, a verifier that wasn't paying attention would treat the two as interchangeable.- Header
typisrefresh+jwt(mirroring chapter 13 style). - TTL is 60 days, matching chapter 13.
The DPoP binding (cnf.jkt) is critical here too: a refresh token by
itself doesn't let the bearer mint access tokens. They also need to
present a DPoP proof signed by the bound key.
📖 Why is
dpop_jktalso stored in the database row? Belt and suspenders. The JWT body carries it, so a forgery would need both a differentcnf.jktand a forged signature with our key — but the row is the canonical source of truth. If we ever issued a refresh token with the wrong cnf and tried to validate, the row check catches it.
The refresh flow
A client with a valid refresh token in hand and the DPoP key it was
issued for in scope hits POST /oauth/token like this:
POST /oauth/token HTTP/1.1
Host: pds.example.com
Content-Type: application/x-www-form-urlencoded
DPoP: <compact DPoP proof JWT>
grant_type=refresh_token&refresh_token=<jwt>&client_id=https://app.example/client.json
Inside, in order:
- Verify the DPoP proof against
POSTandhttps://pds.example.com/oauth/token. We don't yet know whatcnf.jktto expect, so we just compute the proof's key thumbprint and remember it. - Validate the refresh token JWT: signature with our OAuth public
key, issuer, audience, expiry,
token_kind === 'refresh', andcnf.jktmatching the thumbprint from step 1. - Look up the refresh row by
jti. Confirmkind === 'oauth'anddpop_jkt === proof.jkt(second cross-check — the row is authoritative). - Delete the row. This is the rotation step from chapter 13 applied to OAuth refreshes — one use, then dead.
- Optionally narrow
scope. RFC 6749 §6 lets the client downscope on refresh but never broaden. We intersect. - Mint a new access token with
signOauthAccessToken. Itscnf.jktmatches the proof's key thumbprint, so the same DPoP key keeps working. - Mint a new refresh token with
signOauthRefreshToken, which inserts the new row withkind='oauth'and the samedpop_jkt. - Return the pair as a JSON
{ access_token, token_type: 'DPoP', expires_in, refresh_token, scope, sub }.
Steps 4 and 7 are not in a database transaction. A crash between them
leaves the user with a working access token but no refresh token —
they'd need to log in again the next time the access expired. We
accepted this for the teaching port; production would BEGIN ... COMMIT
around the rotation. Compare with chapter 13's same trade-off in
rotateRefreshToken.
Revocation
POST /oauth/revoke is RFC 7009. The body is
token=<jwt>&token_type_hint=refresh_token, form-encoded. We decode the
JWT without verifying (we only need its jti claim to address a row),
delete the matching row, and return 200 with an empty body — even if the
token didn't exist, was malformed, or had already been revoked.
That last bit is mandated by the spec: a revocation endpoint must not leak which tokens are valid. Returning different statuses based on "was the token good" would let an attacker probe for which strings correspond to live sessions. So we say "OK" to everything.
Access tokens aren't stored, so there's nothing to revoke for them; if
the client hints token_type_hint=access_token we still 200, just
without doing anything. Their natural expiry handles the rest.
DPoP on /revoke is optional per the spec. A logged-out user who lost
their key still needs a way to revoke the matching server-side row;
requiring DPoP would orphan the row. We accept calls with or without
the header.
The refresh-token row, extended
The chapter-13 refresh_tokens table held the bare minimum for legacy
sessions: jti, did, expires_at, created_at, app_password_name.
OAuth refreshes need three more bits per row:
ALTER TABLE refresh_tokens ADD COLUMN kind text NOT NULL DEFAULT 'session';
ALTER TABLE refresh_tokens ADD COLUMN dpop_jkt text; -- nullable
ALTER TABLE refresh_tokens ADD COLUMN scope text; -- nullable
kinddistinguishes'session'(legacy) from'oauth'(new). The default'session'keeps every existing row valid without a backfill.dpop_jktis the SHA-256 thumbprint of the client's DPoP key. NULL for session rows; set for oauth rows.scopeis the OAuth scope string the row was issued for. NULL for session rows; set for oauth rows.
The session flow in src/pds/auth/session.ts doesn't touch the new
columns at all — its inserts leave them at their defaults (kind =
'session', the rest NULL). The OAuth flow always populates all three.
A future "list all my sessions across both protocols" endpoint would
read both kinds together and render them in a unified view.
The full authorization flow
With both halves in place, here's what an OAuth client does end-to-end to get its first access token. Each step names the spec it's implementing so you can cross-reference.
-
Discover the AS by fetching
/.well-known/oauth-authorization-server(RFC 8414). The client reads our PAR endpoint, token endpoint, scopes, supported DPoP algs. -
Generate a fresh DPoP keypair (per-session, per-app — never reused across clients). Compute its RFC 7638 thumbprint; that's the
jktit'll bind tokens to. -
Generate a PKCE pair — 32 random bytes base64url'd is the
code_verifier;base64url(sha256(verifier))is thecode_challenge. Pickstate(random) the same way. -
PAR push — POST
/oauth/parwith the parameters:client_id https://app.example.com/client-metadata.json response_type code redirect_uri https://app.example.com/cb scope atproto transition:generic state <random> code_challenge <base64url sha256 of the verifier> code_challenge_method S256 dpop_jkt <thumbprint of the DPoP key> login_hint alice.example.com (optional)On success the client gets
{ request_uri, expires_in: 60 }. -
Redirect the user's browser to
/oauth/authorize?request_uri=<urn>. The PDS looks up the PAR row, renders a login + consent screen pre-filled with thelogin_hint, sets a CSRF cookie, and waits for the form POST. -
Sign in. The user types their handle + password, the browser POSTs back to
/oauth/authorize?request_uri=<urn>. The PDS verifies the CSRF token, verifies the password via the sameloginWithPasswordchapter 13 uses, mints a one-shot authorizationcodebound to the PAR row'sdpop_jkt/ PKCE challenge / scope, deletes the PAR row, and 302s the browser to<redirect_uri>?code=<code>&state=<state>&iss=<issuer>. -
Token exchange. The client POSTs
/oauth/tokenwith:grant_type authorization_code code <the code> redirect_uri <must match step 4> client_id <must match step 4> code_verifier <the raw PKCE verifier from step 3>Headers include
DPoP: <freshly signed proof>whosejktmatches what was pinned at PAR time. The PDS verifies the DPoP proof, atomically marks the code used, verifiessha256(verifier) === challenge, cross-checksredirect_uriandclient_id, then mints an access + refresh JWT pair both bound (cnf.jkt) to the DPoP key. -
Use the access token with
Authorization: DPoP <access_jwt>+DPoP: <fresh proof for this request>on every call. -
Rotate by POSTing
/oauth/tokenwithgrant_type=refresh_tokenwhen the access expires. The refresh token is single-use — the old row gets deleted, a fresh pair gets minted with the samecnf.jkt.
Run it locally
# 0. Generate signing key + start the dev server.
export PDS_OAUTH_SIGNING_KEY=$(openssl rand -hex 32)
pnpm dev
// dev-oauth-flow.ts — run with `pnpm tsx dev-oauth-flow.ts`.
import {
SignJWT,
exportJWK,
generateKeyPair,
calculateJwkThumbprint,
} from 'jose'
import { createHash, randomBytes } from 'node:crypto'
const PDS = 'http://localhost:3000'
const CLIENT_ID = `${PDS}/dev-client.json` // host the JSON yourself
const REDIRECT_URI = `${PDS}/dev-client/cb`
const { privateKey, publicKey } = await generateKeyPair('ES256', {
extractable: true,
})
const jwk = await exportJWK(publicKey)
const jkt = await calculateJwkThumbprint(jwk, 'sha256')
const verifier = randomBytes(32).toString('base64url')
const challenge = createHash('sha256').update(verifier).digest('base64url')
const state = randomBytes(16).toString('base64url')
const par = await fetch(`${PDS}/oauth/par`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
client_id: CLIENT_ID,
response_type: 'code',
redirect_uri: REDIRECT_URI,
scope: 'atproto transition:generic',
state,
code_challenge: challenge,
code_challenge_method: 'S256',
dpop_jkt: jkt,
login_hint: 'alice.test',
}),
}).then((r) => r.json())
console.log(`open in your browser:
${PDS}/oauth/authorize?request_uri=${encodeURIComponent(par.request_uri)}`)
// After sign-in, the redirect URL will contain ?code=<...> — paste it:
const code = process.argv[2]
if (!code) {
console.error('run again with the code from the redirect:')
console.error(' pnpm tsx dev-oauth-flow.ts <code>')
process.exit(1)
}
const proof = await new SignJWT({
htm: 'POST',
htu: `${PDS}/oauth/token`,
jti: randomBytes(8).toString('base64url'),
})
.setProtectedHeader({ alg: 'ES256', typ: 'dpop+jwt', jwk })
.setIssuedAt()
.sign(privateKey)
const tokens = await fetch(`${PDS}/oauth/token`, {
method: 'POST',
headers: {
'content-type': 'application/x-www-form-urlencoded',
DPoP: proof,
},
body: new URLSearchParams({
grant_type: 'authorization_code',
code,
redirect_uri: REDIRECT_URI,
client_id: CLIENT_ID,
code_verifier: verifier,
}),
}).then((r) => r.json())
console.log(tokens)
// → { access_token, refresh_token, token_type: 'DPoP', expires_in, scope, sub }
The sub claim is the user's DID; the access token's cnf.jkt matches
the thumbprint of the DPoP key you generated; the refresh token is
already persisted in the refresh_tokens table with kind='oauth' and
the same dpop_jkt.
Plumbing OAuth tokens into XRPC handlers
The front half mints tokens; the back half lets clients use them.
Wave 9B closed the loop in src/pds/auth/middleware.ts; Wave 10B
finished the migration:
export async function requireOauthAccess(args: {
authorization?: string
dpopProof?: string
request: Request
opts?: AuthOptions
}): Promise<AuthenticatedAccount & { scope: string }>
export async function requireEitherAuth(args: {
authorization?: string
dpopProof?: string
request: Request
opts?: AuthOptions
}): Promise<AuthenticatedAccount & { scope: string }>
export async function requireAuthWithScope(
ctx: { authorization?: string; dpopProof?: string; request: Request },
scope: 'atproto' | 'transition:generic',
opts?: AuthOptions,
): Promise<AuthenticatedAccount & { scope: string }>
A client paired with an OAuth token sends:
Authorization: DPoP <oauth-access-jwt>
DPoP: <proof-jwt>
The dispatcher in src/pds/xrpc/server.ts carries the paired DPoP:
header alongside the existing Authorization header in HandlerCtx.dpopProof
— literally just request.headers.get('dpop'). Handlers call
requireAuthWithScope({ authorization, dpopProof, request }, scope), which:
- Inspects the scheme.
Bearer …delegates to the chapter-13requireAccessAuthand tags the result withscope: 'session'.DPoP …delegates torequireOauthAccessand tags the result with the scope claim from the OAuth token. Anything else is anUnauthorizedInvalidToken. - For DPoP: strip the prefix, verify the access JWT against our
OAuth public key, then verify the proof JWT with
expectedJktset to the token'scnf.jkt. The proof'shtm/htumust match the live request — that's the proof-of-possession binding. MissingDPoP:isAuthMissing; jkt mismatch / replay / signature failure isInvalidToken. - Loads the account by the token's
suband applies the same active / deactivated gate the legacy flow uses. - Enforces scope.
requireScope(account, required)lets every session-flow caller through (the legacy flow predates OAuth scopes and is treated as fully privileged), accepts an OAuth token whose space-separated scope claim containsrequired, and treatstransition:genericas a superset ofatproto. Anything else isForbiddenInsufficientScope.
Required scope per handler
All ~30 auth-required handlers now accept both schemes; the table below lists the scope each one demands of an OAuth caller. Two atproto-profile scopes drive the matrix:
atproto— minimal "who am I" — lets the client confirm identity and read non-sensitive data.transition:generic— strict superset — also lets the client write on the user's behalf.
| Handler | Required scope |
|---|---|
com.atproto.server.getSession |
atproto |
com.atproto.server.getAccountInviteCodes |
atproto |
com.atproto.server.checkAccountStatus |
atproto (allowDeactivated) |
com.atproto.server.getServiceAuth |
atproto |
com.atproto.server.createAppPassword |
transition:generic |
com.atproto.server.listAppPasswords |
transition:generic |
com.atproto.server.revokeAppPassword |
transition:generic |
com.atproto.server.requestEmailConfirmation |
transition:generic |
com.atproto.server.confirmEmail |
transition:generic |
com.atproto.server.requestEmailUpdate |
transition:generic |
com.atproto.server.updateEmail |
transition:generic |
com.atproto.server.requestAccountDelete |
transition:generic |
com.atproto.server.deleteAccount |
transition:generic |
com.atproto.server.deactivateAccount |
transition:generic |
com.atproto.server.activateAccount |
transition:generic (allowDeactivated) |
com.atproto.repo.createRecord |
transition:generic |
com.atproto.repo.putRecord |
transition:generic |
com.atproto.repo.deleteRecord |
transition:generic |
com.atproto.repo.applyWrites |
transition:generic |
com.atproto.repo.uploadBlob |
transition:generic |
com.atproto.repo.importRepo |
transition:generic |
com.atproto.identity.updateHandle |
transition:generic |
A small set of authenticated handlers is intentionally session-only and does not accept the OAuth scheme:
com.atproto.server.refreshSessionandcom.atproto.server.deleteSessioncarry their own refresh-token shape (chapter 13'srequireRefreshAuth); the OAuth equivalent is/oauth/tokenwithgrant_type=refresh_tokenplus a DPoP proof. There's no shared surface to migrate.com.atproto.server.createInviteCodeandcreateInviteCodesare admin-only and run throughrequireAdmin(HTTP Basic) — a totally separate scheme.- The entire
com.atproto.admin.*namespace is admin-Basic too.
The mechanical migration
The pattern for each migrated handler is one line at the top of the body:
- import { requireAccessAuth } from '~/pds/auth/middleware'
+ import { requireAuthWithScope } from '~/pds/auth/middleware'
- const handler: Handler = async ({ authorization }) => {
- const me = await requireAccessAuth(authorization)
+ const handler: Handler = async ({ authorization, dpopProof, request }) => {
+ const me = await requireAuthWithScope(
+ { authorization, dpopProof, request },
+ 'transition:generic',
+ )
The returned me carries an additional scope field — 'session' if
the caller used the legacy flow, or the OAuth token's scope claim if
they used DPoP. The requireAuthWithScope wrapper composes
requireEitherAuth + requireScope; handlers that want the dispatcher
result without a scope assertion (none today) can still call
requireEitherAuth directly.
Plumbing the DPoP replay store
verifyDpopProof doesn't track jti values itself — it delegates to a
small interface:
// src/pds/oauth/dpop_store.ts
export interface DpopReplayStore {
checkAndRecord(jti: string): Promise<{ firstSeen: boolean }>
reset?(): Promise<void>
}
checkAndRecord is the atomic primitive: check whether jti has been
seen inside the 60-second window and, if not, record it. A firstSeen: true result means the proof is fresh and the request continues; a
firstSeen: false result means it's a replay and the verifier throws.
The window is fixed at ~60s because DPoP proofs themselves expire that
fast (the iat ±60s tolerance, see above) — anything we'd remember
longer is wasted memory; anything shorter opens a replay gap.
Two backends ship.
InMemoryDpopReplayStore is the default. It's a Map<jti, expiresAtMs> with a 16384-entry cap. Every checkAndRecord lazily
sweeps expired entries, then checks the cap and drops the oldest by
insertion order if a new entry would push us over. Single-process
deployments — every dev setup and most small self-hosts — are fine on
this. Cross-process replays aren't a concern because there is no
second process.
RedisDpopReplayStore is a documented stub. The teaching port's
"no new deps" rule keeps ioredis out of package.json, but the
intended implementation is one Redis primitive away:
-- atomic check-and-record, 60s expiry
if redis.call('SET', KEYS[1], 1, 'NX', 'EX', 60) then
return 1 -- firstSeen
else
return 0 -- replay
end
SET … NX EX 60 is "set this key to 1 only if it doesn't already
exist, and expire it in 60 seconds, atomically". The same call returns
the truthy or falsy answer for firstSeen. No second round-trip, no
race between processes that both saw the same proof — Redis serialises.
Wire ioredis in, replace the stub body with one SET call, done.
The selector reads PDS_DPOP_REPLAY_STORE (default 'in-memory',
'redis' selects the stub) once on first call and caches the result.
getDpopReplayStore() is the only entry point verifyDpopProof
touches; the rest of the OAuth code path is unchanged.
This mirrors the shape the rate-limit store uses (chapter 18 —
src/pds/xrpc/rate_limit.ts). Same two-implementation split, same env
selector, same "the stub throws with a chapter pointer" convention.
Once a deployment has a Redis client in scope, both stores can be
swapped together.
Try it
The end-to-end flow — DPoP keypair, PAR push, consent-page sign-in, token redemption — is in the "Run it locally" section above. The shorter "poke the discovery surface" variant:
# 0. Generate a PDS OAuth signing key, if you haven't already.
export PDS_OAUTH_SIGNING_KEY=$(openssl rand -hex 32)
# Restart `pnpm dev` so the new env var is picked up.
# 1. Inspect the discovery doc.
curl -s http://localhost:3000/.well-known/oauth-authorization-server | jq
# 2. Inspect the JWKS — there should be exactly one key, alg=ES256K.
curl -s http://localhost:3000/oauth/jwks | jq
# 3. Inspect the protected-resource metadata.
curl -s http://localhost:3000/.well-known/oauth-protected-resource | jq
The refresh-only path (assuming you already have a refresh token from the authorization-code flow) looks like:
// Per request.
async function dpopProof(method: string, url: string): Promise<string> {
return new SignJWT({
htm: method,
htu: url,
jti: crypto.randomUUID(),
})
.setProtectedHeader({ alg: 'ES256', typ: 'dpop+jwt', jwk: dpopJwk })
.setIssuedAt()
.sign(privateKey)
}
const proof = await dpopProof('POST', 'http://localhost:3000/oauth/token')
const res = await fetch('http://localhost:3000/oauth/token', {
method: 'POST',
headers: {
'content-type': 'application/x-www-form-urlencoded',
DPoP: proof,
},
body: new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: refreshJwt,
client_id: 'http://localhost:3000/dev-client.json',
}),
})
const { access_token, refresh_token, expires_in } = await res.json()
On success: a fresh DPoP-bound access JWT and a rotated refresh JWT.
Decode the access token (the middle base64url segment) and you'll see
the cnf.jkt claim — that's the thumbprint of the DPoP key you
generated up front.
Finally, use the access token to call a real XRPC endpoint —
getSession is the first one to accept the DPoP scheme:
const getSessionUrl = 'http://localhost:3000/xrpc/com.atproto.server.getSession'
const getSessionProof = await dpopProof('GET', getSessionUrl)
const me = await fetch(getSessionUrl, {
method: 'GET',
headers: {
authorization: `DPoP ${access_token}`,
dpop: getSessionProof,
},
}).then((r) => r.json())
console.log(me)
// → { did, handle, email, emailConfirmed: true, didDoc, active: true }
The proof must be fresh — try the same fetch twice and the second one
fails with InvalidToken because the jti is now in the replay cache.
Exercises
-
The
/oauth/tokenendpoint acceptsscopeon a refresh request and narrows the existing grant. Walk through what should happen if the client requests a scope that's broader than the granted scope. What's the spec answer? What does our implementation do today? -
The DPoP replay cache holds
jtivalues for 60 seconds in process memory. A malicious replay arriving 61 seconds after the original would be accepted. Why is that OK? What threat is the cache actually defending against — and what threat is theiat±60s tolerance defending against? -
Read
requireOauthAccessinsrc/pds/auth/middleware.ts. It composesverifyOauthAccessToken(signature + claims) andverifyDpopProof(proof-of-possession on this request). What error names does it raise, and which one fires when (a) theDPoP:header is missing, (b) the proof's key thumbprint doesn't match the token'scnf.jkt, (c) the proof'shtmsays POST but the request is a GET? Cross- reference the chapter-13Unauthorized/Forbiddentaxonomy.
Up next
This is the end of the back half of OAuth. The next session takes on the front half: authorize, PAR, client metadata, PKCE, the consent UI. Together they'll let real third-party clients onboard a Bluesky user without ever touching the password.