clients
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 client | server client | |
|---|---|---|
| library | @atcute/oauth-browser-client, @atproto/oauth-client-browser | @atcute/oauth-node-client, @atproto/oauth-client-node |
| tokens live | IndexedDB in the user's browser | your store, keyed by DID |
| client type | public (no secret possible) | public or confidential (private_key_jwt) |
| session max | 2 weeks | 2 weeks public, 180 days confidential |
| needs a backend | no — a static site on wisp works | yes |
| right when | the 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"). serveclient.metadataat theclient_idurl; the library fills indpop_bound_access_tokens, grant/response types, andapplication_type. - derive
client_idfrom the request origin so the same worker serves workers.dev and a custom domain. when the origin is127.0.0.1/[::1], omitclient_idand the library becomes a loopback client — no hosted metadata needed forwrangler dev. localhostis not a loopback address for this purpose (RFC 8252). the redirect URI must behttp://127.0.0.1:<port>/…;localhostfails metadata validation. open the dev server by ip.- stores are
{get, set, delete, clear}; states need ~10 minute TTL, sessions the token lifetime. KV withexpirationTtlis 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,mousedownwithpreventDefaultso 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
alsoKnownAsvia the identity resolver (pensieve), orapp.bsky.actor.getProfile(noti, which also filtershandle.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 fix921fefe - quotree
src/lib/auth.js, at-mesrc/view/oauth.js— browser clients with loopback dev metadata @atcute/oauth-node-client2.0.1 README anddist/oauth-client.js
Did you enjoy this article?
Recommend it — Standard Reader surfaces well-loved writing to more readers across the network.