Skip to main content

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 did query 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.replaceState as 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 no Referer header — 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/memberships and GET /v1/group-memberships, or ask about one directly with GET /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/feed is 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.