A space credential is a key to the whole space, so every access configuration question reduces to two: who gets handed a key, and what software they can use. This post walks through them, the configuration structures that carry them, and what an implementation actually decides at credential-mint time.

This one steps outside the shapes series for a deep dive. The shapes posts kept gesturing at policy and appAccess as an important config value, but didn't go far enough as to how and why. It draws on the permissioned data proposal and some recent changes to it, #99 on DPoP-binding credentials, #100 on alpha-implementation fixes, and #101 on manage in permission sets, plus the experience of building both sides of this in the PDS I've been working on.

Also, I'm going to continue repeating everywhere lately applies double here: permissioned-data is experimental. There is real risk of data loss and real risk of data leakage, people are going to want to treat it as private, safe, secure content, and they shouldn't yet, and none of it is a replacement for end-to-end encryption. Configuration is where a lot of that risk concentrates, which is exactly why it deserves a careful post.

Space Credentials as Access Gates

A space credential is presented to a PDS to read permissioned-data within a space. Space credentials are "whole space", meaning whoever holds one can read every member's records in that space, not only their own. Issuing one does not add to or change the member list and does not authorize writes.

Keep the other framing from the proposal next to it: this is access control, not confidentiality. Hosts and authorized services can read the data, which is what makes search, aggregation, notifications, and moderation possible. A space decides who is handed a key, not who could theoretically see the bytes.

Conditions of Authorization

Authorization to mint a credential depends on two separate, but related required conditions:

  1. 1.

    The user axis asks which person may be issued a credential. This is determined by policy and proven by the delegation token's iss value.

  2. 2.

    The app axis asks which applications may hold one. This is determined by appAccess and proven with client attestation material whose iss and sub reference an OAuth client_id.

This separation is necessary because access patterns require it. A delegation token signed by a user's key is used to answer one specific question "can this user access this space?" while an OAuth client attestation answers a different question, "can this application be used by identities to access this space?"

The "atproto-space-credential+jwt" credential has changed recently, so let's look at what's different now:

{
  "typ": "atproto-space-credential+jwt",
  "alg": "ES256K",
  "kid": "#atproto_space"
}
{
  "iss": "did:plc:cbkjy5n7bk3ax2wplmtjofq2",
  "sub": "at://did:plc:cbkjy5n7bk3ax2wplmtjofq2/space/community.lexicon.bookmarks.collection/self",
  "cnf": { "jkt": "0ZcOCORZNYy-DWpqq30jZyJGHTN0d2HglBV3uiguA4I" },
  "client_id": "https://events.ngerakines.me/oauth-client-metadata.json",
  "iat": 1786550000,
  "exp": 1786557200,
  "jti": "7f3b5a0d9c2e41c8b6a4f1e0d5c39b72"
}

First, proposal PR #99 has introduced cnf.jkt. The whole-space credential has no aud because it gets presented to every repo host in the space. That means that it could also be intercepted as a plain bearer token that makes it a shared secret. Any host handed one to serve its own repo could replay it against the others. This is the exact replay attack that RFC 9449 (Demonstrating Proof of Possession) helps protect against. The value of cnf.jkt is the thumbprint of a key the requesting app proved possession of, via an RFC 9449 DPoP proof on the mint request, and every read afterward has to prove that key again. The credential stops being a secret you show and becomes a key only its holder can use.

Space Configuration

The policy and appAccess space configuration objects are contained in a com.atproto.simplespace.defs#spaceConfig object with these default values:

{
  "$type": "com.atproto.simplespace.defs#spaceConfig",
  "policy": { "$type": "com.atproto.simplespace.defs#memberListPolicy" },
  "appAccess": { "$type": "com.atproto.simplespace.defs#open" }
}

An unconfigured space has member-list policy and open app-access configuration by default. It is effectively a private guest list where the guests can use any client they wish to access the permissioned-data that the space holds.

  • policy is an open union with three variants: #memberListPolicy#publicPolicy, and #managingAppPolicy

  • appAccess is an open union with two variants: #open and #allowList

When the #managingAppPolicy is applied, the managingApp service reference is required inside the policy object:

{
  "policy": {
    "$type": "com.atproto.simplespace.defs#managingAppPolicy",
    "managingApp": "did:web:members.example#svc"
  }
}

Looking At Policies

These three policies answer "who decides which people get keys": a list you maintain, everyone, or a service you nominate.

#memberListPolicy is the default. The authority keeps an explicit roster of DIDs that can be granted credentials that is managed with the addMemberremoveMember, and listMembers XRPC methods. No one outside of that roster can be granted access. When an identity invokes the createSpace XRPC call, they are added as the first member, so a fresh space has a member list of one.

The roster is a committed structure with its own set-hash, and it belongs to the management implementation, simplespace, rather than to the protocol layer every space host implements.

Membership removal is not revocation. Removing a member stops future mints. A credential already issued keeps working until it expires, because there is no revocation path. Previously minted tokens eventually expire, and that TTL is the only binding. The default lifetime is two hours, my atproto-pds implementation clamps operator configuration between one minute and one day, and DPoP binding narrows who can present a leaked credential, not how long it lives.

#publicPolicy authorizes any account that asks. There is no roster, no approval step, and no notification to anyone. Every account on the network can obtain a credential just by asking and read the permissioned-data that any member has written to their space repository.

This isn't exactly "public data", and don't apply the duck test because it won't pass muster imo. Space records aren't published to the firehose, permissioned-data blobs are not served by the public blob endpoint, and clients must go through the full delegation, credential, and DPoP process. Public effectively removes the member list, and makes it easier to "kind of" remove access to public content, but there are some very big non-technical caveats to that. Also important to remember that credentials are whole-space, so public spaces mean every member's records in that space, not just the space owner's. Here be dragons.

#managingAppPolicy is effectively #memberListPolicy but with a delegating service. The per-user decision is made by the named service, on every mint. When the local decision function reaches this policy it returns "not decided here," and the authority makes an authenticated call:

GET <endpoint>/xrpc/com.atproto.simplespace.checkUserAccess
    ?space=at://did:plc:owner/space/app.example.club/main
    &user=did:plc:requester
    &clientId=https://client.example/oauth-client-metadata.json
Authorization: Bearer <service auth JWT>

{ "authorized": true }

The bearer is service auth signed by the space authority's own key, with the managing app as audience and the method as lxm, so the managing app can verify the question came from the space's actual authority and not from anyone curious. The attested client_id rides along when present, which gives the managing app a second, softer place to be app-aware. Only a successful HTTP response with authorized as true means yes. Everything else from unreachable endpoints, network errors, non-200 HTTP responses, unparsable bodies, and including false responses means no. This endpoint must fail closed. When the delegated service endpoint is unreachable, nobody new is admitted and credentials already issued keep working until they expire.

I think this is an elegant and clean solution to several hard questions:

  • How can a single space authority service different spaces with different membership needs?

  • How can a single space authority offload a potentially expensive data structure and access pattern?

With it, even a small #atproto_pds / #atproto_space_host host can defer to an external service that is better suited for complex membership features and their AT Protocol integration cost is relatively low. It just needs to know how to verify the ECDSA signature of the Authorization bearer token associated with the checkUserAccess request. The only implementation detail necessary for the membership moderation and enforcement control is an HTTP GET, which affords a lot of flexibility on how that membership list is managed by the implementation.

Client Controls

The two app-access values answer "what applications can be used by identities to access spaces?"

#open means any application can be used. An attestation is still optional under #open, and if one is presented it's verified anyway for good measure. If provided, the resulting client_id is imprinted in the credential and used for read attribution, otherwise the client_id is omitted.

#allowList means only a pre-determined list of clients as determined by their client_id will be allowed to have credentials. When set, client attestation is required and the client ID verified against the allow list. This also means that a client allow-listed space excludes every app that doesn't identify itself. An empty allow list at configuration time would lock every application out of the space.

Minting

There is an order of operations to processing a getSpaceCredential request.

The DPoP proof is verified first as a fail-fast gate for missing or invalid DPoP nonce values and invalid signatures. This is a non-destructive check that also prevents single-use delegation tokens from being burned. Verification of delegation tokens is against the member's signing key, either locally for local accounts or extracted from DID documents for external identities. The jti is also consumed and cached for re-use checks.

The attestation, if present, is verified next. This involves fetching the client metadata the attestation references, which is important to note because that information may not be cached and needs to be retrieved.

At this point, space configuration is loaded and referenced to evaluate the policy and app-access configuration. If all checks pass, the credential is minted with the proof key's thumbprint in the cnf and the attested client_id if there is one.

The failure scenarios walk the line of being vague and useful. AppNotAuthorized says the app can't read this space. UserNotAuthorized says the identity can't. NotAuthorized says the space couldn't decide, which is a retryable condition rather than a verdict, and it's what a managing-app outage or a misconfigured policy produces.

The Layout

These are some different configurations and how they play out:

The default, member-list plus open. Rudy is a member of my tech-books book-club space. Any client he authorizes with a valid space: grant can mint delegations and obtain a valid space credential. This is the private-guest-list, any-software posture, and most personal and small-group spaces should probably live here.

member-list plus allowList. The Pokémon club from the series. Mattie is a member, and through the club's own app view she reads everything. If she uses a custom or independent client that is not on the app list, she gets AppNotAuthorized. The same error is also returned if someone tries to use a client that doesn't identify.

public plus allowList. An open community may have public membership but still enforce strict moderation by requiring members use specific clients to access community content. The content is more broadly available, but participation requires conformance that clients can strongly enforce. It also provides a certain safety to community members knowing that elements of composable moderation and data validation are in-place.

managing-app plus open. This could be a paid newsletter's space, where the managing app fronts the subscription or billing system. A subscriber's mint succeeds because checkUserAccess consulted a subscription table. A lapsed subscriber gets UserNotAuthorized on their next mint and coasts on their existing credential for at most the TTL.

The Dragons I Warned You About

Permissioned data spaces supports a wide variety of use cases, but there are some sharp edges that you need to be aware of.

  • There is no revocation. Removing a member, changing the policy, or emptying an allow list affects only future mints, and outstanding credentials run to expiry.

  • Space credential issuance must not be confused with membership.

  • Reads are whole-space. There is no per-record, per-collection, or per-identity read grant. An entire space is the smallest (and largest) unit of read access.

  • Membership can be unverifiable. A repo host serving a space whose authority lives elsewhere has no roster to consult, so it defers to the authority where it can, demands positive evidence where it can't, and refuses otherwise.

  • Refusals are shaped to prevent probing. Whether a given space holds a given account's records is itself a confidential fact, so membership refusals report SpaceNotFound rather than something more informative.

  • Attribution is intentionally imperfect. A repo host only ever sees credentials presented, so an attesting app shows up in the access log as itself, while a non-attesting one appears as a fresh anonymous reader every time its credential rotates.

Where This Leaves an Owner

The whole configuration surface is two dials and a nominated service, and I think that smallness is right, because every playout above was legible from the config alone. Legible, however, is not the same as safe. Between the revocation gap, the whole-space key, and drafts that are still moving, my advice hasn't changed: treat every permissioned space you run today as an experiment, assume both loss and leakage are live possibilities, and don't put anything behind these dials that would hurt you on either failure.

Check out atproto-pds, my Rust PDS implementation that supports permissioned-data, and Bulleted, an outliner that can use permissioned-data-enabled PDSes.