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 access through the
DFOS API. Serve a dfos-app.json at your domain, or prove
your key from a local port; there is no approval step.
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. Two members are optional: a
statement, which the consent screen never renders, and adid, which means "sign in as this identity or not at all." The schema is in the specification. -
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. 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. There is nothing to file. This is how every hosted app resolves: today, for anyone, with no permission from us. See The dfos-app.json document. - Loopback — a redirect to
localhost,127.0.0.1, or::1: a CLI, an agent, a desktop app, a local dev server. There is nohttpsorigin, so there is no domain. An anonymous loopback request getsidentityalone; a key-proven one proves it holds itsclient_did's key and can 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 carries the chain instead, and the
verification is the same verification.
The tier changes what the consent screen says, and nothing else. A JIT app's domain leads the screen; a loopback app leads with "Local application" and its DID. Neither 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.
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 Troubleshooting.
What the consent screen states
The platform does not vouch, in either direction. There is no "Verified app" badge and no "DFOS has not reviewed this app". The screen states 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 names the thing that was checked — the domain, the DID — never a bare glyph. 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
the chain you serve claims your domain and your served dfos-app.json names
that identity's client_did, the limit row is replaced by two confirmations.
Nothing is added anywhere, and 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. Your app still
gets only what the credential lets it fetch.
The screen shows no string you wrote — not the challenge's statement, and
not your name. A third-party verifier still sees the statement in the signed
bytes. Under the heading sits 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.
That link renders once your identity is proven: by an origin binding, a loopback
key proof, or a credential issued to it. Until then there is no line at all.
Bind your domain and the link appears on every sign-in, identity scope
included.
The screen also states where
An ask that reaches into spaces gets a second block, directly under what is
granted. Your spaces parameter decides which of three the user sees —
In all your spaces, which also says that spaces they join later are covered;
In these spaces, naming the ones you sent; or Choose where, a picker over
their own spaces with nothing pre-ticked and Approve disabled until they choose.
Send no spaces and the user can hand you back less than you asked for, so
read the credential rather than assuming what it covers. The parameter itself is
Where a grant reaches.
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 verification step runs identically whether
trust exists or not. 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.
A returning trusted user gets a short re-confirmation card — one check line, who they are, a scope summary, Approve and Deny — instead of the full screen. A narrower ask still gets the short card; they consented to strictly more.
Three things bring the full screen back. Asking for more scope than the user
trusted is not a different app, so the trust stands — the user simply
re-consents, and approving with trust covers the wider set. Widening your scope
set is safe; it costs one full consent, once. Reaching a space the trusted
grant does not cover does the same: what was trusted is a scope set and the
places it applies to, so an ask that widens either one is re-consented. An ask
that sends no spaces always gets the full screen, because the picker is the
consent. A different app identity, or a change of tier, ends the trust
instead: 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 new client_did from the
same domain forces full re-consent.
Trust is offered only where there is a stable identity to trust. 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 — a domain that used to serve a
DID and now serves none forces full re-consent 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. An app whose grant reaches into spaces also carries a line there naming them, or saying it covers all of the user's spaces.
What that screen says about your domain
Each hosted app on Connected apps can also carry a line saying what the app's domain most recently said about it, beside a "Domain last checked" date:
| Line | What the last look at https://{domain}/.well-known/dfos-app.json found |
|---|---|
| Attests this app | It named this client_did |
| Now names a different identity | It named a different one |
| Not responding | No usable document came back — unreachable, blocked, or malformed |
When DFOS has never looked, or the document asserts no client DID at all, there is no line. A local application has no domain and never gets one.
The line gates nothing and changes nothing your app can do. It is what a user reads when deciding whether the app in front of them is still the one they connected to, so a document that goes dark or starts naming a different identity shows up here.
Local development
A redirect to localhost, 127.0.0.1, or ::1 resolves on the loopback tier.
Sent with no client_did, it is the anonymous shape, limited to
scope=identity. That makes it the right smoke test while you wire the flow: a
round trip that grants a session proves your redirect, your challenge minting,
and your verification all work. A CLI, an agent, or a desktop app that needs a
credential uses the key-proven shape instead.
Local applications covers both shapes, and the normative rules
are in the
specification.
Libraries
The @metalabel/dfos-client package (npm) implements both halves. Its ./siwd
subpath builds and reads the flow:
createSiwdLoginRequestmints the challenge and the authorize URL, and takes optionalspaces,statement, anddidinputs.createSiwdLoopbackLoginRequestdoes the same for a key-proven local client, andmintSiwdClientIdentitymints that client's identity.readSiwdCallbacksorts the return into success, denied, or not a callback.verifySiwdtakes your atomicconsumeNoncecallback 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 — the seven scopes, the
spacesparameter that says where a grant reaches, 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.