{
  "$type": "site.standard.document",
  "content": {
    "$type": "at.markpub.markdown",
    "flavor": "gfm",
    "renderingRules": "marked",
    "text": {
      "$type": "at.markpub.text",
      "markdown": "# auth\n\natproto uses OAuth 2.0 for application authorization.\n\n## the flow\n\n1. user visits application\n2. application redirects to user's PDS for authorization\n3. user approves requested scopes\n4. PDS redirects back with authorization code\n5. application exchanges code for tokens\n6. application uses tokens to act on user's behalf\n\nstandard OAuth, but the authorization server is the user's PDS, not a central service.\n\n## scopes\n\nscopes define what an application can do:\n\n```\natproto                           # base sign-in/authorization scope (the floor, always present)\ntransition:generic                # broad transitional scope: arbitrary CRUD in any namespace\nrepo:fm.plyr.track                # read/write fm.plyr.track collection\nrepo:fm.plyr.like                 # read/write fm.plyr.like collection\nrepo:read                         # read-only access to repo\n```\n\n`atproto` alone grants sign-in, not repo access — the permissions come from what's\nlayered on top. `transition:generic` is the overly-permissive bridge scope from\nbefore granular scopes existed: plyr.fm requested `atproto transition:generic\nrepo:...` in its early days (nov 2025) and later dropped `transition:generic` for\ngranular `repo:` scopes and permission sets — it now survives only in test mocks.\n\ngranular scopes let users grant minimal permissions. an app that only needs to read your profile shouldn't have write access to your posts.\n\n## permission sets\n\nlisting individual scopes is noisy. permission sets bundle them under human-readable names:\n\n```\ninclude:fm.plyr.authFullApp       # \"plyr.fm Music Library\"\n```\n\ninstead of seeing `fm.plyr.track, fm.plyr.like, fm.plyr.comment, ...`, users see a single permission with a description.\n\npermission sets are lexicons published to `com.atproto.lexicon.schema` on your authority repo.\n\nfrom [plyr.fm permission sets](https://github.com/zzstoatzz/plyr.fm/blob/main/docs/lexicons/overview.md#permission-sets)\n\n## session management\n\ntokens expire. applications need refresh logic:\n\n```python\nclass SessionManager:\n    def __init__(self, session_path: Path):\n        self.session_path = session_path\n        self._client: AsyncClient | None = None\n\n    async def get_client(self) -> AsyncClient:\n        if self._client:\n            return self._client\n\n        # try loading saved session\n        if self.session_path.exists():\n            session_str = self.session_path.read_text()\n            self._client = AsyncClient()\n            await self._client.login(session_string=session_str)\n            self._client.on_session_change(self._save_session)\n            return self._client\n\n        # fall back to fresh login\n        self._client = AsyncClient()\n        await self._client.login(handle, password)\n        self._save_session(None, None)\n        return self._client\n\n    def _save_session(self, event, session):\n        self.session_path.write_text(self._client.export_session_string())\n```\n\nfrom [bot](https://github.com/zzstoatzz/bot) - persists sessions to disk, refreshes automatically.\n\n## per-request credentials\n\nfor multi-tenant applications (one backend serving many users), credentials come per-request:\n\n```python\n# middleware extracts from headers\nx-atproto-handle: user.handle\nx-atproto-password: app-password\n\n# or from OAuth session\nauthorization: Bearer <token>\n```\n\nfrom [pdsx MCP server](https://github.com/zzstoatzz/pdsx) - accepts credentials via HTTP headers for multi-tenant deployment.\n\n## app passwords\n\nfor bots and automated tools, app passwords are simpler than full OAuth:\n\n1. user creates app password in their PDS settings\n2. bot uses handle + app password to authenticate\n3. no redirect flow needed\n\napp passwords have full account access. use OAuth with scopes when you need granular permissions.\n\n## sessions, server side\n\nfacts a PDS implementer needs that clients can ignore (from\n[zds](https://tangled.org/zat.dev/zds), zig PDS):\n\n- access and refresh tokens are separated by JWT `typ` header (`at+jwt` /\n  `refresh+jwt`); refresh rotates a stored token *family*\n- session JWTs are only honored if their JTI is live in the session table —\n  revocation is enforced by storage, so a stolen-but-revoked token dies even\n  though its signature still verifies\n- app-password sessions are attributed to the credential that minted them:\n  revoke the app password, its sessions go with it. app passwords can't mint\n  or revoke other app passwords\n\n## service auth\n\n`com.atproto.server.getServiceAuth` mints a short-lived JWT signed by the\naccount's own key — issuer: account DID, audience: the target service DID,\noptional `lxm` binding it to one method. the receiver verifies against the\naccount's DID document. this is the primitive under inter-service calls\n(PDS→appview writes, PDS→PDS experiments).\n\n## why this matters\n\nOAuth at the protocol level means:\n\n- users authorize apps, not the other way around\n- applications can't lock in users by controlling auth\n- the same identity works across all atmospheric applications\n- granular scopes enable minimal-permission applications\n\nfor more operational notes on scope choice, permission sets, progressive scope upgrades, and when `transition:generic` is or is not appropriate, see [`./oauth/README.md`](./oauth/README.md).\n"
    }
  },
  "path": "/protocols/atproto/auth",
  "publishedAt": "2026-07-08T20:16:04.408Z",
  "site": "at://did:plc:xbtmt2zjwlrfegqvch7fboei/site.standard.publication/notes",
  "textContent": "auth\n\natproto uses OAuth 2.0 for application authorization.\n\nthe flow\nuser visits application\napplication redirects to user's PDS for authorization\nuser approves requested scopes\nPDS redirects back with authorization code\napplication exchanges code for tokens\napplication uses tokens to act on user's behalf\n\nstandard OAuth, but the authorization server is the user's PDS, not a central service.\n\nscopes\n\nscopes define what an application can do:\n\natproto                           # base sign-in/authorization scope (the floor, always present)\ntransition:generic                # broad transitional scope: arbitrary CRUD in any namespace\nrepo:fm.plyr.track                # read/write fm.plyr.track collection\nrepo:fm.plyr.like                 # read/write fm.plyr.like collection\nrepo:read                         # read-only access to repo\n\n\natproto 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.\n\ngranular scopes let users grant minimal permissions. an app that only needs to read your profile shouldn't have write access to your posts.\n\npermission sets\n\nlisting individual scopes is noisy. permission sets bundle them under human-readable names:\n\ninclude:fm.plyr.authFullApp       # \"plyr.fm Music Library\"\n\n\ninstead of seeing fm.plyr.track, fm.plyr.like, fm.plyr.comment, ..., users see a single permission with a description.\n\npermission sets are lexicons published to com.atproto.lexicon.schema on your authority repo.\n\nfrom plyr.fm permission sets\n\nsession management\n\ntokens expire. applications need refresh logic:\n\nclass SessionManager:\n    def __init__(self, session_path: Path):\n        self.session_path = session_path\n        self._client: AsyncClient | None = None\n\n    async def get_client(self) -> AsyncClient:\n        if self._client:\n            return self._client\n\n        # try loading saved session\n        if self.session_path.exists():\n            session_str = self.session_path.read_text()\n            self._client = AsyncClient()\n            await self._client.login(session_string=session_str)\n            self._client.on_session_change(self._save_session)\n            return self._client\n\n        # fall back to fresh login\n        self._client = AsyncClient()\n        await self._client.login(handle, password)\n        self._save_session(None, None)\n        return self._client\n\n    def _save_session(self, event, session):\n        self.session_path.write_text(self._client.export_session_string())\n\n\nfrom bot - persists sessions to disk, refreshes automatically.\n\nper-request credentials\n\nfor multi-tenant applications (one backend serving many users), credentials come per-request:\n\n# middleware extracts from headers\nx-atproto-handle: user.handle\nx-atproto-password: app-password\n\n# or from OAuth session\nauthorization: Bearer <token>\n\n\nfrom pdsx MCP server - accepts credentials via HTTP headers for multi-tenant deployment.\n\napp passwords\n\nfor bots and automated tools, app passwords are simpler than full OAuth:\n\nuser creates app password in their PDS settings\nbot uses handle + app password to authenticate\nno redirect flow needed\n\napp passwords have full account access. use OAuth with scopes when you need granular permissions.\n\nsessions, server side\n\nfacts a PDS implementer needs that clients can ignore (from zds, zig PDS):\n\naccess and refresh tokens are separated by JWT typ header (at+jwt / refresh+jwt); refresh rotates a stored token family\nsession 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\napp-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\nservice auth\n\ncom.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).\n\nwhy this matters\n\nOAuth at the protocol level means:\n\nusers authorize apps, not the other way around\napplications can't lock in users by controlling auth\nthe same identity works across all atmospheric applications\ngranular scopes enable minimal-permission applications\n\nfor more operational notes on scope choice, permission sets, progressive scope upgrades, and when transition:generic is or is not appropriate, see ./oauth/README.md.",
  "title": "auth",
  "updatedAt": "2026-07-21T19:27:28.082Z"
}