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

MethodWhat to publish
HTTPShttps://<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 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 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:

ScopeWhat approving it grants
read:profileName, handle, and avatar.
read:emailThe account email address.
read:membershipsSee 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/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.

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:

  1. Does the document fetch? https, no redirect, under 256KB, inside a five-second budget. Run the explorer's domain lookup from step 3.
  2. 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.
  3. 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. An http://localhost:3000 left over from development is the usual culprit; loopback belongs to a different tier, not to this list.
  4. Does the carried chain derive client_did? A chain that folds into a different identity is refused, as is a chain with no client_did at all.
  5. Is client_did present at all? Optional for scope=identity alone; 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.