auth
auth
atproto uses OAuth 2.0 for application authorization.
the flow user visits application application redirects to user's PDS for authorization user approves requested scopes PDS redirects back with authorization code application exchanges code for tokens application uses tokens to act on user's behalf
standard OAuth, but the authorization server is the user's PDS, not a central service.
scopes
scopes define what an application can do:
atproto # base sign-in/authorization scope (the floor, always present) transition:generic # broad transitional scope: arbitrary CRUD in any namespace repo:fm.plyr.track # read/write fm.plyr.track collection repo:fm.plyr.like # read/write fm.plyr.like collection repo:read # read-only access to repo
atproto alone grants sign-in, not repo access — the permissions come from what's layered on top. transition:generic is the overly-permissive bridge scope from before granular scopes existed: plyr.fm requested atproto transition:generic repo:... in its early days (nov 2025) and later dropped transition:generic for granular repo: scopes and permission sets — it now survives only in test mocks.
granular scopes let users grant minimal permissions. an app that only needs to read your profile shouldn't have write access to your posts.
permission sets
listing individual scopes is noisy. permission sets bundle them under human-readable names:
include:fm.plyr.authFullApp # "plyr.fm Music Library"
instead of seeing fm.plyr.track, fm.plyr.like, fm.plyr.comment, ..., users see a single permission with a description.
permission sets are lexicons published to com.atproto.lexicon.schema on your authority repo.
from plyr.fm permission sets
session management
tokens expire. applications need refresh logic:
class SessionManager: def init(self, session_path: Path): self.session_path = session_path self._client: AsyncClient | None = None
async def get_client(self) -> AsyncClient:
if self._client:
return self._client
# try loading saved session
if self.session_path.exists():
session_str = self.session_path.read_text()
self._client = AsyncClient()
await self._client.login(session_string=session_str)
self._client.on_session_change(self._save_session)
return self._client
# fall back to fresh login
self._client = AsyncClient()
await self._client.login(handle, password)
self._save_session(None, None)
return self._client
def _save_session(self, event, session):
self.session_path.write_text(self._client.export_session_string())
from bot - persists sessions to disk, refreshes automatically.
per-request credentials
for multi-tenant applications (one backend serving many users), credentials come per-request:
middleware extracts from headers
x-atproto-handle: user.handle x-atproto-password: app-password
or from OAuth session
authorization: Bearer
from pdsx MCP server - accepts credentials via HTTP headers for multi-tenant deployment.
app passwords
for bots and automated tools, app passwords are simpler than full OAuth:
user creates app password in their PDS settings bot uses handle + app password to authenticate no redirect flow needed
app passwords have full account access. use OAuth with scopes when you need granular permissions.
sessions, server side
facts a PDS implementer needs that clients can ignore (from zds, zig PDS):
access and refresh tokens are separated by JWT typ header (at+jwt / refresh+jwt); refresh rotates a stored token family session JWTs are only honored if their JTI is live in the session table — revocation is enforced by storage, so a stolen-but-revoked token dies even though its signature still verifies app-password sessions are attributed to the credential that minted them: revoke the app password, its sessions go with it. app passwords can't mint or revoke other app passwords service auth
com.atproto.server.getServiceAuth mints a short-lived JWT signed by the account's own key — issuer: account DID, audience: the target service DID, optional lxm binding it to one method. the receiver verifies against the account's DID document. this is the primitive under inter-service calls (PDS→appview writes, PDS→PDS experiments).
why this matters
OAuth at the protocol level means:
users authorize apps, not the other way around applications can't lock in users by controlling auth the same identity works across all atmospheric applications granular scopes enable minimal-permission applications
for more operational notes on scope choice, permission sets, progressive scope upgrades, and when transition:generic is or is not appropriate, see ./oauth/README.md.
Discussion in the ATmosphere