Skip to main content

The dfos-app.json document

To use Sign in with DFOS, serve a small JSON document at https://yourdomain/.well-known/dfos-app.json. DFOS fetches it live when someone reaches the consent screen, and there is nothing to file.

A successful fetch says "this domain asserts this name, these redirect targets, and — if it has one — this DID, right now." A change takes effect shortly after you publish it.

{
"name": "Field Notes Reader",
"client_did": "did:dfos:r7z9c4kfhne2t38va6d9kn2ch7f4c6a",
"redirect_uris": ["https://yourdomain/callback"],
"identity_chain": ["<genesis JWS>", "<next op JWS>"]
}

The document is strict: an unrecognized member is a refusal, not a forward-compatible extension. Validate it against the JSON Schema at https://schemas.dfos.com/dfos-app/v1, and serve it with Access-Control-Allow-Origin: * so browser-based tools can read it too.

Members​

MemberRequiredWhat it is
redirect_urisyes1–10 https URLs, each on this exact domain.
namenoDisplay name, up to 64 characters. Self-asserted.
client_didonly for credentialsYour app's own protocol DID.
identity_chainnoYour app identity's full signed operation log, genesis first.

name​

Optional, and not shown on the consent screen. Your domain is your identity here — it is what the redirect allowlist, the challenge binding, and the consent heading all key on. A name is something you assert about yourself and nobody has checked, so the consent screen does not render it at all, on either tier. Under the domain it links to the protocol explorer's page for your app's identity instead, once that identity is proven by an origin binding or a credential issued to it.

A document that asserts no name is not a degraded document; the consent screen looks the same either way.

Serve one anyway if it helps elsewhere: it is validated like every other member, checked for the characters that could make one app's name render as another's, and it stays on the resolved document for other readers of it. An empty string is a malformed value rather than a way to spell "no name" — leave the member out instead.

redirect_uris​

Every entry must be https, must be on the very domain serving the document, and must carry no port and no userinfo. A redirect target naming a foreign host is refused rather than warned about. Up to 10 entries, each up to 2048 characters.

The redirect_uri your app sends to the consent screen must be one of these, and the domain in your challenge must match it too.

client_did​

Your app's own protocol DID — an identity you create with the dfos CLI or the protocol library. Set up § 2 is the recipe for minting one, and § 3 writes the DID into this file for you rather than leaving you to copy it across.

It is optional when your requested scope set is identity alone, because that scope issues nothing to your app. It is required as soon as the set contains any scope that issues a credential — a credential is addressed to someone, so there is no issuing one without knowing who. See Scopes and credentials.

identity_chain​

Optionally, publish your app identity's own signed operation log: an array of JWS strings, genesis first, in order, up to 100 operations. A real app chain is a genesis plus a handful of key rotations. The chain must derive the client_did beside it, so a chain with no client_did, or one that folds into a different identity, is refused. The carriage format is normative in the specification, and derivation in the DFOS DID method specification.

Do not assemble the array by hand. dfos identity well-known --patch writes client_did and the genesis-first identity_chain into this file while preserving name and redirect_uris, so the two can never drift from the chain you actually hold. Re-run it and redeploy after anything that signs a new operation.

Serving a chain is optional. Everything in Sign in with DFOS works without one.

Publishing your chain gives it a home​

When your document carries a valid identity_chain, your app's DID resolves at relay.dfos.com alongside everyone else's. Anyone verifying a signature your app made can look it up in the ordinary way, at the ordinary place. There is no submission step: someone signing in to your app is enough.

  • Your document stays the source of truth. DFOS never signs on behalf of your identity and never mints keys for it.
  • A new operation is picked up at the next sign-in that issues your app a credential, or periodically on its own. For a key change that has to count right away, follow Key custody § Rotating after a compromise.
  • Each chain you serve must extend the one DFOS already holds. Serving an older copy, such as an old deploy after a rotation, or a chain that diverges from it is refused at the consent screen.
  • Taking your document down does not remove the chain DFOS already holds. Your DID keeps resolving at the relay.

If the DID your chain derives is already an identity DFOS holds keys for, the document is refused rather than merged. You cannot claim someone else's identity by publishing a document that names it.

What any consumer may do with a chain it encounters, and the rules that make first encounter safe, are normative in Carried identity chains.

The other well-known document​

/.well-known/dfos-app.json is your app description — it is what makes your app usable for sign-in. There is a second DFOS document under the same namespace that answers a different question:

DocumentQuestion it answers
/.well-known/dfos-app.jsonWhat app is this domain running, and where may it be redirected?
/.well-known/dfos-didWhich DFOS identity does this domain attest?

The second is the domain attestation half of an origin binding: plain text, containing exactly the DID and nothing else. A _dfos.<domain> TXT record carrying did=did:dfos:<id> does the same job. A valid app description whose client_did names the identity also counts as that attestation — Origin binding has the exact rule. The two documents can hold different verdicts at the same time.

When a fetch fails​

Every document 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? It must be served over https by the domain it speaks for, with no redirect, under 256KB, inside a five-second budget for the whole exchange.
  2. Is your exact redirect target in redirect_uris? The allowlist is exact-match, trailing slash included. Preview-deploy hostnames are not in it.
  3. Does every other entry conform too? One entry that is not https, is on a different host, or carries a port invalidates the whole document, and the refusal does not name it. An http://localhost:3000 left over from development is the usual culprit; loopback belongs to a different tier, not to this list.
  4. Is every member recognized, and is redirect_uris present?
  5. Does the carried chain derive client_did? A chain with no client_did beside it is refused too, as is one past 100 operations.
  6. Is client_did present? Optional for scope=identity alone; required the moment your set contains any credential scope.

One chain failure names itself instead: when the chain you serve is older than, or diverges from, the one DFOS already holds for your client_did, the refusal says so. Re-patch on the controller-key machine and redeploy — Troubleshooting has the causes.

To see what DFOS sees, open your origin in the explorer at https://explore.dfos.com/#/domain/yourdomain.example. Troubleshooting covers the rest of the flow's failures.