Skip to main content

Gate your app on space membership

"Let them in if they belong to our space" 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.

It assumes you already have a credential — that is the Sign in with DFOS flow, wired in Set up § 7.

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 a broad scope — it shows private and unlisted spaces the anonymous API will not admit exist. 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, and a gate configured against one stops matching the day its owner changes it. The entity id and protocol DID are stable for the life of the space — see Conventions § Identifiers.

3. Make the check​

GET /v1/membership/space_vnzfk7hth9vadc3daahd48

Like every credential-gated route it carries both auth headers — the credential and a fresh request proof over this exact path, signed including any query string, byte for byte.

  • 200 — they are a member. The body is one membership object carrying their role (owner, admin, or member), so a tiered gate reads its tier from the same response.
  • 404 — not a member, including the case where the space does not exist; see the collapsed 404. Treat it as a boolean, never as an error to report or retry.
  • 403 — the grant ended: revoked, expired, or it never carried read:memberships. 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. The one thing that must invalidate that cache immediately is a 403 from any gated route.

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. It behaves identically, collapsed 404 included, and groups have no subdomain — address them by entity id or protocol DID. The two walks (GET /v1/memberships and GET /v1/group-memberships) list everything the grant covers, but a check stays one request as the user joins more spaces.

Reading the content behind the gate​

read:posts is what lets you show them something. Ask for it alongside read:memberships and pick the shape that fits:

  • GET /v1/feed — one cursor-paginated stream across every space the grant reaches, each item carrying the space it came from. You do not need to know which spaces the user is in to call it.
  • GET /v1/spaces/{space}/posts — the same feed for one space, and the same URL an anonymous visitor calls. Presenting the credential turns the public feed into the member one; presenting nothing returns the public feed.

Where read:posts reaches is what the user picked at the consent screen: every space they belong to, or a named set. Ask for the narrower thing when you can.

A space the user leaves goes silent: its posts stop appearing, and the API does not report which spaces a grant has lost. A credential that does not cover a space returns the anonymous projection from the post routes rather than an error, so a 200 is not evidence your grant is working — read your coverage from GET /v1/credential.

GET /v1/feed?limit=20 HTTP/1.1
Host: api.dfos.com
Authorization: DFOS <request-proof>
X-Credential: <credential>

Sign only the coordinates you wrote​

A membership check is the case where you genuinely have to parameterize a signer. Sign only the one path segment, validated against a tight charset and percent-encoded, inside a request template written in your own file — never a raw path a browser handed you. See Why sign every request.


See Membership routes for field semantics and both walks, Posts for the feed and comment shapes, Conventions for the auth headers, and the interactive reference.