Atproto Oauth Login

by @zzstoatzz.io · created Sep 20, 2026

Install this skill

npx @skillz.supply/cli install did:plc:xbtmt2zjwlrfegqvch7fboei/skills.atproto-oauth-login

Writes SKILL.md into your agent's skills folder under this skill's name. No CLI? Download the file, or view it.

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.