about things

auth#

atproto uses OAuth 2.0 for application authorization.

the flow#

  1. user visits application
  2. application redirects to user's PDS for authorization
  3. user approves requested scopes
  4. PDS redirects back with authorization code
  5. application exchanges code for tokens
  6. 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                           # full access (legacy)
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

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 <token>

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:

  1. user creates app password in their PDS settings
  2. bot uses handle + app password to authenticate
  3. no redirect flow needed

app passwords have full account access. use OAuth with scopes when you need granular permissions.

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