Over the last few weeks I’ve been building against the 0016 Permissioned Data proposal and its reference implementation. I like it, big fan. The spaces model, the two-hop trust structure between an account’s PDS and a space authority, and the dynamic space scope all fit together. Working on it both from the implementor and integrator’s point of view, I’ve put some thought into a narrow proposal for one specific thing: how the delegation token and the space credential get minted, and what claims they carry.
I shared a spec-shaped version of this with Bryan and Daniel last week. Their feedback, which I think is fair, was that a change like this should get chewed on in public with input from other implementers before anything becomes a PR against the proposal, and that the conversation should start with motivation and tradeoffs rather than wire details. So that’s what this post is. The full proposal and an end-to-end walk-through are linked at the bottom for anyone who wants the particulars.
What 0016 does today
An application holding an OAuth session with a valid space: scope calls com.atproto.space.getDelegationToken on the user’s PDS and gets back a short-lived, single-use JWT signed with the account’s #atproto key. It presents that token to com.atproto.space.getSpaceCredential on the space host, and gets back a credential signed by the space authority to use with any PDS for that space. These two XRPC methods take a provided security token and exchange it for another.
Proposal
Mint both tokens at token endpoints using the RFC 8693 token-exchange grant instead of at XRPC methods.
The delegation token comes from the token-exchange grant at the PDS token endpoint, with the session’s access token as the subject token and the space named in scope. The space credential comes from the token-exchange grant at a token endpoint the space authority operates, with the delegation token as the subject token and the client attestation sent as an ordinary client_assertion, which is what it already is structurally. The space moves out of sub and into scope, using the space: resource 0016 already defines. The credential gains a cnf.jkt binding to the client’s session DPoP key. Each minted class gets a token type identifier (https://atproto.com/oauth/token-type/space-delegation and .../space-credential) that the client requests at the exchange and the server echoes in issued_token_type, alongside the typ header values 0016 already defines.
Here is the credential as 0016 specifies it:
{
"iss": "did:web:clubs.example",
"sub": "at://did:web:clubs.example/space/com.example.club/3lz5j2kq",
"iat": 1788012300,
"exp": 1788019500,
"jti": "01M16XF3ZT4G8W2NQ5RDJHV9AC"
}
And under this proposal:
{
"iss": "did:web:clubs.example",
"iat": 1788012300,
"exp": 1788019500,
"jti": "01M16XF3ZT4G8W2NQ5RDJHV9AC",
"scope": "space:com.example.club?authority=did:web:clubs.example&skey=3lz5j2kq"
}
Everything else stays where it is. The record and event vocabulary, simplespace, membership decisions, token lifetimes, typ values, two-hop acquisition, notifications, etc. are all untouched. The data flow and the network architecture don’t change.
Rationale
The first reason is that both mint operations already are token exchanges. getDelegationToken and getSpaceCredential each take a security token, validate it, and issue a different one, which is the exact shape RFC 8693 standardizes. When the overlap is that close, doing it differently needs a justification, and I haven’t found one that holds up once you get into the details.
The second reason is reuse, though not the kind Bryan shared his skepticism of. Generic OAuth SDKs have been a mixed bag with atproto, and client metadata URLs, mandatory PAR, and DPoP mean client developers do a lot of work regardless. The reuse I care about is server side and internal. A PDS token endpoint already validates private_key_jwt assertions, enforces DPoP with server nonces, evaluates scopes, and returns structured errors. Adding an exchange grant is another branch in a handler that exists. Two new XRPC methods mean two new handlers that each re-implement client authentication, proof of possession, replay protection, rate limiting, error handling, etc.
The third reason is the boundary between minting credentials and using them. Issuing tokens is one problem and one threat surface: authenticate the requester, prove possession, prevent replay, make the endpoint discoverable. A token endpoint solves that once, in one place that’s easy to rate limit, log, and apply policy to. When an XRPC method mints tokens, it becomes its own small authentication endpoint and has to solve all of that again. Service auth has this shape today: the OAuth session’s scopes decide what can be minted, and then getServiceAuth mints it in a different format through a different path. The service auth discussion already calls that indirect, and 0016 repeats the pattern twice more.
The fourth reason is vocabulary. Everything in the three custom claim sets can be said with registered claims, so any JWT library can read these tokens and the claim validation code atproto endpoints already have applies to them. RFC 7519 defines sub as a principal, and a space is a resource, so it belongs in scope. That also means one addressing scheme runs from the session grant through both exchanges into the credential. The credential deliberately carries neither sub nor aud, because it states a client’s access to a space and gets presented to many repo hosts. RFC 7519 only applies the audience check when the claim is present.
The fifth reason is that cnf.jkt gets us the thing “Boring Auth” was after, a credential tied to the OAuth client that asked for it. The DPoP proof on the exchange supplies the key, and every presentation pairs the credential with a proof over that key. Replay handling becomes clean by class: the delegation token and the attestation burn on first use, while the 2+ hour credential can be reused freely because holding it spends nothing without the key.
Feedback and Concerns
Bryan and Daniel gave some really good feedback, and I’m sharing my understanding of that feedback and the responses I gave.
Daniel’s first shared concern is that the spaces spec would become reliant on RFC 8693, a heavier spec, and get further entwined with OAuth. My response is that the RFC stack is already load bearing in 0016. The attestation is a private_key_jwt (RFC 7523), every token is an RFC 7519 JWT with typ per RFC 8725, and DPoP is RFC 9449, whose title begins with “OAuth 2.0”. The slice of 8693 used here is small, with no actor tokens, no multi-resource requests, and nothing about refresh. Everything 8693 says about minting still has to be written down somewhere, and without the RFC the spaces spec gets bigger by re-specifying known things under new names.
Daniel also wants to push the OAuth boundary out as far as possible because OAuth has historically been hard for a lot of developers. I feel that and I’ve personally put a lot of work into helping tackle that. Where we differ is the minting step. To me, 0016 as written pushes OAuth mechanics (client assertions, DPoP, scope checks) into XRPC handlers, and moving the two mints to token endpoints contains them in the one route that is already OAuth. The hard parts of OAuth are the authorization code flow: redirects, PAR, PKCE, consent, refresh. None of that is involved here. A space authority handles one POST endpoint using concepts its implementer already needs for everything else in 0016.
Bryan doesn’t want to commit to “OAuth for everything, forever” at the sync layer, with scoped API tokens still to come and something like UCANs possible further out. I think the proposal is compatible with that. The only OAuth involved is the token endpoint, tokens in and tokens out. There is no authorization endpoint, PAR, refresh token, or consent screen at the authority. If (and when) UCANs arrive, a typed exchange with a subject_token_type naming a UCAN is a natural place to plug them in.
Bryan wants client_id and the client attestation to remain the space authority’s choice rather than a hard requirement. Agreed, and the proposal keeps it that way. An ungated space takes no assertion and issues a credential without client_id, and the cnf binding alone names the holder.
Request For Comments
This proposal addresses a core feature of the 0016 Permissioned Data proposal and needs your feedback and support. Please chime:
- about the boundary between sources (XRPC) and authz/authn
- about the design and shape of the two XRPC methods
- about the use of scopes to reference resources instead of a JWT subject claim
- if you care about registered claims and generic JWT tooling
- or anything adjacent or inbetween
Proposal Reference
- Proposal Summary as permissioned-data-token-exchange-0-summary
- Proposal in full as permissioned-data-token-exchange-1-proposal
- Proposal Walk-Through as permissioned-data-token-exchange-2-walk-through
Links
- Proposal 0016, Permissioned Data: proposals/0016-permissioned-data/README.md at main · bluesky-social/proposals · GitHub
- The 0016 discussion thread here: Permissioned data proposal discussion
- Permissioned Data Diary 6, Boring Auth: Permissioned Data Diary 6: Boring Auth - Daniel's Leaflets
- Proposal 0014, Service Auth Audience Revised: proposals/0014-service-auth-revised/README.md at main · bluesky-social/proposals · GitHub
- Proposal 0010, Client Assertion Backend: proposals/0010-client-assertion-backend/README.md at main · bluesky-social/proposals · GitHub
- RFC 8693, OAuth 2.0 Token Exchange: RFC 8693: OAuth 2.0 Token Exchange
- RFC 9449, OAuth 2.0 Demonstrating Proof of Possession: RFC 9449: OAuth 2.0 Demonstrating Proof of Possession (DPoP)