Sign in with DFOS
Sign in with DFOS lets someone sign in to your app with their DFOS identity,
and — if they consent — grant your app permission to read their profile through
the DFOS API. It is an open flow: serve a dfos-app.json
document at your own domain, or — if you have no domain at all — prove your key
from a local port, with no registration and no approval step either way.
The result of a sign-in is a signed artifact your app verifies for itself against the public relay. DFOS is not in the loop after that, and there is no session on our side for your app to poll.
How the flow works
-
Your app builds a challenge — a small canonical document naming your domain, a nonce you minted, and a timestamp. Base64url-encode it.
-
Redirect the browser to the DFOS consent screen, carrying the challenge:
https://app.dfos.com/authorize?challenge=<base64url challenge>&redirect_uri=https://yourapp.example/callback&scope=identity&client_did=did:dfos:…Or, asking for a credential covering two scopes at once:
https://app.dfos.com/authorize?challenge=<base64url challenge>&redirect_uri=https://yourapp.example/callback&scope=read:profile%20read:email&client_did=did:dfos:…Query parameters are
snake_case.scopeis a space-separated set — ask for several at once, e.g.scope=read:profile%20read:email.client_didis optional when the set isidentityalone and required as soon as it contains any scope that issues a credential — see Scopes and credentials. -
DFOS resolves who is asking and renders the consent screen. If the user isn't signed in to DFOS, they sign in first. The screen leads with your domain — or, for a local application, with "Local application" and your DID — and states what was checked as a short list of one-line facts. Under a credential scope it also lists exactly what would be granted, one line per scope you asked for, in DFOS's own words rather than yours. See What the consent screen states.
-
On approve, DFOS signs your challenge with the user's key and sends the browser back to your
redirect_uriwithjws(the signed challenge) anddidas query parameters. A credential scope additionally delivers ONE credential — covering every credential scope in your set — in the URL fragment, which browsers never send to a server, so it lands in no access log and noRefererheader. -
On deny, the browser returns to your redirect with
?error=access_deniedand nothing signed. -
Your app verifies the artifact — check the signature against the signing DID's identity chain at the relay, check the domain and timestamp, and consume your nonce atomically so a replayed callback can't be redeemed twice. The DID you act on is the one the signature yields, never the
didquery parameter, which is unauthenticated convenience.
A returned jws sits in the address bar, in browser history, and in the
referrer of whatever your page loads next. Scrub it with history.replaceState
as soon as you've read it.
The challenge is short-lived: DFOS refuses one older than four minutes, or one timestamped more than a minute into the future.
Two tiers, and the tier is only about trust
An app reaches the consent screen one of two ways, and this is the part that usually surprises people. The tier is decided at consent time by what kind of proof the request can offer about who is asking — nothing else:
- Just-in-time (JIT) — you serve
https://yourdomain/.well-known/dfos-app.jsonand DFOS fetches it live at consent time. Nothing is filed and nothing is stored. A name is optional and not shown on the consent screen — it is a claim about yourself that nobody checked. Neither is a bareclient_did: once your identity is proven, by an origin binding or by a credential being issued to it, the screen links to the explorer's view of it instead. This is how every hosted app resolves: today, for anyone, with no permission from us. - Loopback — a redirect to
localhost,127.0.0.1, or::1: a CLI, an agent, a desktop app, a local dev server. No name and no domain, ever, because there is nohttpsorigin to serve either. It has two shapes. Anonymous loopback names no client and getsidentityalone. Key-proven loopback names aclient_didand proves it holds that DID's key right now, which is enough to be issued a credential. See Local applications.
Whichever tier resolves you, the identity in play is the one the chain
derives — a dfos-app.json is carriage and a domain binding, not the identity
itself. On the loopback tier the request URL carries the chain instead, and the
verification is the same verification.
The tier changes what the consent screen says, and nothing else. It is a trust display, fully decoupled from function. A JIT app's domain leads on the screen, over a link to the explorer's page for the identity that is asking — once that identity has been proven, and not before; a loopback app leads with "Local application" and its DID, because that is the whole of what is known. Neither one shows a name. Both tiers get the same scopes, the same credentials, the same API access, and the same treatment of a published identity chain. There is no checkmark, and no capability behind one to apply for.
If your app is refused, it is almost always because the document didn't resolve — being unknown to DFOS is the ordinary state and denies you nothing. The exception is an explicit platform denial of a domain or an identity, which is rare, deliberate, and names itself as one rather than arriving as a failed fetch. See The dfos-app.json document.
What the consent screen states
The platform does not vouch, in either direction. There is no "Verified app" badge and — equally — no "DFOS has not reviewed this app", because framing DFOS as the missing authority is still framing it as the authority. What the screen states instead is exactly what was checked, as a list of one-line facts: a check mark for something confirmed, an amber triangle for where confirmation stops. Every row carries the thing that was checked — the domain, the DID — never a bare glyph.
The limit rows are deliberately not alerts. A screen that boxes a warning around every ordinary tier teaches its readers that warnings on it mean nothing. The rows a request gets are:
| What resolved | Rows |
|---|---|
| A domain, no origin binding | ✓ {domain} serves this app's sign-in · △ Identity not yet bound to the domain |
| A domain, origin-bound | ✓ Domain verified · ✓ Identity current |
| Key-proven loopback | ✓ Holds the key for {short DID} · △ Where it runs cannot be verified |
| Anonymous loopback | △ Nothing about this app can be verified |
Binding your domain is the one thing that visibly upgrades the screen. When
your identity chain claims the domain and the domain attests the identity back,
DFOS checks both halves at consent time — no extra fetch, because reaching the
JIT tier already read your dfos-app.json from that domain — and the limit row
is replaced by two confirmations rather than a badge being added anywhere. The
domain still leads. See Origin binding.
Under a credential scope the screen also shows the user their own values
beneath each platform-authored scope line: the actual address for read:email,
their space names and exact counts for read:memberships, and whether their
profile is public or private. None of that is sent to your app — it is the
signer's own data rendered back to them so they are consenting to a value rather
than to a category. Your app still gets only what the credential lets it fetch.
The statement inside your challenge is never rendered, on any tier, for any
scope. It stays in the signed bytes and a third-party verifier still sees it, but
the consent screen shows no string you wrote — not the statement, and not
your name either. What sits under the heading instead is a link to the protocol
explorer's page for your app's identity, so the user's answer to "who is this"
comes from a third party rather than from you.
That link is earned, not automatic. It renders once your identity is a fact
the platform established rather than one your document asserted: an origin
binding, a loopback key proof, or a credential issued to it. Until then there is
no line at all — a client_did a domain merely published could name anyone, and
pointing a user at a stranger's healthy chain would be worse than pointing them
nowhere. Bind your domain and the link appears on every sign-in, identity
scope included. See Origin binding.
Trust, and the second sign-in
A user can tick "Trust this app — skip this screen next time." It is trust-on-first-use, and it buys exactly one thing: how much of the screen a returning user is made to re-read.
Trust authorizes nothing. Every gate runs identically whether trust exists or
not — canonical challenge bytes, freshness, the redirect allowlist, domain
resolution, the scope gates, the loopback ask proof, the single-use consume. A
trusted app that stops serving a valid dfos-app.json is refused exactly as an
untrusted one is. Nothing about your integration changes; the flow you built is
the flow that runs.
What the user pinned is a signature — client DID × domain × tier × scope set — and comparing it to the request in hand gives one of three outcomes:
- Fast path. The signature is unchanged and your ask is within what was pinned. A short card: one check line, who they are, a scope summary, Approve and Deny. A narrower ask still fast-paths; they consented to strictly more.
- Full screen. No standing trust, or a scope escalation over what was pinned. Asking for more is not a different app, so the pin is not voided — the user simply re-consents, and approving with trust re-pins the union. Widening your scope set is safe; it costs one full consent, once.
- Re-fire. The identity moved — a different
client_didanswering for a domain the user already trusted, or a tier change. The pin is voided before the screen renders, and the card leads with the diff: the DID they trusted, struck through, against the one asking now. Rotating a key inside your app identity's chain keeps the same DID and changes nothing; serving a newclient_didfrom the same domain does re-fire, and it should — from the outside, replacing your app identity and a domain changing hands are the same event.
Trust is offered only where there is a stable identity to pin. An anonymous
loopback client proved no identity, so it can never be trusted; a JIT app that
serves no client_did cannot be trusted either, though it still participates in
the delta check — a domain that used to serve a DID and now serves none re-fires
rather than reading as a fresh app.
Users manage this in Settings → Connected apps: a trusted app shows a "Trusted" mark and a Remove trust control, which ends nothing and only means the app asks for full approval next time. Disconnecting an app voids its trust along with its grant.
What that screen says about your domain
Each app on Connected apps also carries a binding-health line — what the app's domain most recently said about it, and when it was last looked at:
| Reads | What the last look at https://{domain}/.well-known/dfos-app.json found |
|---|---|
attested | It named this client_did |
changed | It named a different one |
unreachable | No usable document came back — unreachable, blocked, or malformed |
unchecked | DFOS has never looked, or the document asserts no client DID at all |
It is a read-out of a check that already happened, not a new one: opening the
screen fetches nothing from you. It gates nothing either — none of the four
states changes what your app can do, and changed is the same affirmative
contradiction that voids a trust pin rather than a separate penalty.
What it is, is what a user reads when they are deciding whether the app in front of them is still the one they connected to. A document that goes dark, or starts naming a different identity, shows up here. A local application has no domain and therefore no line at all.
Local development
A redirect to localhost, 127.0.0.1, or ::1 is recognized as a loopback
redirect, which is how a CLI or a local dev server signs in. Which of the two
loopback shapes you get depends on what your request carries.
Anonymous loopback is the dev smoke test. Send no client_did and nothing
is claimed on your behalf: the consent screen invents no name and carries the
single limit row saying nothing about the app can be verified, plus "Continue
only if you just started a sign-in yourself." That shape is limited to
scope=identity, and a set pairing identity
with a credential scope is refused outright rather than quietly narrowed. It is
still the right first check when you are wiring the flow — a round trip that
grants a session proves your redirect allowlist, your challenge minting, and
your verification all work.
Key-proven loopback is for apps that have no domain at all — a CLI, an
agent, a desktop app. Carry a client_did, that DID's identity chain, and an
ask proof (a JWS over the same challenge bytes, signed by a current auth key
of that chain, under its own did:dfos:siwd-ask type) and the request can ask
for credential scopes like any other. What it buys is a real credential; what it
costs is a shorter life — a loopback credential expires in 14 days rather
than 90, because there is no domain to re-check and nothing published that could
go dark.
Local applications is the page for this, and the normative
rules are in
SIWD § Loopback clients.
Libraries
The @metalabel/dfos-client package (npm) implements both halves. Its ./siwd
subpath builds and reads the flow — createSiwdChallenge,
createSiwdLoginRequest, readSiwdCallback, and verifySiwd, which takes your
atomic consumeNonce callback and returns a verified session. Its ./api-auth
subpath signs the request proofs you'll need if you take a credential.
The normative byte-level rules live in the Sign In With DFOS specification, and the libraries, the CLI, and a worked demo app are open source at github.com/metalabel/dfos.
Next
- Set up Sign in with DFOS — the step-by-step recipe: install the CLI, mint an app identity, serve the app description, bind your domain, and wire the flow.
- The dfos-app.json document — what to serve, and what publishing your app's identity chain gets you.
- Local applications — the lane for a CLI, an agent, or a
desktop app: the two loopback shapes, the ask proof, and
dfos loginas the worked example. - Scopes and credentials —
identity,read:profile,read:email, andread:memberships; how to ask for several at once, and how a credential lives and dies. - Why sign every request — why a credential is not a bearer token, and what a stolen one is actually worth.
- Origin binding — how an identity claims a domain and the domain attests it back, and what each verdict means.
- Key custody — where your app's keys live, what losing one costs, and how to get one onto a deployed server.
- Troubleshooting — every failure in this flow, why it is silent on the wire, and how to diagnose it locally.
GET /v1/profile— the granting user's own profile and email.- Membership routes — the spaces and groups they belong to, as paginated walks or one-request checks.
GET /v1/credential— what the grant you are holding actually covers.- Glossary — identity chain, credential, request proof, attenuation, and the rest of the vocabulary.