Skip to main content

Scopes and credentials

A grant has two dimensions: scope says what your app may do, and spaces says where. Every scope except identity issues your app a credential, a durable grant addressed to your app's DID that opens credential-gated routes on the DFOS API.

The scopes​

Each scope has a level, and the level decides whether spaces applies to it. An account-level scope is a fact about the person, and there is one of each per account. A space-level scope is about content inside a space, so it needs somewhere to be true.

ScopeLevelWhat approving it grants
identity—Nothing. The user signs your challenge and that is all.
read:profileaccountRead the granting user's profile — name, handle, and avatar.
read:emailaccountRead the granting user's email address.
read:membershipsaccountSee the spaces and groups they belong to.
read:postsspaceRead posts and their comments as that user sees them, paid content included.
write:upvotesspaceUpvote and un-upvote posts and comments as them.
write:commentsspaceWrite, edit, and delete their own comments.
write:postsspaceWrite, edit, and delete their own posts. Never announce, broadcast, pin, or moderate.

The three write scopes are own-content-only: nothing in this family reaches another member's content.

read:posts is a superset disclosure of read:memberships, and the consent screen says so rather than letting the token names imply independence. It also reaches paid content: an entitled member sees full bodies and signed attachments, and so does an app reading as them.

scope=identity​

The user signs your challenge and nothing is issued. Your app learns their protocol DID and can verify the signature itself; it gains no access to anything private. This is the sign-in-button case, and it needs no client_did.

The consent screen says so plainly: signing in shares the user's DID, which is their public identity address, and their public profile is already resolvable from it.

scope=read:profile​

The user signs your challenge and DFOS mints a credential naming your app's DID as the audience. That credential lets you read the granting user's own profile through GET /v1/profile — their handle, display name, bio, avatar, and join date.

It does not include the email address: GET /v1/profile returns only the fields your grant covers. Note that a DFOS profile can be private, so these fields are not always available anonymously — asking for read:profile is a real request, not a formality.

scope=read:email​

The user's email address, usually what an app needs to key an account of its own.

It stands on its own: a credential carrying read:email and nothing else opens GET /v1/profile and gets {did, email} — no name, no handle, no avatar. You do not have to ask for read:profile to get the email, and asking for read:profile does not get you it.

scope=read:memberships​

Where the user spends their time: every space they belong to, their role in each, and every group they belong to inside those spaces, through the membership routes.

This includes private and unlisted spaces. The anonymous API will not admit those exist; this route names them because the space's own member told you to. That is the disclosure the consent line describes, and it is why this is a separate scope rather than part of read:profile — an app that wants a display name should not incidentally learn a person's whole social graph.

Ask for it when your app genuinely does something with the membership graph — gating your own features on a space, syncing a roster, showing a "your spaces" picker. If you only need to know who someone is, read:profile is the smaller ask, and users approve smaller asks.

Where a grant reaches​

The spaces parameter is how you say where. It takes three values, and the third one is the interesting one.

spacesWhat it meansConsent screen
allEvery space the user belongs to, including ones they join later.States that, and that it discloses their membership list.
A comma-separated listExactly those spaces. Each id is the 31-character body of a space DID.Lists them. No picker.
OmittedThe user chooses at the consent screen.A picker over their own spaces, plus an explicit all-spaces option.
https://app.dfos.com/authorize
?challenge=<base64url challenge>
&redirect_uri=https://yourapp.example/callback
&scope=read:posts
&spaces=a9f79nfc28r6t9kk9akkch43k4cr8tc
&client_did=did:dfos:…

Omitting spaces is the friendlier ask. It is the one case where consent can NARROW an unqualified request: you ask for read:posts, and the user decides which of their spaces that covers. Nothing is pre-ticked, so a user who wants to grant everything has to say so.

Four rules, and each is a refusal rather than a repair:

  • spaces is refused on an ask that reaches no spaces. Sending it alongside read:profile alone fails the whole request rather than being ignored, because a caller that sent it believes it narrowed something.
  • Nothing is normalized. Whitespace, an uppercase character, a duplicate id, a trailing comma, or all mixed with ids all refuse the request. You chose the string; the user is about to approve what it means.
  • A list of more than 31 spaces is refused. The same ceiling bounds what a user can pick.
  • A space the user is not in is still issued. The consent screen renders the raw id and says they are not in it — DFOS will not resolve an id you supplied into a name it has no standing to show you. The entry is inert: it grants nothing.

All-spaces is open-ended, on purpose​

spaces=all is not a snapshot of the spaces someone is in today. It keeps covering spaces the user joins next month. That is why the consent screen says so out loud, and why a user who wants a fixed set should be offered the picker instead.

Requesting more than one​

scope is a space-separated set, the OAuth convention. Ask for everything your app needs in one sign-in:

https://app.dfos.com/authorize
?challenge=<base64url challenge>
&redirect_uri=https://yourapp.example/callback
&scope=read:profile%20read:email
&client_did=did:dfos:…

Three rules govern the set:

  • Every token must be a scope DFOS implements. One unknown token refuses the whole request — the valid part is not silently granted, because your app would then believe it holds a scope it does not, and the user would have approved a screen describing something else.
  • The consent screen states one platform-authored line per scope. DFOS writes those sentences, not you, and it shows all of them.
  • You get ONE credential, not one per scope and not one per space. It carries an attenuation with one entry per place: a api:<host> entry listing the account-level actions you were granted, plus one api:<host>/spaces/<space id> entry per space the grant was narrowed to. For the request above that is a single entry, read:profile,read:email. Present that one credential on every request; the route you are calling picks the action it needs out of it.

The credential is the answer, not your request​

What you asked for is a hint. What you were granted is the artifact in your hand, and the two can differ — a user narrowing an unqualified read:posts ask to one space is the ordinary case, not an error. So read the grant rather than assuming your request was honored whole:

const { scopes, attenuation } = await introspect(); // GET /v1/credential

attenuation is the authoritative shape. Do not infer coverage from a resource string's spelling — treat unrecognized forms as opaque, and let a 403 tell you when an action is not covered. GET /v1/credential returns both, which makes it the right call at startup.

Building the authorize URL by hand is not the intended path. Set up § 7 shows the scope set going out through @metalabel/dfos-client, the credential coming back, and the call it opens.

Because a credential is addressed to someone, client_did is required as soon as the set contains any credential scope: it must be present on your request and backed by your dfos-app.json — or, for a local client, by an ask proof and, unless DFOS already holds that DID's chain, a carried identity chain (Local applications).

Approving is single-use with respect to the challenge: re-submitting the same approved challenge is refused, so a replayed consent can't mint a second credential.

One app holds one standing grant. When the same user approves your app again, the credential they had already issued you is revoked in the same breath as the new one is minted — including a grant made under a different domain, because the pair is keyed on your app's DID and an app that publishes a dfos-app.json and also ships a CLI is one app.

That is what makes narrowing mean anything: a user re-consenting your app down from all their spaces to one would otherwise leave the wide grant standing beside the narrow one, and you would keep spending the wide one.

For your app it means exactly one thing: store the newest credential and drop the previous one. The old one starts returning 403 on your very next request. It is not a disconnection — the user is not warned that your app was removed, and their trust decision survives — it is a replacement.

Ask for the narrowest set that works​

A user reading a shorter list approves more of them. If you only need an account key, ask for read:email alone. If you only need a display name and avatar, ask for read:profile alone. read:memberships is the largest of the three by a wide margin — it enumerates private rooms — so ask for it only when your app actually reads the membership graph.

What you get back​

The signed challenge comes back as jws and did query parameters. The credential comes back in the URL fragment — the part browsers never send to a server, so it appears in no access log and no Referer header. Read it, store it server-side, and scrub the URL.

Treat the credential as a secret your server holds: it is half of an access grant and belongs nowhere near a browser's persistent storage or your logs. Why sign every request covers what a stolen one can and cannot do.

How a credential lives​

A credential is durable and reusable. It is issued once and stays valid across as many API requests as you want to make with it. This is the piece most worth getting straight:

  • You do not consume a credential by using it. The same credential backs every request. Nothing is spent, decremented, or exchanged.
  • You do sign a fresh proof for every request. Each API call carries a new short-lived signature from your app's key, binding that exact method, host, path, and body. The credential says what you may do; the proof says you're the one doing it, and doing exactly this. See Authentication.
  • The credential names one host, and one action set on it. A credential for api.dfos.com authorizes the actions it lists there, and nothing else anywhere else.

Credentials carry a 90-day expiry as a backstop, so an abandoned grant lapses on its own. Do not build around it: expiry is not the safety mechanism here.

One tier is the exception. A credential issued to a local application — the key-proven loopback tier — expires in 14 days instead. See Local applications. Revocation is still the timely lever there too; the shorter expiry just means re-consent is a normal event on that tier rather than a rare one.

How a credential ends​

Revocation is the timely lever. The moment a credential is revoked, the next request using it returns 403.

Design for revocation as a normal event, not an error:

  • Handle 403 on a credential-gated route as "this grant has ended." Stop retrying, drop the credential, and put the user back in front of a sign-in button if they want to reconnect.
  • Delete the data the grant let you read when it ends, or when your app no longer needs it. Revoking ends future access; it cannot un-share what you already read.

Users revoke from their own settings: the Connected apps screen lists every standing grant, states what each one permits, and can end one app's access or all of it. A revocation takes effect on your very next request — build for that, not for expiry. What that screen does and does not cover, from the user's side, is Sessions, grants, and keys.

That screen also tells the user what your domain last said about your app, so keep your dfos-app.json served and current — see What that screen says about your domain.

Building the proofs​

The @metalabel/dfos-client package's ./api-auth subpath implements the signing side — signApiRequest and buildApiAuthHeaders produce the two headers each request needs. The normative rules are in the DFOS API-AUTH specification; there is a byte-compatible Go implementation as well. You should not need to construct the signing input by hand.

Set up § 7 has the runnable version: both packages installed, the fetch seam where they meet, and one gated call going out. A proof that will not verify is nearly always one of three things — Troubleshooting works down the list.