From atproto-skills
This skill should be used when the user asks to "implement ATProto OAuth", "add Bluesky login", "debug DPoP proofs", "set up pushed authorization requests", "configure PKCE for ATProto", "fix ATProto OAuth errors", "set up client metadata", "debug token exchange", "authenticate with Bluesky", "refresh token keeps invalidating", or mentions DPoP nonce issues, client assertions, base64url padding errors, OAuth callback verification, confidential client setup, or OAuth for Bluesky/AT Protocol apps. Covers the full OAuth flow — for identity resolution and handle lookups, see atproto-domain instead.
How this skill is triggered — by the user, by Claude, or both
Slash command
/atproto-skills:atproto-oauthThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
ATProto uses OAuth 2.0 Authorization Code flow with three mandatory extensions: **PAR** (Pushed Authorization Requests), **DPoP** (Demonstration of Proof-of-Possession), and **PKCE** (S256 only). There is no client registration — clients publish metadata at a URL, and that URL *is* the `client_id`.
ATProto uses OAuth 2.0 Authorization Code flow with three mandatory extensions: PAR (Pushed Authorization Requests), DPoP (Demonstration of Proof-of-Possession), and PKCE (S256 only). There is no client registration — clients publish metadata at a URL, and that URL is the client_id.
Generic OAuth libraries will not work. Mandatory DPoP, PAR, decentralized discovery, and metadata-as-registration require custom implementation.
Identity resolution (handle → DID → PDS → auth server) is covered by the atproto-domain skill. This skill starts after the auth server has been discovered.
[Identity Resolution → Auth Server Discovery] → PKCE → DPoP → PAR → User Auth → Callback → Token Exchange → Resource Requests
↑ See atproto-domain skill
Generate a fresh ES256 (P-256 ECDSA) key pair per session. This binds tokens to the client instance.
DPoP proof JWT structure:
Header: { typ: "dpop+jwt", alg: "ES256", jwk: { kty, crv, x, y } }
Payload: { jti: <uuid>, htm: <HTTP method>, htu: <URL>, iat: <timestamp> }
typ MUST be lowercase dpop+jwt (not DPoP+jwt)htu MUST exclude query string and fragment — scheme + authority + path onlynonce when the server provides one via DPoP-Nonce headerath (base64url SHA-256 of access token) when making resource requestsiss in DPoP JWTs for PDS resource requestsPOST to the PAR endpoint with all auth parameters in the body.
The nonce dance: The first PAR request will typically return HTTP 400 with a DPoP-Nonce header. This is expected — extract the nonce, rebuild the DPoP proof with it, and retry. This is not an error condition.
Required body parameters:
client_id, redirect_uri, response_type ("code"), scope, statecode_challenge, code_challenge_method ("S256")client_assertion_type ("urn:ietf:params:oauth:client-assertion-type:jwt-bearer")client_assertion (signed JWT — confidential clients only)login_hint (optional but recommended — the user's DID)Headers: Content-Type: application/x-www-form-urlencoded, DPoP: <proof>
Critical: Always call .toString() on URLSearchParams when passing as fetch() body — some runtimes (notably Cloudflare Workers) don't serialize correctly otherwise.
Redirect the browser to:
{authorization_endpoint}?request_uri={from_PAR}&client_id={client_id}
The callback returns code, state, and iss parameters. Verify:
state matches the stored value (CSRF protection)iss matches the expected authorization server (prevents mix-up attacks)POST to token endpoint with grant_type=authorization_code, the auth code, redirect_uri, code_verifier, client_id, and client assertion. Include DPoP proof header. Handle nonce retry (same pattern as PAR).
After receiving tokens, verify sub — the DID in the token response MUST match the DID resolved during identity resolution. This prevents authorization-to-wrong-account attacks.
Authorization: DPoP <access_token>
DPoP: <proof with ath claim>
The ath claim is the base64url-encoded SHA-256 hash of the access token. Handle 401 with DPoP-Nonce header by retrying with the nonce.
| Client Type | Access Token | Refresh Token | Overall Session |
|---|---|---|---|
| Public | ≤30 min | ~2 weeks | ~2 weeks |
| Confidential | ≤30 min | ~3 months | ~2 years |
Refresh tokens are single-use — each refresh returns a new refresh token. Implement locking to prevent concurrent refresh requests, which would invalidate the session.
bsky.social.http://localhost — no publicly reachable metadata URL is needed. The auth server generates virtual client metadata automatically.client_id: Use exactly http://localhost — no port, path must be /http (not https)localhost (not 127.0.0.1 or [::1])token_endpoint_auth_method: "none")client_id:
redirect_uri (multiple allowed) — e.g. http://localhost?redirect_uri=http://127.0.0.1:3000/oauth/callbackscope — defaults to atprotohttp://127.0.0.1/ and http://[::1]/localhost but the callback redirects to 127.0.0.1 (or vice versa), the cookie won't be sent. Ensure the redirect URI hostname matches where the app serves.secure: false and sameSite: 'lax' for local HTTP dev.For deployed apps, the client_id must be a publicly reachable HTTPS URL serving the client metadata JSON. The auth server fetches it dynamically — no registration process exists.
signedJwtSchema) that only allows base64url chars (A-Za-z0-9-_) and dots. If the base64url encoder adds = padding, the JWT silently fails Zod validation, the credential union falls through to the none type, and the server returns a misleading "required a client_assertion" error. Always use unpadded base64url encoding. For @oslojs/encoding, use encodeBase64urlNoPadding, not encodeBase64url. This applies to JWT headers, payloads, signatures, PKCE code verifiers, and SHA-256 hashes.jwtBearer | secretPost | none). If the client_assertion is present but malformed (padding, wrong format), Zod silently falls through to the none schema which strips client_assertion entirely. The server then reports "required a client_assertion" when the real issue is JWT format validation failure. Debug by checking the JWT string itself, not the request body.URLSearchParams passed directly as fetch() body may not serialize in all runtimes. Always use .toString().htu includes query params — strip them. htu is scheme + host + path only. The server validates this with a Zod schema that explicitly rejects query strings and fragments.aud wrong — must be the authorization server identifier (the issuer URL, e.g. https://bsky.social), NOT the endpoint URL. The server validates aud against authorizationServerIdentifier from its config. This is different from many OAuth implementations that expect the token endpoint URL.typ casing — must be lowercase dpop+jwt.iss verification on callback — required to prevent mix-up attacks.sub verification after token exchange — required to prevent account confusion.DPoP-Nonce header. This is expected. But a single retry is not enough — the server may return a new nonce on the retry if the first one became stale during processing. Use a retry counter (max 2) instead of a boolean guard. The error message for this is use_dpop_nonce with description "nonce mismatch".login_hint should be the handle, not the DID — Pass the user's handle (e.g. patrickheneise.com) as login_hint in the PAR request so the auth server pre-fills the identifier field. Using the DID shows @did:plc:... which password managers won't recognize.client_id URL, the entire flow fails silently. This does not apply to http://localhost clients, which use virtual metadata.references/client-metadata.md — Full client metadata JSON examples (confidential + public), JWKS endpoint setup, client assertion JWT structure, and ES256 signing notesnpx claudepluginhub zentered/atproto-skills --plugin atproto-skillsThis skill should be used when the user is implementing, auditing, or debugging AT Protocol OAuth in Rust, TypeScript, or Go — covering confidential backend (BFF) clients, public SPA clients, native desktop clients, the authorization flow (PAR / DPoP / PKCE), client metadata publication, permission / scope design, refresh token handling, session storage, and server-side DPoP validation. Triggers on phrases like "OAuth client metadata", "client_id as URL", "private_key_jwt", "dpop_bound_access_tokens", "dpop_signing_alg_values_supported", "PAR", "pushed authorization request", "request_uri", "DPoP proof", "DPoP nonce", "use_dpop_nonce", "invalid_dpop_proof", "ath claim", "htu", "htm", "jkt", "jwk thumbprint", "refresh token race", "token rotation", "invalid_grant", "access_denied", "state store", "session store", "requestLock", "oauth/authorize", "oauth/callback", "oauth/token", "oauth/par", "oauth/revoke", ".well-known/oauth-protected-resource", ".well-known/oauth-authorization-server", "permission-set", "transition:generic", "transition:email", "atproto scope", "account:email", "identity:handle", "repo:*", "blob:*", "rpc:*", "include:", "iss parameter", "RFC 9449", "RFC 9126", "RFC 7636", "RFC 7523", "RFC 9207", "OAuth 2.1", "SameSite=Lax for OAuth", "confidential client", "public client", "BFF pattern", "SPA OAuth", "app password migration". Also triggers on dependency/import names like `atproto-oauth`, `@atproto/oauth-client-node`, `@atproto/oauth-client-browser`, `@atproto/oauth-client`, `@atproto/oauth-types`, `@atproto/jwk-jose`, `indigo/atproto/auth/oauth`, `NodeOAuthClient`, `BrowserOAuthClient`, `ClientApp`, `ClientSession`, `ClientAuthStore`, `OAuthClient`, `OAuthRequestStorage`, `DpopRetry`, `validate_dpop_jwt`, `auth_dpop`, `request_dpop`, `StartAuthFlow`, `ProcessCallback`, `ResumeSession`, `JoseKey`. Use this skill to build a login/callback/refresh/logout flow, publish a `/oauth-client-metadata.json` document, implement a pre-flow `state` store or post-flow session store, design permission sets, set up DPoP on resource requests, or debug token/DPoP failures. Covers identity verification (`sub` DID → DID doc → PDS → AS match), session-cookie hardening (SameSite=Lax, HttpOnly, encryption at rest), refresh-race mitigation, multi-node BFF lock patterns, SSRF hardening on metadata fetches, and cross-language differences between the three reference implementations. Does NOT cover DID / handle resolution in depth (see `atproto-identity-resolution`), CAR / MST / commit signing (see `atproto-repository`), CID parsing (see `atproto-cid`), lexicon-level record validation or XRPC method invocation beyond OAuth (see `atproto-lexicon`), or app-password flows (a separate, legacy mechanism being deprecated in favor of OAuth).
Guides building on AT Protocol (atproto/Bluesky): authoring Lexicons, app views, firehose consumption, DIDs/handles, repositories, records, XRPC endpoints, OAuth.
Guides OAuth2 flow selection by client type and deployment environment: authorization code + PKCE for user-facing apps, client credentials for machine-to-machine, device code for browserless clients.