publish date:

bsky.social silently drops `rpc:` scopes from access tokens — then 403s `getServiceAuth`

Status: resolved (workaround deployed); root cause in bsky’s AS, closed source.

TL;DR

When an OAuth client requests the spec-correct scope for getServiceAuth (rpc:com.atproto.server.createAccount?aud=did:web:zds.linji.at, declared in the client metadata, accepted at PAR), bsky.social’s authorization server grants an access token whose scope claim contains only atproto. The rpc scope is silently dropped — no PAR error, no consent notice. The PDS’s getServiceAuth handler then refuses to mint the service JWT:

403 {"error":"ScopeMissingError","message":"Missing required scope
     \"rpc:com.atproto.server.createAccount?aud=did:web:zds.linji.at\""}

The AS accepts the scope it will never grant, and the resource server demands the scope it was never given. A client following the OAuth spec end to end cannot complete this flow against bsky.social.

Context

We run an invite-only community app (linji.at) on our own PDS (did:web:zds.linji.at). External users sign in with any atproto handle, including bsky.social ones. To give a bsky-hosted user working records on our PDS we call com.atproto.server.getServiceAuth on their home PDS (address aud = our PDS, lxm = com.atproto.server.createAccount), then use the service JWT to adopt the DID. That is the standard service-auth pattern.

Exact reproduction (2026-08-02)

  1. Client metadata (https://app0.linji.at/client-metadata.json) declares the maximum scope set, including the rpc permission:

    scope: atproto repo:* space:… rpc:com.atproto.server.createAccount?aud=did:web:zds.linji.at
    
  2. PAR against https://bsky.social/oauth/par with scope=atproto rpc:com.atproto.server.createAccount?aud=did:web:zds.linji.at and login_hint=1a-insec.net200 {"request_uri":"urn:…"}. No error, no scope warning.

  3. User consents at bsky.social’s authorize page → authorization code → token exchange.

  4. The minted access token decodes to:

    {
      "jti": "tok-5eaa2950388fd8d926753021f4eea992",
      "sub": "did:plc:73xkqje76lj5tostbsqa74b7",
      "iat": 1785663499,
      "exp": 1785667099,
      "aud": "did:web:yellowfoot.us-west.host.bsky.network",
      "cnf": { "jkt": "rzAx5T-2KMzuvDPqx5Sg5WKMCXW7kW9tUvtfktHuXM4" },
      "scope": "atproto",
      "client_id": "https://app0.linji.at/client-metadata.json",
      "iss": "https://bsky.social"
    }
    

    scope is "atproto" — the requested rpc scope is absent.

  5. GET /xrpc/com.atproto.server.getServiceAuth?aud=did:web:zds.linji.at&lxm=com.atproto.server.createAccount with that token (DPoP) →

    403 {"error":"ScopeMissingError","message":"Missing required scope
         \"rpc:com.atproto.server.createAccount?aud=did:web:zds.linji.at\""}
    

Reproduced twice (04:41Z and 09:38Z). The second run requested the exact scope named in the first run’s error; the token still came back with atproto only.

The contradiction

The failure is only visible at the last hop, as a 403 whose required scope the client already requested. Nothing in the OAuth flow tells the client the grant was truncated.

Where we landed

Root cause: bsky’s hosted AS (entryway, closed source) does not grant aud-specific rpc scopes to third-party clients. It accepts them at PAR, but the minted token carries only atproto — no error, no consent-time signal. The PDS then demands the scope it never issued (ScopeMissingError, thrown by permissions.assertRpc in auth-verifier.ts). RFC 6749 §3.3 means the token response must carry the granted scope, so a client checking it could have detected the shortfall — we (like most clients) didn’t.

Fix (deployed, verified): request the registry migration set instead — atproto transition:generic. bsky grants it, and the PDS’s ScopePermissionsTransition.allowsRpc treats it as covering rpc permission for any non-chat lxm, which is all the bootstrap needs (com.atproto.server.createAccount; OAuth credentials skip the PRIVILEGED_METHODS check since their type is oauth, not access).

Fault verdict: primarily bsky’s AS (silent partial grant + its own PDS requiring what the AS won’t issue); shared with the client for not checking the granted scope, and with the docs for never steering third parties to transition:generic.

Takeaway for client devs: check the granted scope on every token response, and if you need getServiceAuth against bsky.social, request transition:generic, not an aud-scoped rpc: permission.

Appendix: environment