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; together they prove one party controls both, with no DFOS server in the loop.
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. Profile
links and verified-domain badges show the bound domain with a seal beside it;
the consent screen names its verdicts instead — Domain verified, Identity current. 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.
The consent screen reads both halves from what your app already serves: the
DfosOrigin entry on the chain, and your dfos-app.json naming the same
client_did. When both are there, the limit row is replaced by two
confirmations:
✓ Domain verified ✓ Identity current
Nothing is added anywhere: no badge, no mark beside the name, and the domain
still leads the card. A dfos-did file or _dfos TXT record is not what the
consent screen reads; those serve the CLI, the explorer, and any other verifier.
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: * so browser-based tools can read it.
A verifier queries both methods. 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 dfos-app.json whose client_did
names this DID, you may not need a dfos-did file at all. A verifier falls back
to the app description when /.well-known/dfos-did gives no answer: a 404, a
redirect, or a body that is not exactly one DFOS DID, such as an app shell served
for every unknown path. It never overrides a dfos-did that names a different
DID, and it does not apply when the request fails outright. When --json output
reports fallbackUsed: true, the app description supplied the HTTPS half. The
exact rule is in the
specification.
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 outcome: the specification's
three verdicts, plus no-claim for an identity that claims no domain.
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 outcomes 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, a redirect at the well-known path, NXDOMAIN) |
no-claim | 0 | The chain claims no domain, so there is nothing to verify |
no-claim exits 0 because nothing failed. 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
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 you serve carries the domain claim the consent screen looks for. 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.