atproto pds in zig pds.zat.dev
pds atproto
zds docs account-security.md
5.5 kB

account security #

ZDS keeps account security state in SQLite so credentials and sessions can be listed, revoked, and audited without trusting self-contained tokens alone.

sessions #

Password login creates a durable session row keyed by the access and refresh JWT IDs. Refresh rotates that row to new JWT IDs, and revocation marks the stored row revoked. A session JWT is accepted only when its JTI still has an active storage row.

What a client hears back follows the reference PDS, because clients switch on the error name:

  • a session token this server signed whose exp has passed answers 400 ExpiredToken on every bearer route, including com.atproto.space.*. ExpiredToken is only ever reported after the signature verifies; a token that never verified is 401 InvalidToken, and a missing or non-bearer header is 401 AuthenticationRequired.
  • a refresh token the server revoked, or whose row is gone, answers 400 ExpiredToken on refreshSession. A revoked access token stays 401 InvalidToken so the client refreshes and learns the outcome there.
  • refresh is idempotent for 30 seconds: the retired refresh token answers with the same successor pair the winning request got, so a client with two requests in flight is not logged out by refreshing twice. Outside that window the retired token is refused.
  • OAuth access tokens are a different profile. Their expiry answer is a 401 with a WWW-Authenticate: ... error="invalid_token" challenge, which is what OAuth clients refresh on.

The session row also records auth_method and optional app_password_name. Account-management endpoints that create, list, or revoke app passwords require a real password session, matching the official PDS boundary that app passwords cannot mint or manage other app passwords.

app passwords #

App passwords follow the official PDS model: each row belongs to an account DID and name, stores a hashed password, creation time, privileged bit, and optional scope/controller metadata. The raw generated password is returned once from com.atproto.server.createAppPassword.

Supported routes:

  • com.atproto.server.createAppPassword
  • com.atproto.server.listAppPasswords
  • com.atproto.server.revokeAppPassword

createSession accepts either the account password or a stored app password. App-password access JWTs use the official com.atproto.appPass or com.atproto.appPassPrivileged scope, while refresh JWTs keep com.atproto.refresh. Sessions created through app passwords are attributed to the app-password name. Revoking an app password deletes the credential and revokes active sessions that were created with it.

Privileged app passwords are for trusted clients that need the official privileged app-password scope and access to sensitive account features. In the current Bluesky-compatible ecosystem, that also includes chat transition access: ZDS stores privileged app passwords with transition:generic and transition:chat.bsky; ordinary app passwords store only transition:generic.

management UI #

/account is the local resident account hub. Its security section, /account/manage, signs in with the account password, then lists passkeys and app passwords for that account. App passwords can be created and revoked from the page; the generated password is displayed once and is not recoverable later because only the hash is stored. The privileged-app-password checkbox is labeled as trusted-client behavior because ordinary clients should not request it casually.

The same hub also exposes app/OAuth sessions, direct API token families, resident email and account-status controls, and the experimental permissioned-space record browser. /security and /passkeys remain compatibility redirects to /account/manage.

references #

  • official PDS: app passwords are account-owned rows keyed by DID and name, and management requires password auth rather than OAuth or app-password auth.
  • Tranquil: extends the same account-security plane with scopes, controller DID fields, session attribution, and delegation-oriented identity boundaries.

remembered OAuth browser login #

Password authorization can opt into remembering one account in this browser for one year. This is a separate, opaque __Host-zds-browser cookie, marked Secure, HttpOnly, and SameSite=Lax. SQLite stores its SHA-256 digest, account binding, password credential version, and fixed expiry. Password changes invalidate the login; inactive accounts cannot reuse it. Application OAuth tokens keep their own lifetime and revocation rules.

A remembered login still shows the client and requested permissions and requires explicit consent. The consent POST checks the origin and a browser/request-bound CSRF value. login_hint must match the remembered account and prompt=login requires fresh authentication. The authorization page offers "forget this browser / use another account", which deletes the stored credential and cookie. Authorization pages are non-cacheable and cannot be framed.

This first increment remembers password logins only. Passkey remembrance and account-management login reuse are separate follow-ups; no new web route is introduced. The protocol allows unlimited confidential OAuth sessions, with individual refresh tokens recommended to expire within 180 days. Longer client sessions also require binding refreshes to the original client authentication key; the browser cookie does not change that policy.