Set up Sign in with DFOS
This is the recipe: from nothing to a working sign-in button, one step at a time, each with a way to check it before you go on. The reference pages linked throughout explain why each piece is shaped the way it is — this page only tells you what to run and what you should see.
The worked example is dfos-siwd-demo.vercel.app, a complete relying party — challenge minted server-side, JWS verified server-side, a live credential-gated API call — whose source is small enough to read in one sitting. Every step below points at the corresponding piece of it.
Before you start
No domain? If you are building a CLI, an agent, or a desktop app, this recipe is not your lane — go to Local applications, which covers the loopback tier end to end. Everything below assumes a hosted app.
You need:
- A domain you control that can serve a static file over
https. The served file is the whole of your setup — there is no developer portal and no client secret. - Somewhere to run server code. A signed sign-in must be verified where the session is granted, and a credential must be exercised by a key a browser cannot hold. A serverless function is enough; the demo uses six.
If you only need scope=identity — prove who someone is, grant your app
nothing — steps 1, 2, 4, and 5 are optional. Serve a dfos-app.json with just
name and redirect_uris (step 3) and wire the flow (step 6). The rest of this
page is what a credential scope and a published app identity add.
1. Install the dfos CLI
The dfos CLI is the sovereign actor: it generates your app's keys, signs its
identity operations, and never lets private key material leave your machine.
Origin binding needs v0.34.0 or newer.
# macOS / Linux
curl -sSL https://protocol.dfos.com/install.sh | sh
# or Homebrew
brew install metalabel/tap/dfos
Windows binaries are on
GitHub Releases; go install github.com/metalabel/dfos/packages/dfos-cli/cmd/dfos@latest works too.
Check your work. dfos version prints the version — confirm it is at least
0.34.0. The full command reference is the
CLI documentation.
2. Mint your app's identity
Your app gets its own protocol identity — its own keypair, its own signed chain — exactly like a user's. That DID is what a credential is issued to, and what every request proof is signed by.
dfos identity create --name my-app
The controller key and the first auth key land in your OS keychain — or,
on a host without a reachable one, in plaintext under ~/.dfos/keys/. Run
dfos status to see which. Key custody is the page to read
before you go further: there is no key export and no seed phrase today, losing
the controller key freezes this identity permanently, and the only mitigation is
a second controller key added in advance. It is the one irreversible decision
in this recipe.
For a deployed server you want a second auth key whose secret you can actually hold, so a compromised deployment is a revoke-and-re-add rather than a new identity:
# file-based key storage writes the seed to ~/.dfos/keys/ and prints the
# id + public Multikey to hand to add-key
DFOS_NO_KEYCHAIN=1 dfos identity device-pubkey --as my-app --json
# graft it into the chain's auth set, signed by the keychain controller key
dfos identity add-key --auth-key --id key_<from above> --pubkey z6Mk<from above>
Getting that secret onto a server is the other half of this step: the seed file's
path, the hex→base64url conversion, the DFOS_APP_KID / DFOS_APP_PRIVATE_KEY
contract, and the delete-the-seed hygiene step are all in
Key custody § Getting the auth key onto a deployed server.
Check your work. dfos identity show my-app prints the DID and current
state; dfos identity keys my-app lists every key and whether its private half
is available locally. Both keys should appear in the auth set.
Can't run the CLI? There is a JavaScript path with real custody tradeoffs — see Key custody § Minting an identity without the CLI.
3. Author your app description
/.well-known/dfos-app.json is the one thing you publish. It declares your
name, the exact redirect targets DFOS will accept, your client_did, and —
carried inline — your identity's full signed operation log.
Write the file's own members by hand:
{
"name": "Field Notes Reader",
"redirect_uris": ["https://yourapp.example/callback"]
}
Then let the CLI fill in the two identity members, so they cannot drift from the chain you actually hold:
dfos identity well-known my-app --patch public/.well-known/dfos-app.json
--patch writes client_did and the genesis-first identity_chain into the
file while preserving name and redirect_uris. Run it with no --patch to
print those two members instead of writing them. Carriage is capped at 100
operations.
What you should have afterward — the whole document, four members, nothing else:
{
"name": "Field Notes Reader",
"redirect_uris": ["https://yourapp.example/callback"],
"client_did": "did:dfos:r7z9c4kfhne2t38va6d9kn2ch7f4b6a",
"identity_chain": [
"eyJhbGciOiJFZERTQSIsImtpZCI6ImtleV84emszaGU…",
"eyJhbGciOiJFZERTQSIsImtpZCI6ImRpZDpkZm9zOnI…"
]
}
identity_chain is the raw array of operation JWS strings, genesis first —
each entry one compact JWS, exactly as the CLI printed it. It is never a single
base64 blob, and never a JSON string containing an array. If yours is one long
string rather than a list, something re-encoded it on the way into the file.
Deploy so the file is served at https://yourdomain/.well-known/dfos-app.json.
Check your work. curl https://yourdomain/.well-known/dfos-app.json should
return the document over https with no redirect. Then open the origin in the
explorer —
explore.dfos.com/#/domain/dfos-siwd-demo.vercel.app
is the demo's; substitute your own host — which fetches the document, verifies
that the carried chain derives the declared client_did, and compares it
against what the relay holds.
The document is strict: an unknown member is a refusal, and every
redirect_uris entry must be https, on this exact domain, with no port.
The dfos-app.json document has the full member table and
the list of things that make a fetch fail.
4. Bind your domain
Publishing the document proves control of the domain at consent time. An origin binding makes that fact standing and independently checkable: your identity's chain claims a domain, and the domain attests the DID back. Either half alone is a claim anyone could make; together they prove one party controls both, with no DFOS server in the loop.
It is also the one step that visibly upgrades your consent screen: DFOS checks both halves at consent time, and a bound app's caveat row is replaced by two confirmations rather than a badge — what a binding buys at the consent screen.
Run this on the machine holding the controller key — the one where you ran
identity create. Steps 3, 4, and 5 all sign or read from that chain.
dfos identity bind-domain yourdomain.example
The argument is a bare lowercase hostname. The command prints exactly what the domain must then serve — either of these, whichever your hosting allows:
| Method | What to publish |
|---|---|
| HTTPS | https://<domain>/.well-known/dfos-did containing exactly the DID, plain text |
| DNS | _dfos.<domain>. TXT "did=did:dfos:<id>" |
If you already serve an app description, you may not need either. A valid
dfos-app.json whose client_did names this DID attests it too, as an HTTPS
fallback for the absence of a dfos-did file.
Re-patch your app description afterward. bind-domain signs a new chain
operation, and the identity_chain you carried in step 3 predates it. Run
dfos identity well-known my-app --patch public/.well-known/dfos-app.json
again — on the controller-key machine — and redeploy, so the chain DFOS ingests
carries the domain claim.
Check your work. dfos identity services my-app should list a DfosOrigin
entry naming your domain. The demo's binding is visible in its
served document.
The flags, the CORS header the well-known document wants, the exact scope of the fallback rule, and why an identity claims at most one domain are all in Origin binding.
5. Verify the binding
verify-binding runs both halves locally — resolve the chain claim, query the
domain over HTTPS and DNS — and folds them into one verdict.
dfos identity verify-binding # the active identity
dfos identity verify-binding my-app # a local name or a DID
dfos identity verify-binding yourdomain.example # domain-first walk
dfos identity verify-binding yourdomain.example --json
Four verdicts, mapped to exit codes so a script can branch without parsing
output: bound (0), broken (1), stale (2), and no-claim (0). stale is
not broken — silence is could not check, contradiction is checked and
contradicted — and the difference matters enough that
Origin binding spells out each verdict,
what causes it, and what to do about it.
Check your work, visually. The explorer renders the same verdict on the
identity's page — the bound domain itself, one row per method, and what each
one actually answered. The demo's identity is
explore.dfos.com/#/did/did:dfos:8zk83zez862n6ahnvt3h3e4kc4n2dke,
reading bound against dfos-siwd-demo.vercel.app.
A verified binding proves control of a domain at verification time — never personhood, endorsement, or trustworthiness. That is why every surface shows the domain rather than a checkmark. The normative rules are in the Origin Binding specification.
6. Wire the sign-in flow
Four moves: mint a challenge, redirect, read the callback, verify. The
@metalabel/dfos-client
package's ./siwd subpath implements all four, and you should not be building
the signing input by hand.
import { createClient } from '@metalabel/dfos-client';
import { createSiwdLoginRequest, readSiwdCallback, verifySiwd } from '@metalabel/dfos-client/siwd';
// server, on the sign-in click — mint and redirect
const { url, expect } = createSiwdLoginRequest({
authorizeUrl: 'https://app.dfos.com/authorize',
domain: 'yourapp.example',
redirectUri: 'https://yourapp.example/callback',
scope: 'read:profile read:email',
clientDid: 'did:dfos:…',
});
// persist `expect` in state YOU minted, then redirect the browser to `url`
// browser, on return — sort the callback into success, denied, or not-a-callback
const callback = readSiwdCallback(location.search);
if (callback.kind !== 'success') {
// 'denied' carries callback.error; 'none' means a plain page load
throw new Error(callback.kind === 'denied' ? callback.error : 'not a callback');
}
const { jws } = callback; // POST this to your server — verification happens there
// server, where the session is granted — verify
const client = createClient({ relays: ['https://relay.dfos.com'] });
const result = await verifySiwd(client, jws, {
domain: expect.domain,
consumeNonce: async (nonce) => (await store.getdel(nonce)) !== null,
});
Three rules do the load-bearing work:
- Verify where the session is granted, on your server. A bare DID is an
address, not a proof — the
didquery parameter is unauthenticated convenience, and the DID you act on is the one the signature yields. - Take your expectation from state you minted, never from the callback. A verifier that reads its expected nonce out of the artifact it is checking has implemented the check and none of the protection.
- Scrub the artifacts out of the address bar with
history.replaceStateas soon as you read them. The credential arrives in the URL fragment, which browsers never send to a server, so it lands in no access log and noRefererheader — keep it that way.
The step-by-step flow, the challenge's four-minute freshness window, and what
comes back on approve and on deny are in
Sign in with DFOS. The demo's
api/verify.ts
is the whole verification path in one readable file.
Check your work. Sign in at scope=identity first. It needs no
client_did, no store, and no app key — if the round trip grants a session,
your redirect allowlist, your challenge minting, and your verification all
work, and everything after this is about what you are allowed to read. A
redirect to localhost is recognized as a loopback redirect, and one carrying no
client_did is the anonymous shape of that tier — identity-only by construction,
which is exactly what this check wants — so it runs on your dev server as-is. A
local client that needs a real credential has a different path:
Local applications.
7. Ask for the scopes you need
scope is a space-separated set, the OAuth convention. There are three
registered credential scopes today, and identity, which issues nothing:
| Scope | What approving it grants |
|---|---|
read:profile | Name, handle, and avatar. |
read:email | The account email address. |
read:memberships | See the spaces and groups they belong to. |
Each one renders as its own platform-authored consent line — DFOS writes those sentences, not you, and the screen shows all of them. One unknown token refuses the whole request; the valid part is not silently granted.
You get one credential, not one per scope. Every credential scope in your
set names the same api:<host> resource, so they coalesce into a single
credential whose action list carries all of them — for the set above,
read:profile,read:email,read:memberships. Present that one credential on every
request and the route picks the action it needs out of it. Revoking it severs
the whole API grant at once.
Ask for the narrowest set that works. A user reading a shorter list approves
more of them, and read:memberships is the largest of the three by a wide
margin — it enumerates private and unlisted spaces.
Get the credential from the browser to your backend
The signed challenge comes back as jws and did query parameters. The
credential comes back in the URL fragment, under the key credential. There
is no kit helper for this — history and location belong to the environment,
not the library — so read it yourself:
// browser, on the callback
const params = new URLSearchParams(location.hash.replace(/^#/, ''));
const credential = params.get('credential');
if (credential !== null) {
await fetch('/api/session', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ credential }),
});
}
// drop the jws AND the fragment out of the address bar, history, and the
// referrer of whatever this page loads next
history.replaceState(null, '', location.pathname);
The split is not optional. The credential arrives in the browser, but only
your server holds the key that can sign a request proof for it — a browser
cannot hold an Ed25519 key non-extractably, and a credential in localStorage is
half an access grant sitting somewhere any script on the page can read. The
browser's only job is to hand it over; your backend stores it and does every
call.
Make the call
Spend the credential on the credential-gated routes:
GET /v1/profile— the granting user's own profile, avatar, and email, each field served only under the scope that covers it.- Membership routes — the spaces and groups they
belong to. Walk
GET /v1/membershipsandGET /v1/group-memberships, or ask about one directly withGET /v1/membership/{space}. GET /v1/credential— what the grant you are holding covers, and whether it still stands. Needs no particular scope.
Every request carries two headers: the credential, and a fresh request proof signed by your app's key over that exact method, host, path, and body. The credential is not a bearer token — holding it alone opens nothing, and why that is worth a signature per call is its own page.
npm install @metalabel/dfos-api @metalabel/dfos-client \
@metalabel/dfos-protocol @metalabel/dfos-web-relay
The snippet below assumes @metalabel/dfos-api@^0.8.0 and
@metalabel/dfos-client@^0.46.0; the last two are dfos-client's peer
dependencies, at the matching ^0.46.0, so the crypto kernel and the relay
transport are installed once rather than shipped twice.
@metalabel/dfos-api knows the API's shape — paths, params, response types,
generated from the live OpenAPI spec. @metalabel/dfos-client/api-auth knows the
byte contract. They meet at the fetch seam, where the API client hands you one
fully-composed Request and you sign exactly that:
import { createDfosApi } from '@metalabel/dfos-api';
import { buildApiAuthHeaders, signApiRequest } from '@metalabel/dfos-client/api-auth';
const api = createDfosApi({
baseUrl: 'https://api.dfos.com/v1/',
fetch: async (request: Request): Promise<Response> => {
const url = new URL(request.url);
const { proof } = await signApiRequest({
method: request.method,
host: url.host, // carries the port when there is one
path: url.pathname + url.search, // byte for byte, no normalization
body: new Uint8Array(await request.clone().arrayBuffer()),
credentialCID: held.facts.credentialCID, // from the credential you stored
kid: process.env.DFOS_APP_KID, // did:dfos:<id>#key_<id>
sign: signAsApp, // your Ed25519 signer — server-side only
});
const headers = new Headers(request.headers);
for (const [name, value] of Object.entries(
buildApiAuthHeaders({ proof, credential: held.jws }),
)) {
headers.set(name, value);
}
return fetch(new Request(request, { headers }));
},
});
const { data, error, response } = await api.GET('/profile');
Signing the request the client actually built, rather than a description of it, is what keeps the proof's binding honest — the same method, the same origin-form target, the same body octets that are about to go on the wire.
Do not let the browser choose what gets signed. Give each call its own route with the coordinates hardcoded, and let the session cookie decide only which credential to spend — a backend that signs whatever a page hands it is a confused deputy, and no key had to leak for it to be one.
How a credential lives, what it costs to use, and how revocation ends it are in
Scopes and credentials. The demo's
api/profile.ts
is this whole path in one readable file.
Check your work. Sign in at your real scope set and call the route. A 403
means your proof was right and your grant was not; a 401 means the proof
itself was refused — usually a key that is not current for your app. The demo
reads GET /v1/profile the moment a session exists and shows the credential,
the request proof, and the server's checks under Show the receipts.
Next, a real job. Gating your app on membership of one space is one request
and one careful reading of its 404 — see
Gate on membership.
When it does not work
Troubleshooting is the page for a failure that is not
on this list — a 401 you cannot place, an origin-binding verdict that is not
bound, a sign-in that returns no credential. What follows are the
app-description checks in recipe order.
Every app-description failure reaches your user as the same refusal, naming your
domain and saying it does not serve a valid dfos-app.json — which way it
failed is deliberately not distinguished on the wire. Check, in this order:
- Does the document fetch?
https, no redirect, under 256KB, inside a five-second budget. Run the explorer's domain lookup from step 3. - Is your exact redirect target in
redirect_uris? The allowlist is exact-match, trailing slash included. Preview-deploy hostnames are not in it, by design. - Does every other entry conform too? The allowlist is all-or-nothing. One
entry that is not
https, or is on a different host, or carries a port invalidates the whole document — the conforming entries alongside it stop working, and so does sign-in. The refusal deliberately does not name the offending entry, so read the list end to end rather than checking only the target you are using. Anhttp://localhost:3000left over from development is the usual culprit; loopback belongs to a different tier, not to this list. - Does the carried chain derive
client_did? A chain that folds into a different identity is refused, as is a chain with noclient_didat all. - Is
client_didpresent at all? Optional forscope=identityalone; required the moment your set contains any credential scope.
An app is never refused for being unknown to DFOS. Being unknown is the ordinary state, and there is no list whose absence counts against you — which tier you resolve on changes what the consent screen says and nothing else: same scopes, same credentials, same API access. The one refusal that turns on who you are is an explicit platform denial, which says so in its own words and is not something the document can fix. See Two tiers, and the tier is only about trust.
Next
- The dfos-app.json document — every member, every rule, and what publishing your identity chain gets you.
- Scopes and credentials — how a credential lives, how you spend it, and how it ends.
- Why sign every request — proof-of-possession, and what a stolen credential or a captured proof actually gets an attacker.
- Gate on membership — the most common job a credential does, in one request.
- Local applications — this recipe's counterpart for a CLI, an agent, or a desktop app, which serves no document at all.
- Origin binding — steps 4 and 5 in full: both publication methods, all four verdicts, and the fallback rule.
- Key custody — where your keys live, what losing one costs, and the deployment recipe behind step 2.
- Troubleshooting — every failure in this flow, and how to diagnose it locally.
- Glossary — the vocabulary these pages share, defined once.
- The siwd-demo source — the whole recipe, running, with its README as the long-form commentary.