Skip to main content

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 DfosOrigin services 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.

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.

FlagWhat 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.
--jsonMachine-readable output.

Attesting from the domain

The domain has to answer with the DID. Either method suffices — publish 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>"

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:

VerdictExitMeaning
bound0At least one method attests this DID, and no method answers anything else
broken1A method answers a different DID, the methods disagree, or DNS carries multiple did= records
stale2A claim exists and every method is silent (network, TLS, timeout, 404, NXDOMAIN)
no-claim0The 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:

  • stale means 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.
  • broken means 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