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 hold the details; this page 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.
This recipe needs v0.42.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. 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.
Your server will need a second auth key whose seed you can read, so a
compromised deployment is a revoke-and-re-add rather than a new identity. The
CLI adds a key only when it sits in the same keystore as the controller key, and
only the file store hands a seed back. So keep DFOS_NO_KEYCHAIN=1 set for every
command in this step, and for any later command that signs for this identity:
# keys and the vault phrase go to plaintext files under ~/.dfos/
export DFOS_NO_KEYCHAIN=1
# the seed this identity's keys derive from — 24 words, printed once
dfos vault create app
# one key, declared as controller, auth, and assert
dfos identity create --name my-app --vault app
# the server's auth key: prints its id and public Multikey
dfos identity device-pubkey --as my-app
# declare it in the auth set, signed by the controller key in the same store
dfos identity add-key --as my-app --auth-key --pubkey z6Mk<from above>
Write the 24 words down offline: they are the backup, and dfos vault import
plus dfos recover rebuild this identity's keys from them on a fresh machine.
Skip the vault — --no-vault, or an identity create on a machine that holds
none — and the key is standalone, covered by no phrase, and losing it freezes
the identity permanently. Key custody is the page to read
before you go further; it covers the plaintext file store, and this is the one
irreversible decision in this recipe.
Getting the server's seed 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 status should report the file backend.
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.
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:r7z9c4kfhne2t38va6d9kn2ch7f4c6a",
"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 or one bad redirect_uris entry
refuses the whole thing. The dfos-app.json document has the
member table and the list of things that make a fetch fail.
4. Bind your domain
An origin binding makes your control of the domain a standing, independently checkable fact: your identity's chain claims the domain, and the domain attests the DID back.
It is also the one step that visibly upgrades your consent screen. Once the
chain you serve claims the domain and your dfos-app.json names the DID, a
bound app's caveat row is replaced by two confirmations —
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 what the domain
must serve: a /.well-known/dfos-did file or a _dfos TXT record. Your app
description from step 3 is already the domain's half for the consent screen,
and other verifiers fall back to it when no dfos-did file answers. Publish a
dfos-did file or TXT record as well if you want the binding to stand on its
own.
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 you serve
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.
Both publication methods, the flags, and the exact fallback rule are 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
You want bound. Each outcome maps to an exit code, and stale is not
broken — Origin binding explains
each one.
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.
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 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 with no client_did resolves as anonymous loopback,
which is identity-only, so this check runs on your dev server as-is.
7. Ask for the scopes you need
scope is a space-separated set, the OAuth convention. identity issues
nothing; every other scope issues a credential. The registered scopes, what each
one grants, and the spaces parameter that says where a space-level scope
applies are all in
Scopes and credentials. To say where, pass
spaces: 'all' or an array of space ids to createSiwdLoginRequest; leave it
out and the user picks their spaces at the consent screen.
Each scope 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 — asking for
read:profile read:email gets you an action list of
read:profile,read:email. 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 the widest asks are read:memberships, which enumerates
private and unlisted spaces, and any space-level scope over spaces=all, which
keeps covering spaces the user joins later.
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.- Post routes — under a space-level grant, read posts
and comments as the granting user sees them, and write, edit, and delete their
own posts and comments.
GET /v1/feedis their cross-space feed. Writing one is walked end to end in Post a comment.
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 — why sign every request.
npm install @metalabel/dfos-api @metalabel/dfos-client \
@metalabel/dfos-protocol @metalabel/dfos-web-relay
The snippet below assumes @metalabel/dfos-api@^0.14.0 and
@metalabel/dfos-client@^0.54.0; the last two are dfos-client's peer
dependencies, at the matching ^0.54.0, so the crypto kernel and the relay
transport are installed once rather than shipped twice. Writes need
dfos-client at 0.54.0 or newer: from that release createApiAuthFetch
attaches a fresh jti to every request except GET, HEAD, and OPTIONS,
which every write requires.
@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
If the consent screen refuses your app, work down The dfos-app.json document § When a fetch fails in order, with the explorer's domain lookup from step 3 open beside it. An app is never refused for being unknown to DFOS; the one refusal that turns on who you are says so in its own words.
Troubleshooting covers everything else — a 401 you
cannot place, an origin-binding verdict that is not bound, a sign-in that
returns no credential.
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.