Gate your app on space membership
"Let them in if they belong to our space" is the most common thing an app does
with a DFOS credential, and it is one request. Ask the user for
read:memberships at sign-in, then call the membership check — not the walk
— with the space you are gating on.
This recipe assumes you already have a credential. Getting one is the Sign in with DFOS flow, and Set up § 7 is the wiring.
1. Ask for read:memberships
Include it in your scope set when you send the user to the consent screen:
https://app.dfos.com/authorize
?challenge=<base64url challenge>
&redirect_uri=https://yourapp.example/callback
&scope=read:memberships
&client_did=did:dfos:…
It is the largest of the three credential scopes by a wide margin — it enumerates private and unlisted spaces, which the anonymous API will not admit exist at all. Users approve it when your app visibly does something with the membership graph. See Scopes and credentials.
2. Configure the gate against a stable id
Read the space's id once, when you set the gate up, and store that:
curl https://api.dfos.com/v1/memberships … | jq '.items[].space | {id, did, domain}'
Key the check on the space_… id, not the subdomain. A subdomain is a
mutable alias its owner can change, and a gate configured against one silently
stops matching the day they do. The entity id and the space's protocol DID are
stable for the life of the space — see
Conventions § Identifiers.
3. Make the check
One request, one answer:
GET /v1/membership/space_6encc4akrze2ah9kntzd9t
Like every credential-gated route it carries both auth headers — the credential and a fresh request proof over this exact path. Sign the path including any query string, byte for byte; the identifier in the path is part of what the proof binds.
Three answers, and each one means something specific:
200— they are a member. The body is one membership object carrying theirrole(owner,admin, ormember), so a tiered gate reads its tier out of the same response rather than making a second call.404— no. It means "not a member" and "no such space" at once: the two are indistinguishable, byte for byte, deliberately and permanently. The 404 is collapsed on purpose explains why. Treat it as a boolean, never as an error worth reporting or retrying — a404here does not mean you got the identifier wrong.403— the grant ended. Revoked, expired, or it never carriedread:membershipsto begin with. Retrying will not help: send the user back through sign-in to re-consent, and delete whatever you cached about them.
const res = await fetch(`https://api.dfos.com/v1/membership/${SPACE_ID}`, { headers });
if (res.status === 404) return false; // not a member — or no such space
if (res.status === 403) throw new GrantEnded();
const { membership } = await res.json();
return membership.role; // 'owner' | 'admin' | 'member'
4. Cache the answer for the session
Check when the session begins, keep the result alongside the session, and re-check when it renews. Membership changes on a human timescale; a check on every page load spends a request proof to re-learn what you learned a second ago.
The one thing that must invalidate that cache immediately is a 403 from any
gated route — that is the grant ending, and it ends your copy of the answer
too.
Gating on a group instead
GET /v1/group-membership/{group} is the same primitive one level in, for
gating on a role inside a space rather than on the space itself. It behaves
identically, collapsed 404 included, and groups have no subdomain — address
them by entity id or protocol DID.
If you need the whole picture rather than one answer, the two walks
(GET /v1/memberships and GET /v1/group-memberships) are paginated listings
of everything the grant covers. Prefer the check when you have a specific
question: it is one request and it stays one request as the user joins more
spaces.
Sign only the coordinates you wrote
A backend that signs whatever path a browser hands it can be asked for a proof over any request at all. Where you parameterize a signer — and a membership check is the case where you genuinely have to — confine the input to the one path segment, validated against a tight charset and percent-encoded, inside a request template written in your own file. See Why sign every request.
See Membership routes for the full field semantics and both walks, Conventions for the auth headers, and the interactive reference to try the endpoints.