name: "atproto-oauth-login"
description: "Add AT Protocol OAuth sign-in to an app: decide browser-side or server-side, then build the login page, typeahead, session, and allowlist the way the org's apps already do."
createdAt: "2026-09-20T00:14:06.047938+00:00"
repoUrl: "https://skillz.supply/skills/did:plc:xbtmt2zjwlrfegqvch7fboei/skills.atproto-oauth-login/SKILL.md"
# atproto OAuth login Load this before adding sign-in to any app that authenticates atproto accounts. The depth lives in the user's notes collection under `protocols/atproto/oauth/` (`clients.md`, `login-page.md`, `scopes.md`, `dpop.md`); read the relevant note before writing code rather than reconstructing the flow from memory. ## 1. decide where the client runs Ask one question: **does the server need to trust who is calling?** - **No** (the app only acts as the user from their own browser: reads public data, writes to the user's own repo): a **browser client**. Static site, no backend, tokens in IndexedDB. Examples: quotree, at-me, doodl, rally. - **Yes** (a route is gated on a DID, an allowlist, or the server acts for the user out of band): a **server client**. The exchange happens server-side and the browser gets an opaque session cookie. Examples: noti, pensieve, ken, pollz, plyr.fm. An admin page is always this case, even on an otherwise static app. A browser-issued DPoP token proves identity to the PDS, not to your backend, so "verify the user's token on the server" is not an option. ## 2. pick the reference implementation for the stack | stack | reference | notes | |---|---|---| | browser, vanilla or svelte | quotree `src/lib/auth.js`, at-me `src/view/oauth.js` | `@atcute/oauth-browser-client`; loopback metadata for dev | | cloudflare workers, TS | pensieve `src/auth.js` (KV), noti `src/cloudflare/auth.ts` (D1, failure taxonomy) | `@atcute/oauth-node-client` runs unchanged on workers | | zig | ken `backend/src/oauth.zig` (from pollz `backend/src/http.zig`) | confidential client on `zat.oauth`; in-memory sessions | | python | plyr.fm `backend/src/backend/api/auth.py` | server client behind a SvelteKit front end | Copy the shape from the reference for the same stack; do not port across stacks when a same-stack reference exists. ## 3. build the page One heading, one handle input with the org typeahead, one primary button. The spec (normalization, debounce, keyboard, avatar placeholder, `X-Client` header, immediate sign-in on select, error rendering, `return_to`) is in `login-page.md`; the typeahead endpoint is `https://typeahead.waow.tech/xrpc/app.bsky.actor.searchActorsTypeahead`. Use the app's existing design tokens; the page is a centered card, not a new theme. ## 4. non-negotiables - Client metadata is served at the `client_id` URL and derived from the request origin; local dev uses `127.0.0.1`, never `localhost`. - The session cookie is an opaque random token, never the DID. `HttpOnly; Secure; SameSite=Lax; Path=/`. - Ask for the narrowest scope (`scopes.md`); `atproto` alone is sign-in only. - Allowlisted apps gate **before** the redirect when the login collects a handle, and re-check the list on every gated request. - Logout revokes at the PDS and still succeeds when the PDS is unreachable. - Callback errors are classified (denied, expired state, invalid grant, server, other) and shown as one plain sentence on the login page. ## 5. record what was new If the app hit something the notes do not cover, add it to the relevant note under `protocols/atproto/oauth/` with a dated source, so the next app does not relitigate it.