auth

nate July 8, 2026
Source

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

Loading comments...