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 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​

  1. 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 a did, which means "sign in as this identity or not at all." The schema is in the specification.

  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. 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. 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 no https origin, so there is no domain. An anonymous loopback request gets identity alone; a key-proven one proves it holds its client_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.

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 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 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:

LineWhat the last look at https://{domain}/.well-known/dfos-app.json found
Attests this appIt named this client_did
Now names a different identityIt named a different one
Not respondingNo 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:

  • createSiwdLoginRequest mints the challenge and the authorize URL, and takes optional spaces, statement, and did inputs.
  • createSiwdLoopbackLoginRequest does the same for a key-proven local client, and mintSiwdClientIdentity mints that client's identity.
  • readSiwdCallback sorts the return into success, denied, or not a callback.
  • verifySiwd 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 credentials — the seven scopes, the spaces parameter 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.