Skip to content

clients

nate
Aug 21, 20265 min read1 read

clients

building the client side of AT Protocol OAuth: where the client runs, what it stores, and the handful of decisions every app in this org has had to make the same way. companion to scopes (what to ask for) and dpop (the resource-server side).

where the client runs

browser clientserver client
library@atcute/oauth-browser-client, @atproto/oauth-client-browser@atcute/oauth-node-client, @atproto/oauth-client-node
tokens liveIndexedDB in the user's browseryour store, keyed by DID
client typepublic (no secret possible)public or confidential (private_key_jwt)
session max2 weeks2 weeks public, 180 days confidential
needs a backendno — a static site on wisp worksyes
right whenthe app only ever acts as the user from their own browser (quotree, doodl, at-me, rally)the server must know who is calling, or must act for the user out of band (noti's cron sweeps, pensieve's indexer, plyr.fm)

the dividing question is whether the server needs to trust the identity. a browser client proves identity to the PDS, not to your worker; a worker cannot cheaply verify a DPoP-bound token it did not mint. so if a request to your backend must be gated on "this is really did:plc:…", the OAuth exchange has to happen server-side.

server-side on cloudflare workers

@atcute/oauth-node-client has no node: imports and runs unchanged on workers. a discoverable public client needs only:

new OAuthClient({
  metadata: {
    client_id: `${origin}/oauth-client-metadata.json`,
    redirect_uris: [`${origin}/oauth/callback`],
    scope: "atproto",
  },
  actorResolver,                       // @atcute/identity-resolver
  stores: { sessions: kvStore(...), states: kvStore(...) },
});
  • no keyset makes it a public client (token_endpoint_auth_method: "none"). serve client.metadata at the client_id url; the library fills in dpop_bound_access_tokens, grant/response types, and application_type.
  • derive client_id from the request origin so the same worker serves workers.dev and a custom domain. when the origin is 127.0.0.1/[::1], omit client_id and the library becomes a loopback client — no hosted metadata needed for wrangler dev.
  • localhost is not a loopback address for this purpose (RFC 8252). the redirect URI must be http://127.0.0.1:<port>/…; localhost fails metadata validation. open the dev server by ip.
  • stores are {get, set, delete, clear}; states need ~10 minute TTL, sessions the token lifetime. KV with expirationTtl is enough. D1 works too (noti) and buys compare-and-swap on session writes, which matters only if several isolates may refresh the same session concurrently.
  • a request lock is only needed if you call restore(). an app that only wants identity never refreshes, so the default in-memory lock is fine.

the session cookie is not the DID

after the callback, keep {did, handle, pds} behind an opaque random token (crypto.getRandomValues, 32 bytes) and set that as the cookie — HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=…. DIDs are public; a cookie that carries the DID is a credential anyone can forge. ken shipped that bug and fixed it in 921fefe; pollz had the same one.

SameSite=Lax also means a cross-site POST /oauth/logout arrives without the cookie, so logout needs no separate CSRF token.

what to do with the tokens when you only wanted identity

if the app reads public data (sync.getRepo, the CDN), the access token is dead weight after the callback. still call client.revoke(did) on logout — it revokes at the PDS and deletes the stored session — and fall back to deleting your own row when the PDS is unreachable. logout must not fail because a remote call did.

callback failures are not one thing

the authorization server comes back with ?error=… for a user who clicked cancel (access_denied), the library throws OAuthCallbackError for a state it does not recognise (expired, reused, or forged), and handle resolution fails before any of that when the handle does not exist. noti classifies these into oauth_denied | oauth_state | oauth_invalid_grant | oauth_server | oauth_callback and shows a reason on the login page with a reference id (cf-ray) in the log line. the user-facing minimum is three messages: couldn't find that handle, sign-in was cancelled, that sign-in expired. scrub the ?error= param with history.replaceState once it is shown, or a reload re-shows a stale error.

the library checks state before it checks error, so a cancelled sign-in whose state has since expired reports as expired, not cancelled. that is the right order — an attacker should not learn which states are live.

gate before the redirect

when the app has an allowlist, an account it will reject must never be sent through the authorization flow: the PDS will happily authenticate a user the app then turns away, which reads as a broken app rather than a closed door. where the gate can sit depends on what the login collects:

  • a login that collects a handle can resolve it to a DID before redirecting (authorize() needs the resolution anyway — do it first, check the list, then pass the resolved DID) and refuse on-site with a plain message.
  • a login with no identifier (a bare "sign in" button) can only reject at the callback. noti does this, and pairs it with the other half: every surface re-checks the allowlist, and a session whose access was removed is revoked on its next request rather than left working.

handle input and typeahead

  • strip a leading @, lowercase handles but not DIDs (did:web: paths are case-sensitive).
  • accept a DID in the same box; authorize({target: {type: "account", identifier}}) takes either.
  • the org's typeahead is https://typeahead.waow.tech/xrpc/app.bsky.actor.searchActorsTypeahead (ken, pensieve). debounce ~120 ms, abort the previous request, start at two characters, arrow keys + enter + escape, mousedown with preventDefault so the input does not blur before the click lands, and a placeholder circle when an actor has no avatar so rows stay aligned.
  • after the callback the app has a DID, not a handle. resolve it: the DID document's alsoKnownAs via the identity resolver (pensieve), or app.bsky.actor.getProfile (noti, which also filters handle.invalid). fall back to showing the DID rather than the user's typed text, which may be a different case or a DID.

sources

  • pensieve src/auth.js, src/worker.js — workers public client on KV, 2026-08-21
  • noti src/cloudflare/auth.ts, src/auth-common.ts, src/worker.ts — D1 stores, failure taxonomy, revoke on logout
  • ken backend/src/oauth.zig, TODO.md — opaque session token fix 921fefe
  • quotree src/lib/auth.js, at-me src/view/oauth.js — browser clients with loopback dev metadata
  • @atcute/oauth-node-client 2.0.1 README and dist/oauth-client.js

Did you enjoy this article?

Recommend it — Standard Reader surfaces well-loved writing to more readers across the network.

Across the AtmosphereDiscussions