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
| Member | Required | What it is |
|---|---|---|
redirect_uris | yes | 1–10 https URLs, each on this exact domain. |
name | no | Display name, up to 64 characters. Self-asserted. |
client_did | only for credentials | Your app's own protocol DID. |
identity_chain | no | Your 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:
| Document | Question it answers |
|---|---|
/.well-known/dfos-app.json | What app is this domain running, and where may it be redirected? |
/.well-known/dfos-did | Which 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:
- Does the document fetch? It must be served over
httpsby the domain it speaks for, with no redirect, under 256KB, inside a five-second budget for the whole exchange. - Is your exact redirect target in
redirect_uris? The allowlist is exact-match, trailing slash included. Preview-deploy hostnames are not in it. - 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. Anhttp://localhost:3000left over from development is the usual culprit; loopback belongs to a different tier, not to this list. - Is every member recognized, and is
redirect_urispresent? - Does the carried chain derive
client_did? A chain with noclient_didbeside it is refused too, as is one past 100 operations. - Is
client_didpresent? Optional forscope=identityalone; 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.