Certified Group Service (CGS)
In standard AT Protocol, each repository is controlled by a single identity (DID) — there's no built-in way for multiple users to collaboratively manage the same repo with different permission levels.
The Certified Group Service (CGS) fills that gap. It's an AT Protocol service that sits between clients and a group's backing PDS, enforcing role-based access control, tracking record authorship, and keeping a full audit log. From the client's perspective, a group looks like any other AT Protocol repository — it just happens to be co-governed.
Certified operates a hosted CGS instance, but CGS is also designed to be self-hostable per operator — anyone can run their own instance against any AT Protocol PDS (including, but not limited to, the Certified-operated PDSs).
A single CGS instance can host groups across multiple PDSs. Each group records its own backing PDS, resolved from the group's DID document and stored per-group. When a group is imported (app.certified.group.import), CGS uses whatever PDS already hosts that account, so imported groups can live on different PDSs within one instance. The GROUP_PDS_URL environment variable is narrower than it sounds: it's only the PDS on which app.certified.group.register creates brand-new group accounts. Letting register target a specific PDS per call is a plausible future extension but is not implemented today.
Certified operates production, staging, and test CGS instances. See Certified Group Services for the current hostnames, version endpoints, and guidance on which to use in which scenario.
System overview
Any AT Protocol client
│
│ atproto-proxy: did:plc:GROUP#certified_group
▼
User's PDS
│
│ Authorization: Bearer <service-auth-jwt>
│ (signed with user's key, iss=user, aud=group)
▼
┌──────────────────────────────────┐
│ Certified Group Service │
│ │
│ 1. AuthVerifier (JWT → DID) │
│ 2. RbacChecker (DID → role) │
│ 3. PDS proxy (forward) │
│ 4. AuditLogger (record all) │
└──────────────────────────────────┘
│
▼
Group's backing PDS
Clients never call CGS directly. Instead they send a normal XRPC request to their own PDS with an atproto-proxy header pointing at the group DID and service fragment (did:plc:GROUP#certified_group). The user's PDS resolves the group's DID document, finds the #certified_group service endpoint, mints a service auth JWT signed with the user's key, and forwards the request to CGS. This is the same proxying mechanism used by Ozone labeling services.
Integrating from an app
Apps don't call CGS directly. Your backend uses an authenticated AtpAgent for the user and sends XRPC requests to the user's PDS with an atproto-proxy header set to did:plc:<groupDid>#certified_group. The PDS handles service auth and forwards the request to CGS on your behalf. See the upstream integration guide for a worked example.
One important gotcha: CGS uses custom NSIDs for record operations — app.certified.group.repo.createRecord, putRecord, deleteRecord, uploadBlob — instead of the standard com.atproto.repo.*. This is deliberate: if your app called com.atproto.repo.createRecord, the user's PDS would handle it itself and write to the user's own repo, not proxy it to CGS. The custom NSIDs are unrecognized by the PDS, so it looks them up in the group's DID document and routes them to CGS. Use the custom NSIDs in your proxied calls.
Authentication
Every request to CGS arrives with Authorization: Bearer <JWT>. The AuthVerifier runs the following checks:
- Signature — verified against the issuer's DID document via
@atproto/xrpc-server'sverifyJwt(). - Audience — for group-scoped operations the JWT's
audmust match a group DID registered with this CGS instance. Service-level (cross-group) operations such asapp.certified.groups.membership.listinstead requireaudto be the service's own DID. - Lexicon method — the JWT's
lxmmust match the requested XRPC method (from an allowlist of record and group-management operations). - Token lifetime —
exp - iatmust not exceed the nonce TTL (120 seconds), so that tokens can't outlive the replay-prevention window. - Nonce (replay prevention) — the JWT's
jtiis checked against a short-lived nonce cache. If it's been seen before, the request is rejected.
For a group-scoped request, the handler then receives { callerDid, groupDid } and proceeds to authorization. Cross-group requests receive just { callerDid }.
Authorization (RBAC)
Roles are strictly hierarchical and compared numerically. A higher level grants every permission of the lower levels.
member (0) < admin (1) < owner (2)
Permission matrix
| Operation | Minimum role |
|---|---|
| Create records, upload blobs, list members | member |
| Edit / delete records you authored | member |
| Edit / delete any member's record | admin |
| Edit the group's profile | admin |
| Add / remove members | admin |
| Query the audit log | admin |
| Change a member's role | owner |
Special rules
- Cannot modify equal or higher roles. An admin cannot remove another admin; only an owner can.
member.addcannot assignowner. The owner role is immutable — it is fixed at registration and cannot be assigned or changed viarole.set(ownership transfer is a separate operation, not yet implemented).- Owners cannot be removed or demoted.
role.setandmember.removeboth reject the owner role — this takes precedence over self-removal, so even an owner cannot remove themselves. - Self-removal succeeds for non-owners. Any member or admin can remove themselves regardless of the equal-or-higher-role rule; only the owner is excepted (see above).
- Authorship is tracked per record. CGS maintains a
group_record_authorstable sodeleteOwnRecord(member) can be distinguished fromdeleteAnyRecord(admin).
PDS proxying and credentials
Once a request is authorized, CGS forwards it to the group's backing PDS using stored credentials:
- Credential storage. The group's PDS app password (and, where applicable, the recovery keypair used for PLC operations) is stored encrypted with AES-256-GCM, using a 32-byte master key from the service's
ENCRYPTION_KEYenvironment variable. - Agent pool. An authenticated
AtpAgentper group is cached in memory; stale sessions are refreshed automatically onAuthenticationRequired/ExpiredTokenerrors. - Blob uploads.
uploadBlobrequests are streamed to the PDS with an enforcedMAX_BLOB_SIZElimit (checked both upfront viaContent-Lengthand incrementally during the stream).
Audit logging
Every meaningful action — permitted or denied — is written to the per-group group_audit_log table. Each entry captures:
- Who — the caller's DID (
actor_did) - What — the operation name (e.g.
createRecord,member.add) - Where — collection and rkey, for record-level operations
- Result —
permittedordenied(plus a reason for denials) - Tracing — the JWT's
jti, for correlation with auth logs - When — ISO timestamp
Admins can query the audit log via app.certified.group.audit.query.
Cross-group membership
Most CGS operations are scoped to a single group. One endpoint is service-level rather than group-level: app.certified.groups.membership.list lets the authenticated user list every group they belong to on this group service, along with their role and join date in each. Because it spans groups, its service auth JWT is addressed to the service's own DID (aud = service DID) rather than to any one group.
CGS answers this query from a member_index table in the global database — a reverse index from member DID to the groups they're in — since the per-group databases have no way to look up membership across groups.
Group lifecycle
A group enters the service in one of two ways: register (create a brand-new account) or import (adopt an account that already exists).
Register — app.certified.group.register requires a service auth JWT proving the caller controls the prospective owner DID. During registration, CGS:
- Creates a new PDS account on the instance's configured backing PDS (
GROUP_PDS_URL) and receives a new group DID. - Generates a recovery keypair and registers a
#certified_groupservice entry in the group's DID document via a PLC operation. - Stores the encrypted app password and recovery key in its own database.
- Seeds the caller as the group's first owner.
Import — app.certified.group.import adopts a pre-existing PDS account instead of creating one. The caller supplies an app password, and the JWT must be signed by the account being imported (proving control of the group DID beyond merely holding its app password). CGS resolves the account's PDS from its own DID document — which may be any PDS, not just GROUP_PDS_URL — authenticates there with the app password, stores the credentials, and seeds the named owner. It does not generate a recovery key (an app password cannot grant key control) or modify the account's DID document.
Both are service-level operations (aud = the service's own DID), since the group does not yet exist in the service when they run. From then on, the group's DID is co-governed through CGS: owners promote admins, admins manage members, and members interact with the repository subject to the permission matrix above.
Storage
CGS uses SQLite for all persistence:
- A global database holds the group registry (
groupstable), the nonce cache, and amember_indextable (the reverse member-to-group index that backs cross-group membership listing). - Each group gets its own per-group database, named by the SHA-256 hash of the group DID. This isolates group data and keeps audit logs per-group.
- All databases use WAL mode for concurrent read performance.
Future directions
The current RBAC model — three fixed roles (member, admin, owner) with a hard-coded permission matrix — is intentionally simple. It covers the common case of a small group co-managing a repository, but it is a starting point rather than an endpoint. Directions being explored as groups' governance needs mature:
- Customizable roles. Let each group define its own roles and permissions instead of relying on a fixed three-tier hierarchy.
- Finer-grained permissions. Scope permissions per collection or record type — for example, a role that can only create records in one lexicon, or only edit the group profile.
- Group-level governance. Move beyond unilateral admin/owner actions toward proposals, voting, or quorum-based decisions for sensitive operations.
- Time-bound and delegated roles. Temporary elevations — e.g. an admin grants another member
adminfor 24 hours, after which the role automatically reverts. - Credential-based membership. Derive membership and roles from external signals (verifiable credentials, badges, tokens) rather than only manual
member.addcalls. - Per-call PDS targeting for
register. A single instance can already host groups across multiple PDSs viaimport(which adopts an account on whatever PDS already hosts it). What's missing is lettingregistercreate new group accounts on a caller-chosen PDS rather than only the instance's configuredGROUP_PDS_URL.
None of the above are committed features; they're possibilities being shaped by user and developer needs and feedback.
Further reading
- Certified Group Services — hosted CGS instances, environments, and version endpoints
- CGS repository
- Architecture doc — full data model, startup sequence, and implementation details
- Integration guide
- API reference
- Deployment guide — for running your own CGS instance