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.
| Scope | Level | What approving it grants |
|---|---|---|
identity | — | Nothing. The user signs your challenge and that is all. |
read:profile | account | Read the granting user's profile — name, handle, and avatar. |
read:email | account | Read the granting user's email address. |
read:memberships | account | See the spaces and groups they belong to. |
read:posts | space | Read posts and their comments as that user sees them, paid content included. |
write:upvotes | space | Upvote and un-upvote posts and comments as them. |
write:comments | space | Write, edit, and delete their own comments. |
write:posts | space | Write, 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.
spaces | What it means | Consent screen |
|---|---|---|
all | Every space the user belongs to, including ones they join later. | States that, and that it discloses their membership list. |
| A comma-separated list | Exactly those spaces. Each id is the 31-character body of a space DID. | Lists them. No picker. |
| Omitted | The 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:
spacesis refused on an ask that reaches no spaces. Sending it alongsideread:profilealone 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
allmixed 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 oneapi:<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.
Re-consent replaces
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.comauthorizes 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
403on 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.