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
exphas passed answers400 ExpiredTokenon every bearer route, includingcom.atproto.space.*.ExpiredTokenis only ever reported after the signature verifies; a token that never verified is401 InvalidToken, and a missing or non-bearer header is401 AuthenticationRequired. - a refresh token the server revoked, or whose row is gone, answers
400 ExpiredTokenonrefreshSession. A revoked access token stays401 InvalidTokenso 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
401with aWWW-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.createAppPasswordcom.atproto.server.listAppPasswordscom.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.