Auth
Contrail has six auth mechanisms. Which one applies depends on who’s calling, where they’re calling, and what they’re asking for.
| Mechanism | Used by | For |
|---|---|---|
| Anonymous | anyone | public reads |
| Service-auth JWT | third-party apps acting on behalf of a user | authority-side ops + record-host fallback |
Space credential (X-Space-Credential) | callers after exchange via getCredential | record-host reads/writes — primary path |
| In-process server client | your own server code | loaders / actions that skip HTTP entirely |
| Invite token | anonymous bearers | read-only access to a specific space |
| Watch ticket | browsers | realtime subscriptions (watchRecords) |
The two “atproto-y” mechanisms (service-auth JWTs and space credentials) work in tandem on permissioned routes: a caller exchanges a JWT for a credential once via space.getCredential, then presents the credential on every subsequent record-host request until it expires.
Service-auth JWTs
The standard atproto mechanism. When a third-party app wants to call your contrail service as a user, it:
- Asks the user’s PDS to mint a service-auth JWT with
com.atproto.server.getServiceAuth— the token’s claims includeiss(user’s DID),aud(your service DID),lxm(the specific method NSID), and a short expiry. - Sends the request to your service with
Authorization: Bearer <jwt>andAtproto-Proxy: <did>#<service-id>so the PDS knows where to route.
Contrail verifies every request against the public key in the issuer’s DID doc (@atcute/xrpc-server does the heavy lifting). It checks:
- Signature valid
audmatches theserviceDidyou configured (underspaces.authority.serviceDid)lxmcovers the method being called- Token hasn’t expired
On pass, your handler sees a populated serviceAuth = { issuer, audience, lxm, clientId } context and can proceed. On fail, 401 or 403 with a structured reason.
Where service-auth JWTs apply
- Authority routes (
<ns>.space.createSpace,addMember,getCredential, etc.) — JWT-only. Credentials are scoped to record-host operations; you can’t use one to manage spaces. - Record-host routes (
putRecord,listRecords,uploadBlob, etc.) — accept JWTs as a fallback path. The credential path (below) is preferred.
The serviceDid gotcha
Use the plain DID (no #fragment) when configuring contrail:
spaces: { authority: { serviceDid: "did:web:example.com" } } // right
spaces: { authority: { serviceDid: "did:web:example.com#com_example_x" } } // wrong
Many PDS implementations reject aud values containing #fragment in com.atproto.server.getServiceAuth, and contrail does strict string equality on aud. The fragment form belongs only in your DID doc’s service entry, where PDSes use it to resolve the service endpoint URL for Atproto-Proxy routing — that’s separate from JWT audience validation.
Space credentials
Short-lived (default 2h) ES256 JWTs minted by the space authority. Once a caller has one, they present it via X-Space-Credential: <jwt> on every record-host request and skip the per-request JWT mint dance. This matches the rough atproto permissioned-data spec.
Lifecycle
1. Caller mints a service-auth JWT { aud, lxm: "<ns>.space.getCredential" }.
2. POST <ns>.space.getCredential { spaceUri } Authorization: Bearer <jwt>
→ { credential: "<jwt>", expiresAt: <ms> }
3. Caller stores the credential. For ~2 hours, every record-host request:
X-Space-Credential: <credential>
succeeds without going back through the user's PDS.
4. Before expiry, refresh:
POST <ns>.space.refreshCredential { credential }
→ { credential: <fresh>, expiresAt: <new> }
Claims
{
"iss": "<authority DID>",
"sub": "<caller DID>",
"space": "ats://<owner>/<type>/<key>",
"scope": "rw",
"iat": 1746000000,
"exp": 1746014400
}
- Signed with the authority’s ES256 key (kid =
<authorityDid>#atproto_space_authority). - Stateless — verifiable by anyone who can resolve the authority DID’s verification key.
scopeis"rw"or"read". Today onlyrwis issued viagetCredential; the read-only path is a future read-grant invite replacement.
How verification works
When the record host receives a credential, it:
- Decodes the JWT, reads
issandspace. - Asks its binding resolver “who’s authorized to sign for this space?” — primary source is the local enrollment table; fallbacks include PDS records and DID-doc service entries.
- Confirms
issmatches the authorized DID. - Resolves the issuer’s verification key (local in-process, or via DID doc).
- Verifies signature, expiry, scope, space match.
In an in-process deployment, this is one DB lookup + one signature verification. No DID-doc fetches per request. See Spaces for the binding details.
When the credential gets rejected
| Reason | Response |
|---|---|
malformed | 401 — JWT structure invalid |
bad-alg | 401 — header alg ≠ ES256 |
bad-signature | 401 — signature didn’t verify against the resolved key |
expired | 401 — past exp. Refresh, or re-mint. |
wrong-space | 403 — credential’s space ≠ request’s space |
wrong-scope | 403 — read-only credential on a write |
unknown-issuer | 401 — iss doesn’t match the binding for the space (most often: not enrolled here) |
In-process server client
When your own server code wants to call contrail, the service-auth dance is pointless — it’s your code talking to your code. createServerClient skips it:
import { createServerClient } from "@atmo-dev/contrail/server";
const client = createServerClient(async (req) => handle(req, env.DB), userDid);
// Calls bypass fetch entirely; acts as `userDid` for ACL purposes.
const res = await client.get("com.example.event.listRecords", { params: {...} });
Pass did to act as that user; omit it for anonymous calls against public endpoints. This is a trust boundary — anything that actually crosses a network needs a real service-auth JWT or space credential, not this shortcut.
See SvelteKit + Cloudflare for the typical loader pattern.
Invite tokens
First-class auth for spaces. When a space owner creates an invite:
<ns>.invite.create { spaceUri, kind, ttl?, maxUses? }
→ { token: "...plaintext..." } // returned once, never again
The plaintext token is handed to the user out-of-band (link, QR, email). Contrail stores only a SHA-256 hash. Redemption is one atomic UPDATE: used_count++ WHERE hash = ? AND !revoked AND !expired AND !exhausted.
Three invite kinds, depending on what the token does:
join— redeemed via<ns>.invite.redeemwith a service-auth JWT. Adds the caller’s DID to the member list. Members have full read + write inside the space; there’s no per-member permission axis beyond “is a member.”read— bearer-only. The token itself grants read access when passed as?inviteToken=<plaintext>, no DID, no redemption. Good for sharing a read-only link that doesn’t add anyone to the member list.read-join— both. Works anonymously as a read token; can also be redeemed with a JWT to promote the caller to member.
Tokens can be revoked (invite.revoke), expire automatically (ttl), and be exhausted (maxUses).
Watch tickets
Realtime subscriptions (watchRecords) can’t use regular service-auth JWTs for two reasons: the WebSocket upgrade can’t carry arbitrary headers, and an open socket would outlive a 60s JWT TTL. So contrail uses separate short-lived tickets.
Server-side minting comes in two flavours:
<ns>.realtime.ticket— POST{ topic }(e.g."space:ats://...") →{ ticket, topics, expiresAt }. Bare topic-list ticket, used with the generic<ns>.realtime.subscribeendpoint.<collection>.watchRecords?mode=ws&spaceUri=…(or&actor=…) handshake — returns{ snapshot, ticket, wsUrl, sinceTs, ticketTtlMs, querySpec }. The ticket is bound to(did, topics, querySpec)and is the one to use for the per-collectionwatchRecordsstream — both for SSE (?ticket=…) and the subsequent WS upgrade.
Both flavours are signed by realtime.ticketSecret (a 32-byte random, configured once). Clients hand the ticket off via ?ticket=... on connect.
In the @atmo-dev/contrail-sync client:
createWatchStore({
url: "/xrpc/com.example.message.watchRecords?spaceUri=ats://...",
mintTicket: async () => (await fetch("/api/ticket")).then((r) => r.text()),
});
Each reconnect mints a fresh ticket, so expiry doesn’t matter for long-lived subscriptions. See Sync for the full flow.
OAuth permission sets
When a user grants a third-party app permission to act as them in your contrail service, the consent screen is driven by a permission set — a lexicon that bundles every XRPC method you expose. Contrail auto-generates {namespace}.permissionSet for you; contrail-lex publishes it alongside the other lexicons.
A third-party app requests scope by referencing your permission set’s NSID in its OAuth metadata:
"scope": "include:com.example.permissionSet"
The user’s PDS fetches that lexicon (via DNS-backed NSID resolution), shows the user what methods are being requested, and mints scoped service-auth JWTs on confirmation.
DNS requirements
Permission sets live under your namespace (com.example.permissionSet), so PDSes resolve the NSID via DNS. Resolution does not walk up subdomains — every authority in your NSID tree needs its own TXT record at _lexicon.<reversed-domain-path>.
contrail-lex publish prints the exact records you need (also works as a --dry-run). Without them, permission sets can’t be fetched, and users get errors instead of a consent screen.
Anonymous / public
No auth needed for:
listRecords/getRecordwithoutspaceUri— returns public recordsgetProfile,getCursor,getOverviewnotifyOfUpdate(unless you setnotify: "some-bearer-token"in config; then it needsAuthorization: Bearer <that>)
Public requests skip all verification middleware — no JWT parsing, no DID-doc fetch. Fast path.
Trust assumptions
Two trust assumptions in the binding layer cannot be closed at the Contrail layer alone. They are listed here so operators picking a binding strategy or onboarding flow can size them against their own threat model.
DID-doc binding path
The record host can resolve a space’s authority from three sources (see Spaces § Discovery ):
- Local enrollment — the host’s own
record_host_enrollmentstable, written viarecordHost.enroll. Owner-signed; the host has full control over what’s stored. - PDS record — read from the owner’s PDS at the space URI.
- DID-doc service entry — read from
service[id="#atproto_space_authority"]on the owner’s DID doc.
The DID-doc path inherits PLC’s authorization model: any rotation key on the owner’s account can submit an update op rewriting the service entry. There is no per-entry signature or “this entry can only be edited by key X” constraint at the PLC layer.
If the integrity of the space-authority binding needs to exceed what any one of the owner’s rotation keys can already do, configure the host to resolve via local enrollment instead, or wait for an upstream signed-binding mechanism. Tracked upstream as flo-bit/contrail#38 .
Contrail-held PDS app password
Deployments running community.provision or community.adopt necessarily hold an ATProto app password for the user-owned PDS account so Contrail can write on the community’s behalf. ATProto app passwords are unscoped at the PDS layer — they can write any record to the repo, including the tools.atmo.space.declaration record (or its embedded equivalent on a space-type record) that drives binding decisions in createPdsBindingResolver.
This is an intentional consequence of the managed-community model, not a bug. Operators should treat stored app passwords with the same care as rotation keys, and reach for scoped app passwords (when ATProto adds them) or signed binding records to tighten the model further. Tracked upstream as flo-bit/contrail#39 .
How the pieces fit
A typical flow for a third-party app acting as a user in a space:
- App registers OAuth client pointing at your permission set NSID.
- User grants consent — PDS fetches your permission set lexicon via DNS, shows the user the methods, records the scope.
- App calls
com.atproto.server.getServiceAuthon the user’s PDS:{ aud: "did:web:example.com", lxm: "com.example.space.getCredential", exp: <60s> }. PDS signs, returns JWT. - App POSTs
<ns>.space.getCredential { spaceUri }withAuthorization: Bearer <jwt>andAtproto-Proxy: did:web:example.com#com_example_space. Authority verifies the JWT, checks membership, mints a 2h credential. - App caches the credential. Every subsequent
putRecord/listRecords/uploadBlob:
The record host verifies the credential against its enrolled authority’s key — no PDS roundtrip, no DID-doc fetch.POST <ns>.space.putRecord X-Space-Credential: <credential> - Before expiry, app calls
refreshCredentialto get a fresh one.
For your own loaders/actions, the credential dance disappears — createServerClient({did}).post(...) bypasses it entirely. For a browser subscribing to a feed, steps 3–5 are replaced by a ticket mint from your server. Same auth model, different surface.