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 # 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.
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:
- 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.
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