Origin binding
An origin binding ties a protocol identity to a domain in a way anyone can check without asking DFOS. It is two halves that have to agree:
- The chain claim. Your identity's signed operation log carries a
DfosOriginservices entry naming one domain. Only a key that controls the identity can put it there. - The domain attestation. The domain publishes your DID back, at a location only someone who controls the domain can write to.
Either half alone is a claim anyone could make — you can name any domain in your own chain, and a domain can name any DID. Together they prove one party controls both, with no DFOS server in the loop and nothing to trust but the two things you fetched yourself.
This is protocol-level. Any identity can bind a domain; a Sign in with DFOS app is just the case where it matters most, because the domain is what the consent screen leads with. The normative rules are in the Origin Binding specification.
This page is the CLI lane, for an identity whose keys you hold yourself. A DFOS account or a space binds a domain from inside the app instead, with no keys to handle and no CLI to install — see Verified domains.
What a binding proves is control of a domain at verification time. Not personhood, not endorsement, not that the app behind it is any good. That is why every DFOS surface renders the bound domain itself rather than a checkmark — the domain is the fact, and the reader judges it.
What a binding buys at the consent screen
An unbound app on the just-in-time tier gets two rows: {domain} serves this
app's sign-in, and the limit Identity not yet bound to the domain. That limit
row is affirmatively true rather than merely unchecked — the resolver looked, and
an identity claiming no domain claims no binding.
Bind the domain and both halves are checked live at consent time, and the limit row is replaced by two confirmations:
✓ Domain verified ✓ Identity current
Two words each, and neither repeats the domain — it is the heading those rows sit directly under, which is what keeps the verdict attached to it. Nothing is added anywhere: no badge, no mark beside the name, and the domain still leads the card. The stronger tier is the one with fewer caveats, not the one with more chrome.
This costs DFOS no extra fetch. Reaching the JIT tier already means your
dfos-app.json was fetched over https from this domain and its client_did
matched the resolved audience — which is the app-description attest-back below,
performed live — and the chain half is the DfosOrigin entry on the chain that
document carried. The binding is exact: a binding to example.org says
nothing about sub.example.org.
Claiming a domain
dfos identity bind-domain yourdomain.example
The argument is a bare lowercase hostname — no scheme, no port, no path;
internationalized names in A-label (Punycode) form. The command appends or
replaces the DfosOrigin services entry, carries every other service entry
forward unchanged, and signs nothing if you re-run it with the same domain. An
identity claims at most one domain.
bind-domain signs a new chain operation, so it must run on the machine holding
a controller key — see Key custody. An auth key cannot
sign it.
| Flag | What it does |
|---|---|
--id <id> | Service entry id. Defaults to the existing entry's id, else origin. |
--peer <name> | Push the signed operation to that relay immediately. |
--json | Machine-readable output. |
Attesting from the domain
The domain has to answer with the DID. Either method suffices — publish whichever your hosting allows:
| Method | What to publish |
|---|---|
| HTTPS | https://<domain>/.well-known/dfos-did containing exactly the DID, plain text |
| DNS | _dfos.<domain>. TXT "did=did:dfos:<id>" |
Serve the well-known document as text/plain with
Access-Control-Allow-Origin: *. It is public by construction, and the header's
absence only stops browser-based tooling from reading what every other client
already can.
Either suffices, but both must agree. A verifier queries both. One answering
and one silent is fine. Two answering with different DIDs is a contradiction, and
so is a single DNS name carrying more than one did= record.
The app description is an HTTPS fallback
If you already serve a Sign in with DFOS app description — the
dfos-app.json document — you may not need a dfos-did
file at all. A valid app description whose client_did names this DID attests
it too, and verification falls back to it when
/.well-known/dfos-did is absent (a 404).
The fallback applies to absence only. A dfos-did document that is present but
names something else is a contradiction, not a miss — the fallback never rescues
a wrong answer. When --json output reports fallbackUsed: true, the app
description is what supplied the HTTPS half.
The two well-known documents are separate systems that happen to share a
namespace: the app description (dfos-app.json) is what makes an app usable
for sign-in, and the domain attestation (dfos-did or the _dfos TXT
record) answers the binding question. They answer different questions and can hold
different verdicts at the same time.
The four verdicts
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
The verdicts map to exit codes so a script can branch without parsing output:
| Verdict | Exit | Meaning |
|---|---|---|
bound | 0 | At least one method attests this DID, and no method answers anything else |
broken | 1 | A method answers a different DID, the methods disagree, or DNS carries multiple did= records |
stale | 2 | A claim exists and every method is silent (network, TLS, timeout, 404, NXDOMAIN) |
no-claim | 0 | The chain claims no domain, so there is nothing to verify |
no-claim exits 0 because nothing failed: an identity that never claimed a
domain is not a broken binding, it is an identity with no binding. Exit 1 is
also the CLI's generic error status — an unresolvable target, a chain you do not
hold locally, malformed input. Those print to stderr instead of a verdict.
stale is not broken
This is the distinction the whole verdict set exists to preserve. Silence is never contradiction:
stalemeans could not check. Hosting and DNS fail and recover routinely, a certificate lapses for an afternoon, a resolver times out. The claim still stands; nobody answered.brokenmeans checked and contradicted. Something answered, and it answered with a different DID.
Do not treat a hosting blip as a hijack. A tool rendering these should say when the domain was last confirmed rather than flipping to red the moment a fetch fails.
The full assessment
--json returns everything the verdict was folded from, so you can see which
method answered what:
{
"did": "did:dfos:8zk83zez862n6ahnvt3h3e4kc4n2dke",
"domain": "dfos-siwd-demo.vercel.app",
"verdict": "bound",
"methods": {
"https": { "answered": true, "source": "…", "detail": "…", "did": "did:dfos:…" },
"dns": { "answered": false, "source": "…", "detail": "…" }
},
"fallbackUsed": true
}
Seeing it in the explorer
explore.dfos.com renders the same verdict on an identity's page — the bound domain, one row per method, and what each one actually answered. It recomputes everything in your own browser from what a relay serves, so it is a second implementation of the check rather than a report of ours.
The demo app's identity is
explore.dfos.com/#/did/did:dfos:8zk83zez862n6ahnvt3h3e4kc4n2dke,
reading bound against dfos-siwd-demo.vercel.app. Its app description is a
separate lookup —
explore.dfos.com/#/domain/dfos-siwd-demo.vercel.app
— which fetches dfos-app.json, checks that the carried chain derives the
declared client_did, and compares it against what the relay holds. Two pages,
two questions.
Re-patch after you bind
bind-domain appends a chain operation, so any identity_chain you already
carried in your app description predates it. Re-run
dfos identity well-known my-app --patch public/.well-known/dfos-app.json
and redeploy, so the chain DFOS ingests carries the domain claim. This runs on the controller-key machine too.
Next
- Set up Sign in with DFOS — steps 4 and 5 of the recipe, in order, with the rest of the setup around them.
- Key custody — which key signs a binding, where it lives, and what losing it costs.
- Troubleshooting — what to do about a
staleorbrokenverdict. - The dfos-app.json document — the app description, its members, and how it doubles as the HTTPS fallback.