Skip to main content

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

  1. Your app builds a challenge — a small canonical document naming your domain, a nonce you minted, and a timestamp. Base64url-encode it.

  2. 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. scope is a space-separated set — ask for several at once, e.g. scope=read:profile%20read:email. client_did is optional when the set is identity alone and required as soon as it contains any scope that issues a credential — see Scopes and credentials.

  3. 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.

  4. On approve, DFOS signs your challenge with the user's key and sends the browser back to your redirect_uri with jws (the signed challenge) and did as 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 no Referer header.

  5. On deny, the browser returns to your redirect with ?error=access_denied and nothing signed.

  6. 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 did query 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.json and 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 bare client_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 no https origin to serve either. It has two shapes. Anonymous loopback names no client and gets identity alone. Key-proven loopback names a client_did and 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.

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 resolvedRows
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_did answering 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 new client_did from 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:

ReadsWhat the last look at https://{domain}/.well-known/dfos-app.json found
attestedIt named this client_did
changedIt named a different one
unreachableNo usable document came back — unreachable, blocked, or malformed
uncheckedDFOS 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 login as the worked example.
  • Scopes and credentialsidentity, read:profile, read:email, and read: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.