Scopes and credentials
Sign in with DFOS has four scopes today. identity proves who someone is and
grants your app nothing. The three read: scopes each additionally issue your
app a credential — a durable grant, addressed to your app's DID, that opens
a credential-gated route on the DFOS API.
The scopes
| Scope | What approving it grants |
|---|---|
identity | Nothing. The user signs your challenge and that is all. |
read:profile | Read the granting user's profile — name, handle, and avatar. |
read:email | Read the granting user's email address. |
read:memberships | See the spaces and groups they belong to. |
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, and it does not include the profile
fields to a credential that did not ask for it: GET /v1/profile returns exactly
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.
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. It carries a single
attenuation on
api:<host>whose action set lists every scope you were granted — for the request above,read:profile,read:email. Present that one credential on every request; the route you are calling picks the action it needs out of it.
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
a carried identity chain and an ask proof
(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.
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 not a bearer token — holding it alone opens nothing, because every request also needs a signature from your app's key — but it is half of an access grant and belongs nowhere near a browser's persistent storage or your logs. Why sign every request is the argument for that design, and for what a stolen credential 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, and it is an exception about the ceiling rather than the framing. A credential issued to a local application — the key-proven loopback tier — expires in 14 days, because there is no domain to re-check and nothing published that could go dark if that client went bad. See Local applications. Revocation is still the timely lever there too; the shorter number 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, and it is checked on every single request.
The moment a credential is revoked, the next request using it returns 403 —
there is no cache to wait out and no window to slip through. That is also why
DFOS returns 503 rather than serving a request when it can't determine
whether a credential was revoked: it declines to guess.
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 the 90-day expiry. What that screen does and does not cover, from the user's side, is Sessions, grants, and keys.
Each app there also shows a binding-health line: what its domain last said
about it, and when. It authorizes nothing and revokes nothing — it is the
context a user reads before deciding whether to keep a grant. An app whose
document has gone unreachable, or has started naming a different identity, says
so on that row, so keeping your dfos-app.json served and current is worth more
than it looks. The four states are in
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.