publish date:
Status: resolved (workaround deployed); root cause in bsky’s AS, closed source.
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.
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.
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
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.net → 200 {"request_uri":"urn:…"}.
No error, no scope warning.
User consents at bsky.social’s authorize page → authorization code → token exchange.
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.
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 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.
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.
private_key_jwt, PAR + PKCE +
DPoP), client metadata at https://app0.linji.at/client-metadata.json.
1a-insec.net = did:plc:73xkqje76lj5tostbsqa74b7
(custom-domain handle, DNS-TXT-only resolution).
https://yellowfoot.us-west.host.bsky.network;
AS issuer: https://bsky.social.