publish date:

Building a private group on atproto — five things we learned

We run linji.at, a private chat space for our Buddhist study class. It is built on atproto — the protocol Bluesky runs on — using a fork of the zds data server with private groups (called “spaces” in the permissioned data proposal). We have been dogfooding it with real people for months, including several who sign in through their Bluesky accounts. Daniel Holmgren’s diary series is the best introduction to how private groups work under the hood. The discussion thread is where this conversation is happening.

Here is what we learned from a running implementation.

1. Most people’s data servers do not know what a private group is

On atproto, each person’s data lives on their own server (a Personal Data Server, or PDS). For public Bluesky posts this works: your posts sit on your PDS, and apps come to your PDS to read them.

Private groups flip this. When Alice creates a group and invites Bob, the protocol says Bob should write his group posts to Bob’s own PDS, not Alice’s. But most people’s PDSes — including Bluesky’s — simply do not handle private group data. They were not built for it.

Our fix: we host Bob’s group data on our server, under Bob’s name, because his own server cannot. This means when Bob signs in through Bluesky, the posts he writes in our private group are stored by us, not by Bluesky. It works, but there are real tradeoffs: our server can technically forge Bob’s posts (we hold the signing keys), and Bob’s export and delete rights depend on us honoring them. His Bluesky account knows nothing about this data.

The protocol should name this situation explicitly — a group host should be allowed to store a member’s data when their own server cannot — and say what it means for trust.

In technical terms: a permissioned repo need not be stored on the account’s PDS. When the host holds the signing keys, commits are signed with the authority key (signer = authority, author = member DID) — the operator can forge authorship. Our mitigation: the compose client signs the canonical record value with a user-held key (we use the OAuth DPoP key) embedded in the record, so authorship verifies against the DID doc regardless of who stores the commit. The proposal should name the hosted-partition role and its verifiability consequences.

2. Bluesky’s login silently drops what the app asked for

When an app like linji wants Bob to sign in, it uses OAuth. The app tells Bluesky: “I need these permissions for Bob.” Bluesky shows Bob a consent screen, Bob clicks approve, and the app gets a token.

We found a bug where Bluesky accepts the permission request, puts a generic “atproto” label in the token, and silently drops every specific permission the app asked for. No error, no warning — for the user or the app. The app then tries to use those missing permissions and gets a confusing 403. We wrote up the full story in a separate post.

The workaround is to use a more generic permission that Bluesky does grant. But the protocol should not assume that every authorization server will faithfully pass through the permissions an app requests. For the critical step — proving a user owns their account when joining a private group — the protocol needs a fallback that does not depend on custom permissions at all.

In technical terms: bsky.social’s authorization server accepts rpc:com.atproto.server.createAccount?aud=<space-host> at PAR, then silently returns scope='atproto' only. The space: scope machinery depends on the home AS reflecting the scope in the access token; for most users (bsky.social), it never does. The proposal needs a defined fallback: a proof of DID control (e.g. a getServiceAuth JWT with aud = space host DID, verified against the issuer’s DID doc) that bootstraps participation with no space-scope support from the home AS.

3. The first message in a group should not be special

We shipped a chicken-and-egg bug. The server checked, before letting you write: “Is this person already set up as a writer in this group?” But that setup step only happened when you successfully wrote your first message. Result: nobody could write their first message, and multi-member groups were effectively owner-only.

The fix was simple: let the first write create the slot on the spot. And the rule should be “does this person have permission to write here?” — which the app already knows — not “are they on a list somewhere?”

In technical terms: the access gate was keyed on a per-writer permissioned_space_actor_state row that only a successful write materializes. The writer repo must materialize lazily in applySpaceWrites on the first write, with no pre-existing state required (upstream PR: “let OAuth scope gate writes; bootstrap writer repos lazily”). The writer set in listRepos must be write-triggered, never membership-triggered — gating registration on membership causes the registry to drift from actual writers.

4. Invite links need a clear ceremony

The proposal mentions “join requests” being routed to the app that manages the group, but it does not say what that looks like. We built the full thing and it is worth standardizing.

The principle: the invite link is the authorization. You do not need to already be a member to join — having the link is the proof.

In technical terms: invite records carry (space, skey, inviter, expiry). A non-member-safe preview endpoint (inviteInfo) validates in strict order: not-found → wrong-space → wrong-authority → expired, so outsiders get precise reasons, not a blanket 403. join is deliberately outside the space read ACL — the invite record is the authorization credential. On join, we write a at.linji.join event record into the joiner’s repo, which doubles as the “communities I’ve joined” list.

5. Removing bad content from a private group has no defined path

Say someone posts something that needs to go. The group admin can cut off their future access — but what about the post that was already made? In the current design, each member’s data lives on their own PDS. The protocol has no way for a group admin to say: “take this post down from that person’s server.”

We handle it because we host the data ourselves — the admin can remove it. But once group members start hosting their own data on their own servers, this gap opens. Eviction and content removal are two different problems. The proposal should name the gap.

In technical terms: the native partitioned model has no protocol-level path for a space authority to remove a record from a member’s permissioned repo. In the hosted-partition model, we implement this as an authority override (space.putRecord/space.deleteRecord into the member’s repo, signed with the member’s key in the single-host era). The Considerations section covers cutting off read access and application-level write restrictions, but not authority-side content removal — the gap is worth naming explicitly.


The proposal discussion is open, and Daniel’s diaries are the best deep-dive on the design.