DFOS Integrations
This document is for a site or an application integrating with DFOS. It specifies four surfaces: sign in, where an application verifies who a user is; API authentication, where a party spends a grant against an HTTP API; origin binding, where an identity and a web domain name each other; and key ceremonies, where a key that lives on a human's own device joins an identity chain.
Everything here builds on PROTOCOL and adds no chain rules to it. Authorship is verifiable without trusting any server. Which view of an identity you follow is a choice of relay.
Time basis. Every temporal check resolves against a basis time, defined once
in PROTOCOL, Time basis. Every
artifact in this document is an ephemeral presentation, so its basis is now:
the signing key MUST be effective in the identity's state at the chain head as
the resolving relay serves it, an exp MUST be strictly greater than now, and no
revocation effective now may cover it. A key rotated out signs nothing new.
JWS profile. Every JWS here is verified under
PROTOCOL, Signature verification profile:
alg is exactly EdDSA, a crit member rejects, an embedded header key
(jwk, x5c, or any key-bearing member) rejects, and the signature check
applies the canonical-scalar gate (S < L) and the 64-byte length check. Each
family below gates on its own exact typ, and that check is normative: it is
what keeps a JWS signed for one purpose from being presented as another. Every
typ value and service type used here is registered in
PROTOCOL, Extension registry.
Sign in
Sign In With DFOS (SIWD) lets any third-party application verify a user's DFOS identity. The third party produces a challenge; the user's key signs it; the third party verifies the signature against the user's identity chain, resolved from a relay, without contacting any DFOS platform server.
The artifact is always the same: the canonical challenge bytes, signed as a JWS
with typ: "did:dfos:siwd". What varies is the courier, how the challenge
reaches a key and the signature comes back:
| Profile | Transport | Latency | Signer |
|---|---|---|---|
| A: web redirect | Browser redirect to a hosted /authorize |
Interactive (seconds) | The hosting platform's custodial key, or any signer the host integrates |
| B: sign-request mailbox | Signing mailbox deposit, poll, respond | Asynchronous (minutes to days) | The subject's signing agent, polling the subject's mailbox |
A verifier cannot tell which courier delivered the artifact, and does not need to. Profile A is the hosted authorize path: it works against custodial identities, and it is where a relationship is established and where credential-returning scopes issue their credentials. Profile B carries the same artifact asynchronously once a deposit grant exists.
Challenge schema
The challenge is a JSON object:
{
"domain": "3p.com",
"nonce": "a8f2e93b...",
"timestamp": "2026-04-13T15:30:00.000Z",
"statement": "Sign in to 3P App",
"did": "did:dfos:<id>"
}| Field | Required | Description |
|---|---|---|
domain |
Yes | The requesting application's domain: a bare lowercase hostname, no scheme or port. Under profile A it MUST match the hostname of redirect_uri. |
nonce |
Yes | Unique value generated by the third party, used for replay prevention. |
timestamp |
Yes | Timestamp of challenge creation, in the timestamp grammar, floor-normalized to whole seconds (.000Z millisecond component). |
statement |
No | Human-readable description a signer MAY show the user; a signer is never obliged to render it. Requester-controlled text, see Rendering the statement. |
did |
No | If provided, binds the challenge to a specific DID. A signer MUST refuse to sign if its own DID does not match. |
The schema is Sign-In with Ethereum's
(EIP-4361, final) field set with address replaced by did and the chain fields
dropped, so its rationales travel with its fields: domain is SIWE's phishing
binding, nonce its replay defense.
CAIP-122 generalizes the same shape
to Sign-in-with-X. Three divergences are deliberate: a DID is a self-certifying
identifier, not a CAIP-10 account, so
there is no chain-id; there is no version member, because the envelope's
typ scopes the artifact; and SIWE's uri has no slot, because its job is done
by redirect URI validation and the domain match
rather than by a signed member.
Timestamps are whole-second. Producers MUST normalize timestamp to the
.000Z form (floor, never round). A strict signer validating this family rejects
a sub-second timestamp as a schema violation.
The challenge is signed as a JWS using a key from the user's identity chain. The
JWS protected header MUST set typ: "did:dfos:siwd", and verifiers MUST reject
any other typ. The JWS kid header contains the DID URL of the signing key
(did:dfos:<id>#<keyId>).
Challenge canonical signing input
The signer and the verifier MUST agree on the exact bytes that are signed. The canonical signing input is the challenge object serialized as minimal UTF-8 JSON (no insignificant whitespace) with its members in this fixed order, omitting any absent optional member:
domain, nonce, timestamp, statement?, did?These bytes are simultaneously the JWS payload segment, the body base64url-encoded
into profile A's challenge query parameter, and the octets a profile B
sign-request carries in its payload field, so a holder of any encoding can
confirm they describe the same object. Reference implementations of the byte
contract are siwdSigningInput(challenge) in
@metalabel/dfos-client/siwd
and its byte-twin in dfos-protocol-go.
Profile A: web redirect
The third party redirects the user's browser to a hosted /authorize endpoint;
consent and signing happen there; the signed artifact returns by redirect.
Finding the authorize endpoint
{ id: string, type: "DfosAuthorizationServer", endpoint: string } // endpoint: bare URL stringThe entry is a service entry under the
core's open type namespace, registered by this document. To a core verifier it is
an unrecognized type: preserved verbatim, never structurally validated.
Structural validation is a consumer obligation here. endpoint is the
canonical authorize origin URL, the base URL at which a platform able to
produce this subject's signature serves this profile's /authorize surface.
A client that already holds the subject's DID SHOULD resolve the subject's
identity chain and take the redirect target of step 1 below from the chain's
DfosAuthorizationServer entry, falling back to its configured or out-of-band
host when the entry is absent. Absence is ordinary and fully legal. One entry,
or none: a services set carrying more than one DfosAuthorizationServer entry
names no discoverable endpoint, and a consumer MUST fall back exactly as if the
entry were absent, so ambiguity degrades to the fallback and never to a choice.
An entry whose endpoint is missing or empty is ignored the same way.
Non-normative: a platform MAY additionally serve RFC 8414-shaped authorization-server metadata at the named endpoint; nothing here reads it.
1. Redirect to authorize
https://app.dfos.com/authorize?
challenge=<base64url-encoded canonical challenge bytes>
&redirect_uri=https://3p.com/callback
&scope=identity
&client_did=did:dfos:<3p id>| Parameter | Required | Description |
|---|---|---|
challenge |
Yes | Base64url of the canonical challenge bytes (see Challenge schema) |
redirect_uri |
Yes | URL the host redirects to after signing |
scope |
Yes | A space-separated set of requested scope tokens (the OAuth scope convention); each token MUST be a registered scope, see Scopes and credentials |
client_did |
When any requested scope returns a credential | The third party's own DFOS DID, the aud any returned credential is issued to |
spaces |
Optional, with a space-level scope | Where the space-level tokens apply: all, or a comma-separated list of distinct 31-character space ids; see Scopes and credentials |
client_proof |
Loopback credential tier | The client's ask proof, a JWS over this request's own canonical challenge bytes under typ: "did:dfos:siwd-ask" |
client_chain |
Loopback credential tier, unless resident | The client identity's carried chain, base64url of the JSON array of its verbatim operation JWS strings, genesis first |
client_did is what makes a credential-returning scope well-posed: the
credential is issued to a named DID. The host MUST display client_did (or an
identity resolved from it) alongside domain on the consent screen. domain,
not client_did, is the phishing-relevant binding, because nothing here proves
that a DID controls a domain; client_did determines only who can exercise a
returned credential, which the credential machinery enforces cryptographically.
2. Consent
The host authenticates the user (existing session) and presents a consent screen
describing what the third party requests: identity verification alone, or
identity plus a scoped credential. The statement, if present, MAY be rendered;
a host that renders it MUST do so under the
rendering rule. Declining to render
requester-controlled prose is a legitimate host posture. When the request names
a space-level scope without spaces, the consent screen is where the user
chooses the places, and consent MAY narrow that set; the token set is never
narrowed.
3. Signing
A key from the user's identity chain signs the canonical challenge bytes. For a custodially-hosted identity, the host signs with the user's custodial key after consent. What is normative is that the signature is produced by a key effective in the identity's state and verifies under the Signature verification profile; the custody mechanics are the host's. A host MAY integrate other signers behind the same consent screen.
4. Callback
https://3p.com/callback?
jws=<signed challenge JWS>
&did=did:dfos:<id>If a credential was requested via scope, it is delivered in the URL
fragment, not the query string:
#credential=<DFOS credential JWS>The fragment never leaves the browser: it does not appear in server access logs,
proxy logs, or the Referer header, so the grant does not land anywhere the
redirect target's infrastructure would passively record. The receiving page's
script reads it and forwards it to the application's backend over an intentional
channel, typically a POST. A loopback application's local listener is the same
shape: the fragment never appears in the HTTP request line its server receives,
so the listener serves the receiving page and the page's script relays the full
URL back over the local channel. The credential is proof-of-possession bound (see
API authentication) and is inert without the audience's
key, so the fragment protects grant metadata, not the grant itself. The jws and
did parameters remain in the query string; they are consumed server-side at the
callback endpoint, which is exactly where a fragment could never reach.
Loopback redirect targets
A redirect_uri on the loopback interface (http://localhost,
http://127.0.0.1, or http://[::1], on any port and any path) is a valid
target: the loopback rule of
RFC 8252 §7.3, adopted with
its reasoning that a local application cannot reserve a port, so any port is
legal and the port is never part of the comparison. An application running on the
user's own machine holds no domain, so the host consents to it under its own tier
and says plainly what it is: software running on this device. At
scope=identity the consent screen shows the literal delivery target and offers
no application name and no client_did, because neither is proven. A
client_did on a loopback authorize request is honored only under the
loopback credential tier, where the request itself proves
control of that identity's keys, and is refused outright otherwise. The port is
not part of the binding either, so the challenge's domain is the bare loopback
host, an IPv6 literal unbracketed (::1, never [::1], because the brackets are
URL grammar rather than part of the name). The hosted /authorize path is where
the signature is produced: the callback may go to a local port, but the user's
key never does, and an application that needs only the user's identity does not
need to hold a key to receive a proof.
Redirect URI validation
The host MUST establish that the redirect_uri belongs to the party named by
domain before redirecting to it, and MUST refuse any target it cannot establish
that for. An open redirector lets an attacker substitute their own callback URL
to capture signed challenges. Exactly two admission paths satisfy the obligation,
and the target itself decides which one applies:
- A web origin is admitted by its own well-known document. A
redirect_urion any non-loopback origin MUST be validated by fetching the app description document (/.well-known/dfos-app.json) from the redirect's own origin and matching the target against itsredirect_urisallowlist. There is no out-of-band path: a host MUST NOT admit a web-origin target on any basis other than the origin's own served document. - A loopback target is admitted as local software. A
loopback target asserts no domain at all and is
consented to as software running on the user's device. At
scope=identityit needs no backing; a credential-returning scope is honored only under the loopback credential tier.
The split is exclusive in both directions, and the exclusivity is normative. A
host MUST refuse client_proof and client_chain on a request whose
redirect_uri names a web origin: an application with an origin proves itself by
serving its document. A host MUST NOT use any app description document to back a
loopback request's client_did: the ask proof is the only thing that backs it.
Outside the loopback case the domain field in the challenge MUST match the
domain of the redirect_uri, and the host MUST reject requests where these
diverge. The host redirects only to the literal redirect_uri the ask named,
port included.
Under profile B there is no redirect and no redirect_uri. The domain binding
lives entirely inside the signed challenge, and the signer's obligation to render
domain prominently is the corresponding control.
Profile B: sign-request mailbox
The third party wraps the canonical challenge bytes in a
sign-request envelope
(payloadTyp: "did:dfos:siwd"), deposits it in the subject's relay signing
mailbox, and polls for the response.
Flow
- Compose. The third party, as envelope requester signing with its own
current key, builds a sign-request:
subject= the user's DID,payloadTyp="did:dfos:siwd",payload= base64url of the canonical challenge bytes. - Deposit.
POST /signing/v0/requestsat the subject's relay, authorized by amailbox:<subject id>/depositcredential, obtained at a prior profile-A consent via thedepositscope or from a published open grant. - Sign. The subject's signing agent polls, applies the signer obligations, and responds with the signed artifact.
- Collect. The third party polls
GET /signing/v0/requests/{cid}/responseand receives the same JWS a profile-A callback would have delivered. Verification is identical.
Discovery under this profile is identity-first: the deposit target comes from the
subject's own chain, its DfosRelay services entries, never from configuration.
The one-clock rule
A sign-request carrying a SIWD challenge MUST set its expiresAt no later than
the moment the composer's own verifier would reject the challenge's timestamp
as stale. The envelope may not outlive the semantics of its payload. A composer
using this profile sizes its challenge-acceptance window to the asynchronous
latency it intends to allow, up to the envelope family's 7-day ceiling, and sets
expiresAt to match. The redirect profile's conventional 5-minute window is a
property of that profile's interactivity, not of SIWD.
Signer obligations for this family
A signer handling payloadTyp: "did:dfos:siwd" applies the full
signer obligations with these family
specifics:
- Strict schema. The decoded payload MUST parse as a challenge object with no
unknown members,
timestampin whole-second.000Zform, and, whendidis present,didequal to the signer's own DID (refuse otherwise, before signing). This is the challenge binding: it keeps an attacker from substituting a different user's identity into a challenge intended for a specific user. - Re-canonicalize and byte-compare per the standard rule; the canonical form is the challenge canonical signing input above.
- Render
domainprominently. The domain is what the user is authenticating to; it is the primary phishing-relevant fact and MUST be shown. - Render the statement as untrusted text, see below.
Rendering the statement
SIWD is the one envelope-family payload that carries requester-controlled prose. A signer that renders the statement MUST render it visibly delimited as third-party-supplied text (quoted, boxed, or otherwise set off) and MUST NOT interleave it with the signer's own UI chrome, instructions, or security indicators. A signer MAY decline to render it at all. What is forbidden is the middle ground, showing requester prose as though the signer said it.
The signing agent
A subject with no self-held key cannot poll a mailbox, because poll authorization is key possession. A custodial platform holding a subject's keys MAY act as the subject's signing agent: it reads asks addressed to its custodial users, applies the signer obligations above in its own product surface, and signs with the custodial key on the user's approval. The obligations do not weaken. A subject who later enrolls a device key takes over polling with no change visible to any requester.
The mailbox is poll-only; there is no push in the protocol. A platform that operates both a relay and its users' signing agent can close the latency gap out-of-band by notifying its agent to poll. That is an operational nicety, not protocol surface.
Verifying a sign-in
Verification is identical regardless of courier, and the JWS signature MUST be checked under the Signature verification profile.
Decode the JWS. Extract the challenge payload,
kidheader (DID URL of signing key), and signature. Before any signature work, apply the header gates: the protected headertypMUST equal the exact string"did:dfos:siwd"; the protected headeralgMUST equal the exact string"EdDSA"; acritmember MUST cause rejection; and an embedded header key (jwk,x5c, or any key-bearing member) MUST cause rejection. The key is never read from the header.Resolve the DID. Fetch the identity chain from a DFOS relay and replay it to the chain head, then extract the public key matching the
kidfrom the effectiveauthKeysat that head. The basis is now, per Time basis: a key rotated out MUST NOT verify, and a deleted identity MUST NOT verify. The protocol has no identity-revocation primitive; deletion is the condition a verifier checks, and an identity later restored to active verifies again. Credential-scoped revocation applies to the optional returned credential, not to the identity.The head is the head as the chosen relay has seen it. A relay that has not ingested the chain's newest operations serves an older head, so a single-relay resolution can accept a key its subject has already rotated out. Following one view is a choice of relay (PROTOCOL, Views). A verifier with higher assurance needs SHOULD resolve from the relays the subject's own identity state lists in its
servicesentries.Verify the signature. Ed25519 verification of the JWS against the resolved public key, with the canonical-scalar gate (
S < L) and the 64-byte length check from the profile.Validate the timestamp. Reject challenges outside the acceptance window the composer chose for the courier in use: conventionally 5 minutes under profile A, and under profile B the window the composer sized when it set the envelope's
expiresAt. The bound runs in both directions: a verifier SHOULD also reject a timestamp in the future beyond small clock skew (the reference implementation allows 60 seconds), because a forward-dated challenge would otherwise outlive every acceptance window.Validate the domain. Confirm the
domainin the challenge equals the verifier's own domain (the bare hostname it serves).Validate the nonce, last. Recover the expected nonce from the verifier's own prior state and confirm the challenge's
noncematches, under whichever replay discipline the grant requires. This step runs after every other check has passed: under the consumed discipline the nonce is spent by verification, and an invalid presentation must never spend a nonce its legitimate holder is still carrying.
No DFOS platform server is contacted during verification. The third party only needs access to a relay to resolve the DID's identity chain.
A DID is an address, not a proof. A bare did:dfos identifier is freely
copyable and freely presentable by anyone who has seen it once, and the callback
hands one to the relying party in the clear. What proves control is the JWS, and
verification of the JWS MUST happen wherever a session is granted. A service
that reads did from a callback, a request body, or a header and mints a session
on the strength of the identifier alone has authenticated no one. A relying party
that splits verification from session-granting across a trust boundary MUST carry
the verification verdict across that boundary, not the DID.
Token lifetime. Signed challenges are short-lived authentication proofs,
redeemable once or once per flow channel under the
replay discipline in force, never bearer tokens. Third
parties SHOULD establish their own session after verification and discard the
JWS. Credentials returned via scope carry an explicit exp; third parties MUST
respect expiration and re-request when expired.
Scopes and credentials
Identity proof is inherent: the signed challenge itself proves DID ownership.
scope is a space-separated set of tokens (the OAuth convention). identity
alone asks for the proof and nothing more, and every other requested token
additionally returns a DFOS credential.
Each token MUST match a registered scope from the table below; a request carrying
an unregistered token MUST be refused whole, never partially honored, because a
consent screen that silently drops tokens misstates what was asked. API tokens
return one credential whose entries the coalescing rule below decides;
tokens naming distinct resource types return one credential each. Consent is to
the set: the consent screen describes every requested token.
| Scope | Level | Returned credential |
|---|---|---|
identity |
None, the signed challenge alone | |
read:<contentId> |
{ "resource": "chain:<contentId>", "action": "read" }, issued to client_did |
|
deposit |
{ "resource": "mailbox:<subject id>", "action": "deposit" }, issued to client_did |
|
read:profile |
account | { "resource": "api:<api host>", "action": "read:profile" }, issued to client_did |
read:email |
account | { "resource": "api:<api host>", "action": "read:email" }, issued to client_did |
read:memberships |
account | { "resource": "api:<api host>", "action": "read:memberships" }, issued to client_did |
read:posts |
space | { "resource": "api:<api host>[/spaces/<id>]", "action": "read:posts" }, issued to client_did |
write:upvotes |
space | { "resource": "api:<api host>[/spaces/<id>]", "action": "write:upvotes" }, issued to client_did |
write:comments |
space | { "resource": "api:<api host>[/spaces/<id>]", "action": "write:comments" }, issued to client_did |
write:posts |
space | { "resource": "api:<api host>[/spaces/<id>]", "action": "write:posts" }, issued to client_did |
SIWD defines no resource grammar of its own. Resource forms, action vocabulary,
and matching rules are the
credential spec's: chain:<contentId>
exact-match; mailbox:<id> / deposit exact-match; api:<host> and
api:<host>/spaces/<id> equal-or-ancestor, with the enumerated action registry
below. Each token is matched against
this table's registered tokens first, and the parameterized read:<contentId>
form matches only when <contentId> is a 31-character content id, so the literal
API tokens are never ambiguous with it.
read:<contentId>. For content owned by the user's DID, the credential is
issued by that DID: iss = the user's DID, aud = client_did, one attenuation
covering the requested chain. For content owned by a space (a separate DID), the
credential is issued by the space's DID, mediated by the host: the user
consents, the host verifies the user's membership and permissions within the
space, then issues from the space's DID. The envelope, signing, CID derivation,
and validity bounds are exactly as the credential spec defines. SIWD adds no
fields and no separate credential format.
deposit. Consenting to this scope issues a mailbox:<subject id> /
deposit credential to client_did, rooted at the subject and signed by the
subject's key, custodially by the host or by the subject's own key under
self-custody. It is how a third party earns the right to use profile B: a first
profile-A authorization with scope=deposit establishes identity and grants
deposit, and from then on the third party never redirects again. Revoking the
deposit credential severs the relationship, because the relay's deposit gate
re-checks revocation on every deposit.
API scopes. An API scope returns an
api: credential for the hosting
platform's own API host: iss = the user's DID, aud = client_did, att
decided by the two dimensions of the ask. scope names the actions, and
spaces names the places the space-level actions apply to. Each wire scope
token maps 1:1 to an API action token, and every API action token registered
below is a SIWD scope the same way, at
the same level. For these scopes client_did is required, and the verifier
MUST apply the consumed replay discipline, because
success yields an artifact redeemable outside the presenting channel. The
credential alone opens nothing: exercising it requires a per-request
request proof signed by the client_did's key.
spaces. all, a comma-separated list of distinct 31-character space ids
(at most 31), or absent. It is well-posed only when scope carries a
space-level token: a request carrying spaces without one, or carrying a
malformed value, is refused whole. Present, the requester has named the places
and the consent screen is locked to them. Absent, the user chooses the places at
consent, and consent MAY narrow the ask to a subset of the spaces the user is
in, or widen it to all of them; the token set is never narrowed. A named space
the user is not in is issued anyway: the entry is inert against live standing,
and a host renders such an id raw, never with a name. all is a standing
choice, since the host form covers every space the subject is in, spaces joined
later included, and the consent screen says so.
Coalescing. Account-level tokens, and space-level tokens granted for all
spaces, coalesce into one api:<host> entry carrying the combined action
list. Each chosen space is one api:<host>/spaces/<id> entry carrying the
space-level token set. One credential per authorization, never one per token,
so revoking it severs the whole grant at once, and the entry count never
exceeds the chosen spaces plus one.
The artifact is the answer. The returned credential's att states what was
granted. A requester reads it and never assumes the ask was honored whole,
because consent may have narrowed the places.
One app, one standing grant. Re-consent for the same subject and
client_did revokes the prior credential and issues the new one in its place.
Narrowing a standing grant is a re-consent.
Replay prevention
The nonce field is the primary replay defense; the timestamp bound is
secondary, and a stale challenge SHOULD be rejected even with a valid nonce. The
third party MUST generate a cryptographically random nonce per authorization
request, expire unverified nonces with their acceptance window, and validate the
presented nonce under exactly one of two disciplines. Which discipline is
admissible is decided by what success grants; SIWD has no token endpoint, so the
obligation lands on the verifier that redeems the artifact.
Flow-bound verification is sufficient when success grants only a session with
the presenting channel (scope=identity browser login, and nothing more). At
mint time the verifier binds the nonce to the agent that initiated the flow,
through state the presenter cannot populate with a value of its choosing: a
server-side login session keyed by an unguessable identifier, or, statelessly,
the nonce sealed under the verifier's own key in an httpOnly cookie scoped
to the callback origin and expiring with the acceptance window. At verification
the verifier recovers the expectation from that binding, compares it to the
challenge's nonce, and clears the binding on success. The guarantee is exactly
this: the artifact redeems only through the channel that initiated the flow,
inside the timestamp window. It is not global single-use, and under this
discipline a stale challenge MUST be rejected, because the acceptance window is
the only server-enforced bound on a captured pair.
A seal is a real MAC or it is nothing: HMAC-SHA-256 (or authenticated encryption) under a key used for no other purpose, full-length tag, constant-time comparison, and an encoding that cannot confuse the sealed value with its tag or with a seal minted for a different purpose. Sealing the whole expectation (nonce, purpose, and an explicit expiry) instead of the bare nonce is the stronger form of the same move.
Consumed verification is REQUIRED whenever success yields anything beyond a session with the presenting channel: every credential-returning scope, any token or grant redeemable outside the originating channel, any success that triggers a side effect beyond establishing the session itself, and all of profile B (a mailbox flow has no presenting channel to bind). The verifier stores each minted nonce server-side, bound to its authorization, and consumption is an atomic check-and-delete performed as the final verification step, after signature, key currency, timestamp, and domain have all passed. An artifact whose nonce does not match an outstanding value, or has already been consumed, MUST be rejected. The atomicity is real, not stylistic: a check-then-delete written as two operations lets two concurrent replays both win.
A profile-A loopback application is its own verifier, and the expectation lives in the process that minted it, held in memory and discarded after one comparison. That is consumed verification with a store of size one, and it is the discipline a loopback verifier applies.
Either way, the expectation MUST be something only the verifier could have produced. A nonce handed to the verifier by the party presenting the artifact, in a callback parameter, a request body, or a header, is not a replay defense at all: an attacker replaying a captured JWS reads the nonce out of the artifact itself. A bare cookie is the same trap in a costume, since cookies are presenter-supplied on every request. What makes either discipline bind is that the expectation is recoverable only from the verifier's own prior act.
The app description document
An application's /.well-known/dfos-app.json, served over https from its own
origin, is its self-description: the file the admission path of
redirect URI validation fetches, where the domain
vouches for itself. Serving the file is the registration. There is no
developer portal and no client secret; domain control is the credential.
The path is one of the corpus's four
RFC 8615 well-known suffixes, tracked
in the
extension registry's external-registrations table.
Alone among the four it carries .json: the document is the one member of the
family designed to be served as a static file from hosting the application does
not program, and a file extension is how static hosts map a file to its media
type. The registered suffix is the full string, extension included.
{
"name": "3P App",
"client_did": "did:dfos:<3p id>",
"redirect_uris": ["https://3p.com/callback"],
"identity_chain": ["<identity-op JWS>", "<identity-op JWS>"]
}| Member | Required | Description |
|---|---|---|
name |
No | Display name, the application's own claim about itself. Nothing here vouches for it; a consumer that renders it renders a claim, and domain remains the phishing-relevant binding. Absent a name, a consumer leads with the domain it already verified. |
redirect_uris |
Yes | Exact-match allowlist of the redirect targets this origin will accept callbacks at, the strings a host compares a redirect_uri against under redirect URI validation. Exact means exact: scheme, host, path, trailing slash included. |
client_did |
No | The application's own DFOS DID. Optional at scope=identity; required whenever a credential-returning scope would name this application as audience. A host MUST refuse a web-origin authorize request naming a client_did the origin's document does not back, because admission runs through the document. |
identity_chain |
No | The application identity's full operation log, carried in place, see below. |
The member set is closed: this table is the registry. Every member is
security-relevant, and a misspelled redirect_uris must never silently degrade
into "no allowlist at all", so a consumer MAY treat a document carrying
unrecognized members as invalid, and new members are added by amending this
table. A document missing a required member is not an app description at all, and
consumers MUST treat it as invalid; a required member that is present but empty
(an empty redirect_uris array) is missing in everything but syntax, and
consumers MUST treat it the same way. An optional member that is present but
empty, a zero-length name or an empty identity_chain, is likewise malformed
rather than merely absent. A document that omits every optional member is valid.
Origins SHOULD serve this document with Access-Control-Allow-Origin: *. The
document is public by construction, so the header withholds nothing, and its
absence blocks browser-based tooling from reading a document that is already
world-readable to every non-browser client.
A JSON Schema for structural validation of this document is published at
https://schemas.dfos.com/dfos-app/v1
(source:
schemas/dfos-app.v1.json).
It mechanically checks this table and nothing more: chain validity, the
genesis-DID equality, and per-operation size are consumption-time checks that
schema cannot express, and this document is normative wherever the two could be
read to disagree.
identity_chain: chain carriage
An application named by client_did needs its identity chain resident somewhere
a verifier can resolve it, and a freshly minted application identity may be
resident nowhere at all. The optional identity_chain member carries the chain
in the one document whose origin already vouches for the application.
- Format. An array of identity-operation JWS strings: the application identity's (not any user's) full ordered operation log, genesis first, the same ordered log a relay serves and a verifier replays, carried here as bare JWS strings. Never a fragment or a suffix; the whole chain from genesis.
- Self-consistency. If
identity_chainis present,client_didMUST be present (structural, checked at every parse) and MUST equal the DID derived from the chain's genesis operation (cryptographic, checked wherever the chain is consumed). A consumer that consumes the chain MUST reject a document where they disagree, whole-document, never ingesting the chain while ignoring the mismatchedclient_did. A processor that leaves the chain unconsumed is not obliged to verify it first, since consumption is optional; what it MUST NOT do is present or treatclient_didas chain-backed without having verified. - Carriage cap. The chain MUST NOT exceed 100 operations. The protocol core does not bound chain length; 100 operations is this transport's bound. An identity that has outgrown the cap has outgrown carriage: its chain belongs on relays, and the document omits the member. A consumer encountering a longer chain MAY refuse the document unexamined, and no consumer is obliged to process past the hundredth operation. Per-operation size is governed by the protocol's operation-size cap, and nodes MAY apply local ingress byte limits to the document fetch.
- Consumption. Verifiers and relays MAY fetch, verify, ingest, and re-serve a carried chain on encounter, with no registration or approval precondition. The chain is self-certifying: replayed from genesis it is as authentic as any relay resolution. What carriage never adds is trust beyond the signatures, and whether a consumer ingests, retains, or ever re-serves what it encountered is its own policy (see Carried identity chains).
Loopback clients
A loopback client is software on the user's own machine: a CLI, a desktop application, an agent. It holds no domain, so the app description document is a registration it can never perform. For identity alone that costs nothing. To receive a credential it must prove the one thing local software can prove: key control.
The loopback credential tier allows a client_did on a loopback authorize
request iff the request proves control of that identity's current keys, under
the rules below. Everything downstream is unchanged: scopes and multi-scope sets,
the one-credential-per-resource coalescing, and revocation all work exactly as in
the hosted case.
Chain residence
The request MUST carry the client's identity chain, unless the DID is already
resident on the verifying host's store, in which case carriage is
unnecessary. A carried chain travels under exactly the
chain-carriage rules of the app description
document: the full ordered operation log, genesis first; the DID derived from the
genesis operation MUST equal client_did, and a request where they disagree MUST
be refused whole; the 100-operation carriage cap; verification under the
Signature verification profile
to the chain head. A host that ingests what was carried applies the
first-encounter discipline unchanged: carriage proves
which party sent the chain, never that the party controls the identity. The chain
travels as the client_chain authorize parameter: base64url of the JSON
array of the verbatim operation JWS strings, genesis first, the challenge
parameter's own grammar, so one decoder shape serves both. A host MAY bound the
parameter's encoded size (the reference host refuses carriages past 8KiB). What
is normative is that the chain arrives with the ask and verifies before any
consent is rendered.
The ask proof
The request MUST carry a client proof, as the client_proof authorize
parameter: a JWS over the exact
canonical challenge bytes of the request's
own challenge, signed by a key effective in the client identity's authKeys
at the chain head, with alg: "EdDSA", kid the DID URL of the signing key, and
protected header typ: "did:dfos:siwd-ask". The host MUST verify it under the
Signature verification profile
against the client chain, carried or resident, before any consent is
rendered: possession is proven at ask-time, not just at spend-time. The
challenge's optional did member binds the subject, and the sign-refusal rule it
triggers belongs to the did:dfos:siwd family; the ask proof is the client
speaking, not the subject.
Consent provenance
The host MUST NOT display a domain for a loopback client. There is none, and
implying one would manufacture exactly the binding this tier cannot check. The
consent screen displays the client DID whose keys were proven, and an honest
statement of what is and is not established: the software runs locally on the
user's device, its origin and authorship are unverifiable, and what the proof
establishes is only that the asking party controls the named identity's keys. The
domain slot of the hosted consent has no loopback equivalent, and the consent
copy MUST NOT suggest one.
Expiry ceiling
A credential minted to a loopback client MUST carry a hard expiry ceiling enforced by the issuing host; RECOMMENDED 14 days. The ceiling bounds the blast radius of what this tier accepts, key control without provenance, while revocation remains the user's "disconnect this app".
Carried identity chains
A chain that arrives by carriage is verified exactly like a chain that arrives from a relay. What carriage changes is operational: the consumer holds identity state fetched on its own clock from an origin the application controls, and five disciplines follow.
- Monotonicity is compared on operations, not on derived state. A consumer that retains observed chain state SHOULD compare a fresh fetch against it on the ordered operation-log CIDs. A fetch that is a proper prefix of previously observed state SHOULD be ignored: serving yesterday's shorter chain is the rollback move, resurrecting a rotated-out key by omitting the rotation.
- Divergence is signed, and acceptance is discretionary. Two chains sharing a prefix and disagreeing after it both verify. Which branch, if either, a consumer accepts is operator discretion, and an observed divergence SHOULD be logged. Nothing outside the consumer picks a branch: both are views of the same identity, and following one is a choice of source (PROTOCOL, Views).
- Staleness runs in both directions. A carried chain is a snapshot at fetch time. The consumer's re-fetch cadence bounds new-key usability and revoked-key death alike, so an operator choosing a cadence is choosing both bounds at once.
- Serving is discretionary, and so is stopping. Nothing obliges any relay or verifier to ingest a carried chain, to retain it, or to keep re-serving it; removal, including abuse removal, is operator discretion. Ingestion is permissionless for the application; retention is policy for the operator.
- First encounter proves nothing about ownership. Identity operations are public data, so encountering a chain in an app description proves which origin served it, never that the origin controls the identity the chain derives. A consumer that ingests carried chains into a store that also holds identities under its own authority MUST refuse or segregate a carried chain whose derived DID it already holds under that authority; a chain already held as previously carried state is an ordinary re-fetch, governed by the monotonicity discipline above. Monotonicity begins only after a first accepted state, so it cannot defend the first encounter.
API authentication
Proof-of-possession authentication for DFOS-gated HTTP APIs: one envelope family, two artifacts. A request proof is a short-lived JWS, signed by the key of the party a DFOS credential was issued to, that binds one exact HTTP request (method, host, path, body) to that credential, right now. The credential says what its holder may do; the proof says the holder is the one doing it, and doing exactly this. An identity proof is the same envelope minus the credential: it binds the same exact request to a bare DID, proving only who is asking. Neither is a bearer token.
The nearer standard neighbor is
HTTP Message Signatures (RFC 9421):
the proof's {method, host, path, bodyHash} binding is almost member for member
a 9421 signature over ("@method" "@authority" "@path" "content-digest"). It is
not adopted because its wire rides
RFC 8941 Structured Fields plus
RFC 9530, two more parsers in five
languages, for no binding this envelope's single canonical JSON payload does not
already make; it defines no slot for the credentialCID; and it leaves key
resolution out of scope. The grant-exercising half of the shape is
DPoP (RFC 9449), which binds htm and
htu and does not cover the request body; the body binding here is a
strengthening of that shape.
What this family is not: not a session (there are no cookies and no server-side
session state, and every request is authenticated by its own proof); not a bearer
token (the Authorization scheme is deliberately not Bearer); not relay
surface (a proof is never ingested, never gossiped, never content-addressed); and
not a signing family (the presenter signs proofs with its own key, so there is no
other party to ask and nothing to courier).
The request proof
JWS header
{
"alg": "EdDSA",
"typ": "did:dfos:request-proof",
"kid": "did:dfos:nzkf838efr424433rn2rzkdv8h7t9ae#key_r9ev34fvc23z999veaaft83nn29zvhe"
}| Field | Value | Description |
|---|---|---|
alg |
"EdDSA" |
Ed25519 signature algorithm |
typ |
"did:dfos:request-proof" |
Protocol-specific type discriminator |
kid |
DID URL | did:dfos:<id>#<keyId>, identifies the signing key |
There is no cid header. Content addressing exists in this envelope family
to make artifacts referenceable. A request proof is referenced by nothing: it is
never stored, never revoked, and correlates to nothing but the request it rides.
The content-addressed artifact in this exchange is the credential, and the proof
binds to it by carrying the credential's CID in its payload, under the
signature.
kid names the credential's audience. The kid MUST be a DID URL containing
#, and its DID portion MUST equal the aud of the presented leaf credential.
This is the possession being proven.
Key resolution is at the basis, which is now. The presenter's signing key is resolved against the chain head, so rotated-out keys are rejected and a deleted presenter's proofs are rejected. Rotation is how a presenter whose key is compromised stops that key from minting proofs in its name. Any effective key role (auth, assert, controller) may sign.
Payload
{
"method": "GET",
"host": "api.dfos.com",
"path": "/v1/profile",
"bodyHash": "47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU",
"credentialCID": "bafyrei...",
"iat": 1772841600
}| Field | Type | Required | Description |
|---|---|---|---|
method |
string | yes | The HTTP method, uppercase (GET, POST, …) |
host |
string | yes | The API's lowercase authority: bare hostname on the default HTTPS port, host:port otherwise; no scheme |
path |
string | yes | The exact origin-form request target, path plus query string, byte for byte |
bodyHash |
string | yes | Unpadded base64url of the SHA-256 of the raw request body octets |
credentialCID |
string | yes | CID of the leaf credential presented alongside this proof |
iat |
integer | yes | Issued-at, unix seconds (positive integer) |
All six canonical members are required and none is optional, so the canonical form below carries no absent-member ambiguity. Additive members follow them, appended after the canonical order in lexicographic member-name order, and a verifier ignores an unregistered one per the protocol's MUST-ignore-unknown rule. One additive member is registered:
| Member | Type | Required | Description |
|---|---|---|---|
jti |
string | no | Per-request uniqueness: a non-empty string of at most 256 UTF-8 bytes, RECOMMENDED at least 128 random bits, unique per presenter inside the freshness window. A write-shaped route requires it (API security notes) |
A present jti that violates its schema invalidates the proof whether or not
the route requires one. The example above is illustrative, with credentialCID
elided; the normative bytes are the reference vectors, not this snippet.
host is an authority, port included when non-default. api: surfaces are
HTTPS surfaces, and the scheme is never in the value. A deployment on the default
port (443) uses the bare hostname (api.dfos.com). A deployment serving the API
on a non-default port MUST include it (api.example.org:8443), in both the
proof's host and the api:<host> resource string,
so the port is part of the binding whenever it is load-bearing. Without this, one
hostname serving two API origins on two ports would collapse them into one
audience. The verifier's configured identity supplies the expected value; it is
never read from the request.
path is the wire string, not a normalization. The value is the origin-form
request target exactly as the request line carries it: the path, plus ? and the
query string when one is present, byte for byte. It MUST begin with / and MUST
NOT contain a fragment. There is no canonicalization: no percent-decoding, no
query-parameter reordering, no trailing-slash equivalence. The presenter
constructs the request and the proof from the same string, and the verifier
compares against the request target it actually received. A deployment whose
infrastructure rewrites paths before the verifier sees them must compare against
the original request line.
bodyHash covers the application body octets, the bytes the sender handed
its HTTP client, which the verifier obtains after reversing transfer encoding
(chunked) and content encoding (Content-Encoding, for example gzip). It is
deliberately not the octets on the wire, so a transparent compression layer on
either side does not break a binding neither party intended to make over
compressed bytes. A request with no body hashes zero octets, whose digest is the
constant 47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU; there is no absent-member
form for bodyless requests, so every proof is checked the same way. The encoding
is the canonical unpadded base64url (RFC 4648 §5, no = padding, zero
trailing bits) of the 32 digest bytes, and the value is compared as a string
against the verifier's own re-encoding of the digest it computes, so a padded or
otherwise non-canonical spelling of the right bytes is rejected, not normalized.
A verifier MUST NOT decode the member and compare 32 bytes.
Two obligations follow for a body-bearing request. First, a verifier MUST hash
the original octets: where a framework or serverless runtime has already
consumed the raw body and offers only a parsed representation, buffer them before
the parser or read the platform's raw-body handle. A verifier MUST NOT hash a
re-serialization of the parsed body, which would convert a byte binding into a
semantic one. Second, because reversing Content-Encoding means decompressing
attacker-controlled bytes before the proof signature is checked, the body cap
the algorithm requires is a bound on the decoded
octet count, and decoding MUST abort at the cap.
iat and no exp. The proof carries when it was made and nothing about how
long it lives; the verifier owns the freshness window. A presenter-chosen exp
would let the presenter widen the replay window unilaterally, and the party
bearing the replay risk sets the bound.
Proof canonical signing input
The signer and the verifier MUST agree on the exact bytes signed. The canonical signing input is the payload object serialized as minimal UTF-8 JSON (no insignificant whitespace) with its members in this fixed order:
method, host, path, bodyHash, credentialCID, iatAdditive members follow, jti among them, in lexicographic order of member
name. These bytes are the JWS payload segment.
Minimal JSON, HTML escaping OFF. path routinely carries & and admits <
and >. The canonical serialization emits these three octets literally
(&, <, >) and MUST NOT apply the HTML-safe escaping that some JSON encoders
default to, which would instead emit each as a JSON \u-escape
(\u0026, \u003c, \u003e, the encoding/json default), changing the
signed bytes. This
matches JavaScript's JSON.stringify; the Go byte-twin MUST disable it
(Encoder.SetEscapeHTML(false)). iat is emitted as a bare JSON integer (no
fraction, no exponent). Because the presenter self-signs and the verifier checks
the signature over the received payload bytes, the verifier does not
re-canonicalize a request proof; the canonical rule binds producers, so that
a TS signer and a Go signer emit the identical proof from identical inputs and
the vectors byte-compare.
HTTP carriage
A credential-gated request carries two headers:
Authorization: DFOS <request-proof JWS>
X-Credential: <leaf credential JWS>The Authorization scheme is the token DFOS, not Bearer, because the proof
is not a bearer token and naming it one invites bearer handling (logging,
caching, forwarding) that this artifact exists to make useless. The scheme is
matched case-insensitively, per
RFC 9110 §11.1, and is
separated from the token by one or more spaces, with surrounding optional
whitespace ignored. The token itself, a JWS compact serialization, is
case-sensitive and is not further decoded at the header layer. X-Credential is
the same header the relay content plane uses
for per-request credentials; the leaf token embeds its full delegation chain in
prf, so one header carries the whole grant. A verifier MUST respond 401 when
either header is missing or malformed, and SHOULD include a
WWW-Authenticate: DFOS challenge on that response.
The challenge header is SHOULD rather than MUST because common serverless front
doors rewrite it in transit: an AWS-fronted deployment may deliver it as
x-amzn-remapped-www-authenticate, or a response path may drop it entirely.
Clients MUST NOT depend on the challenge header for protocol behavior; the
response status code, interpreted through the verdict mapping below, is the
normative machine signal.
This carriage is normative for the api:<host> resource family: every host that
serves it, canonical deployment or fork, authenticates the same way.
Size bounds
| Bound | Value | Applies to |
|---|---|---|
| request-proof JWS | 4096 bytes | the serialized proof token |
| credential JWS | 262144 bytes (256 KiB) | the credential spec's existing cap |
Verifiers MUST reject either token over its cap before any decode, at the header layer. Both bounds are validity-determining and identical across implementations. The credential cap is CREDENTIALS's own, restated rather than redefined.
The credential cap is what the protocol accepts; what a deployment's transport accepts is usually far less, since CDN and serverless header ceilings commonly sit in the 8 to 20 KB range. That gap is deployment policy: a deployment whose grants outgrow its header budget raises the budget.
Verification algorithm
To verify a credential-gated request, given the two tokens, the received request, a way to resolve identities, a revocation source, and the current time:
- Size. Reject a proof token over 4096 bytes or a credential token over 262144 bytes, before any decode.
- Decode the proof JWS and apply the
Signature verification profile
header gates:
typMUST be exactlydid:dfos:request-proof,algexactlyEdDSA, acritmember rejects, an embedded key member (jwk,x5c, …) rejects. A missing or non-stringtyporkidrejects; thekidMUST contain#. - Payload schema. All six members present:
methoda non-empty uppercase string,hosta non-empty lowercase string,patha string beginning with/,bodyHasha string that is the canonical unpadded base64url of exactly 32 bytes (a spelling that does not re-encode to itself, padded or with non-zero trailing bits, rejects here),credentialCIDa non-empty string,iata positive integer. A presentjtiMUST be a non-empty string of at most 256 UTF-8 bytes. Unknown members are ignored. - Freshness. Compute
nowas integer Unix seconds (floor), the same basis asiat, so the boundary does not turn on sub-second precision. Two bounds, both verifier policy: the proof's agenow − iatMUST NOT exceed the acceptance window W (RECOMMENDED 60 seconds), and its forward skewiat − nowMUST NOT exceed a clock-skew allowance S (RECOMMENDED 60 seconds). The binding cap is on their sum,W + S, the total span over which any one proof is accepted and therefore its worst-case replay window, which MUST NOT exceed 300 seconds. Splitting age from skew is what makes the sum meaningful: a single symmetric|now − iat| ≤ Wconflates them, so a proof forward-dated by the full window is replayable for2W. A skew beyondS, an age beyondW, or a configuration withW + S > 300all reject. - Request binding.
methodequals the received request's method;hostequals the verifier's own configured authority for the route being served, a value the deployment holds (hostname, plus port when non-default), NOT one read from the request.Host,X-Forwarded-Host, and the request URL's authority are all attacker-supplied and MUST NOT be the comparison source. A verifier serving several authorities selects the expected one from its own configuration for the matched route or virtual host, never from a request header.pathbyte-equals the received origin-form request target;bodyHashequals the verifier's re-encoding of the SHA-256 of the received body octets (zero octets when there is no body). Any mismatch rejects. - Resolve the presenter named by the
kid's DID to its identity state at the chain head. Unresolvable → unverifiable. Deleted → reject. No key matching thekidfragment effective at the head → reject. How the resolver came to hold that state is the deployment's concern; for an application identity it MAY have been ingested earlier via chain carriage. Resolution here is a local lookup: the canonical deployment never fetches an application's well-known document at verify time. - Signature. Verify the proof JWS under that key, with the profile's
canonical-scalar gate (
S < L) and 64-byte length check. A valid proof signature is the gate to the credential work below: steps 8 to 11 do unbounded and network-touching work, and a verifier MUST NOT perform them for a request whose proof signature has not verified. - Credential chain. Verify the presented credential in full under
CREDENTIALS: signatures, schema, CID
integrity, linear delegation, depth, audience linkage, monotonic attenuation,
with
expevaluated against the basis (now, this being an ephemeral presentation) and revocation checked at every level against the verifier's current knowledge. - Credential binding, and no public audience anywhere. Re-derive the leaf
credential's CID (its canonical base32 CIDv1 string,
bafyrei…) from its parsed payload; it MUST equal the proof'scredentialCID, compared as that canonical string form. The leaf'saudMUST be a named DID equal to thekid's DID portion. Further, no credential in the presented chain, leaf or any parent, may carryaud: "*". A public parent would let anyone self-issue a byte-identical leaf audienced to their own key, present the public parent as itsprf, and pass steps 6 to 8 with a key they own, which is a complete proof-of-possession bypass. - Subject selection. The chain's root
issis the DID whose data the request serves. It is not checked against an externally-known resource owner, because for the action registry there is none:read:profileserves the profile of exactly the DID that rooted the credential, and the credential is what selects that subject. A route parameter never does. - Attenuation coverage. The route's required resource is
api:<host>for an account-level route andapi:<host>/spaces/<id>for a space-addressed route, where<host>is the verifier's own configured authority (the same value step 5 binds, never a request-supplied one) and<id>is resolved by the deployment's own routing, never read from a request-supplied resource string. Someattentry on the leaf MUST cover the required resource under theapi:coverage rule (the host form covers every space at the host; a space form covers only itself), and its canonical action set MUST contain the route's required action token.
Every step MUST pass. Two ordering rules are load-bearing; the rest is free.
(a) The proof signature (step 7) MUST verify before any credential-chain work
(step 8 onward). (b) Body hashing (the bodyHash half of step 5) runs after
the header, freshness, and non-body binding checks (steps 2 to 4 and the
method/host/path of step 5), and only up to the decoded-body cap the deployment
sets (413 over it, a bound on decoded octets, aborting decode at the cap).
Everything else may be ordered for efficiency. No step consumes or mutates state.
Verdicts are machine-distinguishable, as everywhere in the envelope family:
invalid ("checked and failed") versus unverifiable ("could not check", an
unresolvable presenter, an unreachable revocation source). The verdict class is
the outer distinction and takes precedence over the step at which it arose:
unverifiable → 503 regardless of whether the resolution failure surfaced in
the proof phase (step 6) or the credential phase (step 8), because a transient
resolution failure is the server's condition and MUST NOT be reported as a
credential judgment. An invalid verdict then maps by phase: 401 for a
proof-layer failure (steps 1 to 7), carrying the WWW-Authenticate: DFOS
challenge where the deployment's response path preserves it, 403 for a
credential-layer failure (steps 8 to 11). A verifier reports a step 11 miss,
a valid credential that does not reach this route's resource or action,
distinctly from a failed chain, so a route offering
optional authentication serves its anonymous
projection on the former and still refuses the latter; on the wire both are 403. A replayed verdict, raised only by a route that requires jti
(API security notes), maps to 409: the proof was
checked and is valid, and its uniqueness is already spent.
Revocation in the verify path, the freshness window, and at-basis credential expiry are the difference between this design and a bearer token with extra steps. A verifier that skips step 8's revocation check has removed the user's only timely lever over a standing grant.
The identity proof
Not every DFOS-gated request exercises a grant. A caller reading its own data, a peer announcing itself, or a depositor a service admits on reputation rather than delegation needs the resource to know who is asking, with nothing to look up and nothing to revoke. The identity proof is the request proof's credential-less sibling: the same canonical envelope, the same freshness and binding obligations, no credential anywhere.
typis"did:dfos:identity-proof", a distinct registered value, because "possession of a grant's audience key" and "possession of a bare identity's key" are different claims. A route that requires a credential rejects an identity proof at the header gate, and the reverse.- Payload is the request proof's minus
credentialCID: five members, all required, canonical ordermethod, host, path, bodyHash, iat, under the same member rules, the same registered additive memberjti, and the same canonical signing input discipline (minimal JSON, HTML escaping off,iata bare integer). - Carriage is the same
Authorization: DFOS <jws>header. On this family's own surfaces there is noX-Credential: an identity proof has nocredentialCIDto bind, so on any route whereX-Credentialmeans "the credential backing this proof", presenting one alongside an identity proof is malformed (401), and a verifier MUST NOT pick one of the two claims. A resource whose own specification assignsX-Credentiala different role, a resource-authorization artifact evaluated by that resource's own machinery as the relay content plane does on non-creator blob reads, MAY accept it alongside an identity proof: the proof is the authentication half, the credential the authorization half. Which meaning a route serves is fixed by that route's specification, never inferred per request. - Verification is the algorithm's proof phase
exactly (size cap, header gates with this
typ, payload schema of five members, freshness, request binding against the verifier's own configured authority, presenter resolution at the head, signature) and stops there. Steps 8 to 11 do not exist for this artifact. The signer is the principal. Thekid's DID is who the request is from; what that DID may do is the resource's local policy, and this document says nothing about it. - Verdicts map as the proof phase always has: 401 invalid, 409 replayed, 503 unverifiable. There is no 403 tier, because nothing credential-shaped can fail.
- Replay is bounded exactly as for the request proof: within-window
byte-identical replay is the accepted bound for read-shaped uses, and a
deployment gating write-shaped uses of an identity proof MUST apply the
same
jtidiscipline. The signer is the principal, so what a proven identity may write is the resource's local policy; a deployment SHOULD refuse a presenter key whose only effective role iscontroller, since that key is a continuity instrument.
An identity proof is not a session, not a login artifact (sign in is the challenge-response seam for establishing a relationship), and not an authorization (a resource that wants delegable, attenuable, revocable grants uses credentials).
The api:<host> resource and its actions
The credential side of this capability is one resource family, registered in CREDENTIALS, Resource Types with its consuming rules here.
<host> is the API's lowercase authority: the bare hostname on the default
HTTPS port, host:port on any other; never a scheme, never a path. Host-as-id
means the resource names the surface by where it is served, so a fork or
self-hosted deployment gets the same form for free, with no registry of
deployments anywhere. It MUST byte-equal the proof's host (a non-default port
appears in both or neither), so the resource id and the request binding name the
same origin.
Two forms, one level deep. api:<host> names the whole API at that
authority; api:<host>/spaces/<id> names one space there, <id> the space's
protocol DID with the prefix stripped. Coverage is equal-or-ancestor: the host
form covers every space form at that host, a space form covers only itself, and
nothing crosses hosts
(CREDENTIALS).
Actions are enumerated registry tokens. The registry defines seven, each at one level:
| Action | Level | Grants |
|---|---|---|
read:profile |
account | Read access to the granting user's own profile: display name, handle, avatar, and public profile fields. The account email address is excluded; that is read:email |
read:email |
account | Read access to the granting user's account email address |
read:memberships |
account | Read access to the granting user's own memberships: the spaces and groups the account belongs to, and its roles in them |
read:posts |
space | Read posts and their comments in covered spaces as the granting user: the user's own member projection, including content the user is entitled to by payment |
write:upvotes |
space | Upvote, and remove upvotes from, posts and comments in covered spaces |
write:comments |
space | Create, edit, and delete the granting user's own comments in covered spaces |
write:posts |
space | Create, edit, and delete the granting user's own posts in covered spaces. Never announce, broadcast, pin, backdate, or moderate |
Level is where a token is demanded. An account-level token is demanded against the host form only. A space-level token is demanded against the space form for the space a route addresses, and the host form satisfies that demand by ancestor coverage: it means every space the subject is in, spaces joined later included. An account-level token on a space entry is inert, never demanded there and not an error. A write token grants the subject's own content only; moderating another member's content is not in this registry.
Every token is a real grant, not a public-data ceremony. A profile document
may be non-public, so read:profile gates access an anonymous fetch does not
have. A serving route applies the registry per token, never as a lump: the
granting user's DID is already known from the credential chain itself (step 10),
while each field class beyond it requires its own token.
A /v1/... segment in a route path, as in the payload example above,
is the serving API's own path versioning; the registry names actions, never
paths. New tokens and new child forms register here additively, each a row. A
grant carrying several tokens is an ordinary comma-separated action list
(read:profile,read:email), and narrowing is dropping tokens.
There is no action wildcard, and read:* is a trap, not a shorthand. Per the
action lattice, * is a literal
token: an att entry with action read:* matches only a route requiring the
literal action read:*, which no route ever will, so a read:* entry grants
nothing at verification. This is a matching fact, not an attenuation fact:
{read:*} is an ordinary non-empty action set, so under the subset rule it
survives a delegation hop only when the parent's set also contains read:*.
Growth is enumeration, always.
Attenuation follows coverage. api: is the one non-chain: form with a
registered hierarchy: a host entry narrows to a space entry, a space entry
narrows only to itself, and coverage never crosses hosts or resource types.
api:* is an ordinary id covering only itself, which is never a served host.
Issuance
How a third party obtains an api:<host> credential is not this surface's
concern; the proof binds to whatever valid credential is presented. The canonical
issuance moment is a credential-returning scope: the
user consents at the hosting platform's front door, and the credential comes back
through the callback, with iss the user's DID, aud the third party's
client_did, att entries on the platform's API host as the
coalescing rule decides, and exp at the issuer's
discretion with revocation as the timely lever. Delegation onward is
ordinary credential machinery: the third party MAY sub-delegate its grant through
prf chains, and the verification walk holds every hop to monotonic attenuation.
One envelope, optional credential
The identity proof answers "is the named DID making
exactly this request, now?" The request proof answers "is
the credential's audience making exactly this request, now, under this grant?"
Same canonical-bytes machinery, same freshness bounds, same host binding, same
key resolution at the head. The credentialCID member and the credential walk
are the whole delta, and the typ gate keeps the two claims distinct on the
wire. The sign-in challenge proof is a different genus: a
challenge-response artifact a relying party verifies once to establish a
relationship, never a per-request token.
| Concern | Identity proof | Request proof |
|---|---|---|
| Question answered | "Is this DID making exactly this request, now?" | "Is the credential's audience making exactly this request, now?" |
JWS typ |
did:dfos:identity-proof |
did:dfos:request-proof |
| Payload | method, host, path, bodyHash, iat, jti? |
the same, plus credentialCID |
| Grants | Nothing, the resource's local policy decides | Whatever the presented credential's attenuation covers |
| Failure tiers | 401 / 409 / 503 | 401 / 403 / 409 / 503 |
| Stands alone | Yes, it is the whole AuthN statement | No, meaningless without its credential |
Any DFOS-gated HTTP surface (the canonical API, a fork, a relay surface) consumes this family by asking, per route, which claim it needs: bare identity, then local policy, or an exercised grant. Growth is additive members on this family, since both artifacts ignore unknown members, never a new envelope.
Advertising in OpenAPI
A host that serves this family states, in its OpenAPI document, exactly which claim each route needs, so a generic client signs the right artifact for any host from the document alone. The convention below binds documents and the clients that consume them, never the wire: a host that publishes no document is exactly as gated as one that does.
Scheme vocabulary
Three security-scheme shapes, declared in components.securitySchemes. Component
names are the host's choice (the names below are RECOMMENDED); consumers identify
schemes structurally, by type / scheme / header name and the
x-dfos-typ marker, never by component name.
components:
securitySchemes:
dfosIdentityProof:
type: http
scheme: dfos
x-dfos-typ: did:dfos:identity-proof
dfosRequestProof:
type: http
scheme: dfos
x-dfos-typ: did:dfos:request-proof
x-dfos-actions:
read:profile: Read the granting user's own profile
read:email: Read the granting user's account email address
dfosCredential:
type: apiKey
in: header
name: X-Credential- The two proof schemes are
type: httpwithscheme: dfos, literally accurate: the wire isAuthorization: DFOS <jws>, and the scheme token is matched case-insensitively per HTTP carriage (lowercase is RECOMMENDED, matching OpenAPI's registered-scheme convention).x-dfos-typnames the envelope the scheme carries, and under this convention it is REQUIRED on everyscheme: dfossecurity scheme. A consumer encountering a document without it MAY fall back to the combination rules below, which are unambiguous for a proof scheme standing alone and for a proof scheme ANDed with the credential scheme; the mixed authn/authz combination is expressible only with explicit markers. - The credential scheme is
type: apiKey, in: header, name: X-Credential, the grant header, named case-insensitively as HTTP field names are.apiKeyis OpenAPI's honest generic type for "a token in a named header", and nothing declared this way is a bearer artifact. x-dfos-actionson the request-proof scheme is the host's action catalog: a map of action token to human-readable description, restating the registry the host serves. Tokens are opaque strings to consumers. The member name is shared with the operation-level extension below, and the two shapes are strict and deliberately different: on a scheme the member MUST be a map, on an operation it MUST be an array. A scheme member that is not a map, like an operation member that is not an array, is malformed, and consumers reject it rather than guess.
Requirement combinations
OpenAPI's own security semantics carry the claim structure: within one
security-requirement object the named schemes are ANDed, and across the
security array requirement objects are alternatives, so a client satisfies any
one. Under this convention an operation's requirements mean:
| Combination | Claim the route needs |
|---|---|
No requirement, or empty security: [] |
Anonymous, no artifact of this family |
| Identity-proof scheme alone | An identity proof: authentication only; the resource's local policy decides the rest |
| Request-proof scheme AND credential scheme | A request proof with the credential it binds, the delegated-grant profile. Two schemes because the wire is two headers; a request-proof scheme alone advertises a route no conforming client can call |
| Identity-proof scheme AND credential scheme | An identity proof plus a credential in the route's own declared role, the authn/authz split the carriage rule names, as on the relay content plane's non-creator blob reads |
The empty requirement object {} beside others |
Optional authentication: the route serves an anonymous projection to a request carrying no artifact, and the subject's projection under one of its other alternatives |
The combination is the profile declaration. A client selects the alternative it can satisfy and signs the artifact that combination names; a host offering a route under either claim lists two requirement objects.
Optional authentication. A route MAY list the empty requirement object
beside its proof alternatives
(security: [{}, {dfosIdentityProof: []}, {dfosRequestProof: [], dfosCredential: []}]).
Such a route serves an anonymous projection to a request carrying no artifact,
and the subject's projection to a valid proof whose grant covers the route. A
valid proof whose leaf does not cover the required resource or action serves
exactly the anonymous projection: a credential only ever adds. Coverage is a
ceiling, not a standing: the projection served is the subject's live standing,
so a covered space the subject is not in serves the anonymous projection too. A
present but invalid proof is refused by the verdict mapping, never degraded to
anonymous, because an app that learns to ignore authentication failures has no
authentication.
Required actions
OpenAPI permits a non-empty scope list only on oauth2 and openIdConnect
schemes, so required actions ride an operation-level extension rather than the
requirement object. An operation under the delegated combination declares the
actions its route requires as x-dfos-actions on the Operation Object: an array
of alternatives, each alternative a single action token or an array of
tokens, and the presented leaf credential satisfies the operation when its
attenuation covers every token of any one alternative. The array form is the
only form: a single required action is a one-element array, never a bare string,
and a document carrying any other shape here is malformed.
# read:profile OR read:email — the route serves the field classes the grant covers
x-dfos-actions: [read:profile, read:email]
# a route requiring two tokens at once is one AND-alternative
x-dfos-actions: [[read:profile, read:email]]An operation under the delegated combination carrying no x-dfos-actions
requires a valid api:<host> credential and no particular action token: some
att entry naming the host's resource, with the action check vacuous. Declaring
that class by absence is unambiguous because the requirement combination, never
the action list, distinguishes the delegated profile from the identity proof.
x-dfos-actions never appears on an identity-proof-only operation.
The document is discovery, never authority
A consuming client reads the document to decide what to attempt, and the serving verifier's own configuration decides what is accepted. The verification algorithm and its verdict mapping are the normative machine signal, and a stale or wrong document changes what a client tries, never what a host serves. A client MUST honor the runtime verdicts over the document, and an explicit client override, forcing a profile or going anonymous, is always legitimate.
Declaring this family as oauth2 to put action tokens in the scopes slot would
lie to every generic consumer: there is no authorization server, no flow object,
and nothing bearer-shaped. The schemes stay the honest http / dfos and
apiKey shapes generic tooling renders correctly, and DFOS-specific structure
rides x- extensions, which conforming OpenAPI tooling preserves and ignores.
A host SHOULD serve its OpenAPI document at a stable URL. The canonical
deployment serves its own at https://api.dfos.com/openapi.json, and a
relay advertises its document's URL in its
well-known response. There is no registry of deployments.
API security notes
- A stolen credential is a metadata leak, not an access leak. The credential names the grant: who authorized whom, over what, until when. Without the audience key, a captured credential authorizes nothing anywhere. The artifact that must never leak is the audience key, which never crosses a channel.
- Within-window replay of an identical request is the accepted bound on a
read-shaped route. A captured proof replays only as the byte-identical
request, inside the freshness window, against the same host, and a re-read
returns the same response. A write-shaped route closes it with
jti. A deployment gating writes MUST require the registeredjtimember on every write-shaped route and record it by an atomic insert-if-absent into a replay cache keyed on the presenter DID andjti, whose entry lives until the proof's own expiry (iat + W + S). The verifier never held the client-chosen value beforehand, so the primitive is insert-if-absent (accept iff newly inserted), not the check-and-delete a server-minted nonce would use. An already-seenjtiis thereplayedverdict, 409, distinct from an invalid proof so that a client whose request timed out re-reads state rather than retrying blind. A read-shaped route MAY ignore the member. aud: "*"is refused at every level because it un-asks the question. A public credential has no audience, so there is no audience key, so there is nothing whose possession a proof could prove. A public credential anywhere in the chain, not just at the leaf, reopens the hole, because a public parent satisfies audience-linkage for any child issuer. The refusal scans the whole presented chain.- Revocation is the user's lever, and the verify path is where it has teeth.
expis signer-discretionary and may be months out; what keeps a standing grant answerable is that step 8 re-checks revocation on every request against the verifier's current knowledge. That knowledge is as fresh as the verifier's revocation source: an API host SHOULD resolve revocations from the relays its users' identities actually list, and a deployment choosing a cache interval is choosing its revocation latency. - A deployment MAY refuse credentials it did not issue. The algorithm defines validity, not acceptance. A deployment that keeps a registry of the credentials it issued MAY refuse an otherwise-valid presented credential absent from that registry, as a credential-layer refusal (403). This is acceptance policy layered above the algorithm, not a verification step.
- The browser is not a keyholder. A browser application cannot hold the
audience key non-extractably, so the supported browser shape is a
backend-for-frontend: the browser holds an ordinary session with the third
party's own backend, and the backend holds the key and signs proofs. That
backend seam is a signing surface, and it MUST authorize the request it is
about to sign against its own session, signing the coordinates the session is
entitled to rather than whatever
{method, path, body}a browser client hands it. A backend that signs blindly is a confused-deputy oracle. - What the proof binds, and what it does not. The proof covers
{method, host, path, bodyHash}and nothing else about the request. Request headers are unbound, and three matter, each of which a proof-authenticated route MUST close: method-override headers (X-HTTP-Method-Overrideand kin) MUST NOT be honored;Content-Encodingis settled before hashing by construction; andContent-Typedecides how identical octets parse, so a body-bearing route MUST pin exactly one media type, rejecting others before parsing, or bind the normalizedContent-Typeas an additive member. - Protected media into a browser context is an exchange, never URL-carriage.
A browser needs
<img>and<video>-loadable URLs, and a URL can never carry an artifact of this family: anything in a URL leaks throughRefererheaders, history, access logs, and copy-paste. The shape is an exchange: an identity-proven or credential-proven request mints a short-lived, narrowly-scoped view URL, and the browser context consumes that. A resource MUST NOT accept either artifact of this family from a URL. - Transport security is assumed, not replaced. The proof authenticates the
request; it does not encrypt anything.
api:surfaces are HTTPS surfaces, and the freshness window is sized against replay, not against an adversary reading traffic in the clear. - DoS controls are the caps, a bounded body, an order, and rate limits. Both token size bounds are checked before any decode; the stateless proof checks reject malformed floods at parse cost; and the verification order forbids hashing an unbounded or bomb-inflated body and walking a 16-credential chain before the proof signature verifies. One cost the order cannot remove: step 6 resolves the presenter's DID and step 7 structurally needs that key, so a flood of well-formed proofs carrying garbage signatures reaches a DID resolution before it is rejected. The defenses there are a resolver cache and rate-limiting by connection before resolution, and by presenter DID after it.
Origin binding
A bidirectional, independently verifiable binding between a DFOS identity and a
web domain. The identity's chain names the domain, as a DfosOrigin
services entry signed by a controller
key and ordered by the chain. The domain attests the DID back, through a
well-known HTTPS document or a DNS TXT record, whichever the operator can serve.
Each half alone is a claim anyone could publish; together they prove one party
controls both, and any consumer can check the pair with a chain resolution and an
HTTPS or DNS lookup. Domain control is the credential, the same doctrine the
app description document applies to application
registration.
The attestation shape copies atproto handle resolution: an HTTPS well-known document and a DNS TXT record, either one sufficient, so a static-hosting deployment that cannot touch DNS and a DNS operator that cannot serve files are both first-class. The reverse half differs: the identity's domain claim lives inside the signed identity chain, so only a current controller key can change it and every change is ordered. A re-bind is a chain event, not a field overwrite.
The DfosOrigin service entry
{ id: string, type: "DfosOrigin", domain: string }The entry is a service entry under the core's open type namespace, registered by this document. To a core verifier it is an unrecognized type: preserved verbatim, never structurally validated. Structural validation is a consumer obligation here.
| Member | Description |
|---|---|
domain |
The identity's canonical domain: a bare lowercase hostname, no scheme, no port, no path, no trailing dot. An internationalized name appears in its A-label (Punycode) form. Every comparison in this section is an exact byte comparison of this string. |
The member set is minimal: the domain is the claim, and everything else this capability needs (ordering, authorship, history) the chain already provides. New members are added by amending this table.
One entry, or none. An identity claims at most one canonical domain. A
consumer encountering a services set carrying more than one DfosOrigin entry
MUST treat the set as claiming no binding; an ambiguous claim is no claim.
This is deliberately not broken, since contradiction verdicts are reserved for
the domain's side, where a second party is being contradicted. An entry whose
domain is missing, empty, or not a bare hostname in the form above claims
nothing and is ignored.
Setting, changing, and clearing the entry is ordinary services machinery:
full-state replacement in
an identity update, signed by a current controller key. Nothing here adds an
operation type, a field, or a verification rule to the core.
Non-normative, on the name: the type is named for what the binding proves control of. The attestation is served from the domain's HTTPS origin, or the DNS zone that origin sits in, and control of that origin is the fact being demonstrated.
Attest-back: the domain's half
The domain answers the chain's claim by publishing the DID. Two methods are defined; either one suffices, and an operator serves whichever their hosting allows.
HTTPS: /.well-known/dfos-did
GET https://<domain>/.well-known/dfos-diddfos-did is a suffix minted in
RFC 8615's well-known namespace,
tracked in the
extension registry's external-registrations table.
A 200 response whose body, after trimming ASCII whitespace, is exactly one
DFOS DID (did:dfos:<31-char id>) attests that DID. The response SHOULD be
served as text/plain, but verifiers judge the trimmed body, not the media type.
Origins SHOULD serve the document with Access-Control-Allow-Origin: *; like the
app description document it is public by construction.
Verifiers MUST fetch over HTTPS with ordinary TLS validation, MUST NOT follow redirects, and SHOULD bound the read: a conforming body is under a hundred bytes, so a small cap (for example 1024 bytes) rejects garbage before reading it.
A redirect is a non-answer, never a contradiction. The attestation is the
named origin speaking for itself at the registered path, and a redirect is that
origin declining to answer there. A verifier that receives any 3xx treats the
path exactly as it treats absence: the file attests nothing, the app-description
fallback below applies as it does on a 404, and if no channel answers the
verdict is the silent one (stale), never broken, because nothing contradicted
anything. This diverges from
atproto handle resolution, which permits
redirect hops: followed hops make the effective serving path unauditable, and
no-redirects is one byte-comparable rule with no origin-equality edge cases. The
DNS method exists for the operator this rule touches.
App-description fallback. If /.well-known/dfos-did yields a non-answer
(a 404; a redirect, which is absence in everything but status code; or a 200
whose trimmed body is not exactly one DFOS DID, the shape a host serving its
application shell for every unknown path produces), a verifier MUST fall back to
fetching the app description document at
/.well-known/dfos-app.json, under the same no-redirects rule: a structurally
valid document whose client_did names the candidate DID attests it, exactly as
the well-known file would. This makes every application that serves an app
description attest-back-capable with no changes. The fallback applies only on
non-answers: a body that is exactly one DFOS DID naming a different DID is a
contradiction, and MUST NOT be fallen through. A page that is not a DID can
silence the channel, never break the binding.
Failure is not absence. A query that fails (a network error, a TLS failure, a timeout, a server error) tells a verifier nothing about the path, so it is not a non-answer and a verifier MUST NOT fall back on it. A non-answer is the named origin answering at the named path with something that attests nothing; a failure is that origin not answering at all, and a binding verdict follows what a domain publishes, never how well it was serving in the minute it was asked.
DNS: TXT at _dfos.<domain>
A TXT record at the name _dfos.<domain> whose value is exactly:
did=did:dfos:<31-char id>attests that DID. TXT records at that name not beginning did= are ignored.
More than one did= record at the name is a contradiction: the domain is
attesting two answers, and a verifier MUST treat the binding as broken, never
pick one.
_dfos is a global underscored node name, entered with its TXT RR type in the
IANA Underscored and Globally Scoped DNS Node Names registry per
RFC 8552 §4.1.5 and
tracked in the
external-registrations table.
Agreement
If both methods yield an answer, the answers MUST agree. Disagreement between them renders the binding broken, never either-wins, never first-checked-wins. A domain that says two things is contradicting itself, and a contradiction is a verdict, not a tiebreak.
Binding verification
Verification is bidirectional and runs entirely on public surfaces. Starting from a DID:
- Resolve the chain to its identity state at the head under the core's
verification rules and read the
DfosOriginentry. No valid entry means the identity claims no binding, and verification ends. Attestation without a chain claim is equally not a binding: the chain entry is the identity's signed consent to be named by the domain. - Query the domain: the DNS method, the HTTPS method with its app-description fallback, or both.
- Deliver one of three verdicts:
| Verdict | Condition |
|---|---|
| bound | The chain names the domain, at least one method attests exactly the chain's DID, and no method answers with anything else |
| stale | The chain names the domain and the domain is silent: no TXT record, and a non-answer at both well-known paths (absence, a redirect, or a body that is not a DID and not a valid app description), or the queries fail (network error, TLS failure, timeout, server error) |
| broken | Any method answers with a different DID, the two methods disagree, or the DNS name carries multiple did= records |
Starting from a domain, the walk runs the other way: obtain the candidate DID
from the domain's attestation, resolve its chain, and require a DfosOrigin
entry naming this exact domain. It delivers the same verdicts.
Silence is not contradiction. The stale/broken split is this corpus's
standing verdict discipline, invalid versus unverifiable,
applied to domains: broken is checked and contradicted, stale is could not
check. DNS and web hosting fail routinely and recover routinely, so staleness is
legal: a consumer MAY keep displaying the last successfully verified binding,
marked as stale, for as long as its own policy tolerates. What a consumer MUST
NOT do is display a broken binding as bound, or report a transient resolution
failure as a contradiction.
Lapse, transfer, and re-binding
Domains change hands, and the design makes every outcome of that fact visible rather than exploitable.
- A lapsed domain's new registrant cannot touch the old identity. The chain half of the binding is signed by controller keys the registrant does not hold. Control of the domain buys control of the attestation and nothing more.
- A fresh identity claiming the domain breaks the old binding visibly. If the
new registrant mints their own identity and points the domain's attestation at
it, their binding may verify
bound, and the old identity's binding immediately verifiesbroken. The old identity survives untouched, with its whole history; only its domain claim is contradicted, and every verifier sees the contradiction. Nothing transfers silently. - Re-binding within one identity needs no policy. The
DfosOriginentry rides services full-state replacement, so "which domain does this identity claim" has exactly one answer per chain state, and the chain's ordering orders every move. There is no write race to adjudicate.
Display discipline
This section is normative, because the value of a binding is destroyed at the display layer more easily than anywhere else.
A verified binding proves control of a domain at verification time, never personhood, endorsement, notability, or trustworthiness. Consumers rendering a binding MUST display the bound domain itself, and MUST NOT collapse the verdict into a generic verified badge, checkmark, or tier divorced from the domain. A bare checkmark asserts exactly the thing this binding cannot prove. The domain is the credential; show the credential.
The binding is exact: a binding to example.org says nothing about
sub.example.org, and the reverse. There is no inheritance in either direction.
Consumers displaying internationalized domains SHOULD apply their platform's
standard homograph-safe rendering; the domain member's A-label form exists so
that the comparison layer never depends on display-layer Unicode choices.
Custody
A hosting platform MAY append and maintain DfosOrigin entries on behalf of the
custodial identities it hosts, exactly as it signs their other identity
operations; how it exposes that to users is platform policy. The format and the
verification rules above are the whole public surface, and a self-custodied
identity binds a domain with nothing but its own keys and the
CLI.
Origin-binding security notes
- Domain compromise is attestation compromise, not identity compromise. An
attacker controlling a domain (registrar account, DNS, or web hosting) can
silence or contradict the attestation, breaking the binding visibly, or attest
an identity of their own. They cannot extend the bound identity's chain, and
they cannot make the old identity's binding read
boundto a DID they control. - DNS answers are only as trustworthy as the resolver. A verifier SHOULD query a resolver it trusts (its own recursive resolver, or DNS-over-HTTPS to one it chooses); a spoofed TXT answer can forge the DNS half of an attestation for that verifier. The HTTPS method rides TLS.
- Bounded work. Both lookups are single round-trips with small caps: one TXT query, one bounded GET, with at most one bounded fallback GET. A verifier never fetches chains from the domain, so a hostile domain cannot feed a verifier unbounded or forged chain data.
- Verdicts must stay machine-distinguishable. Reporting
staleasbrokenturns every hosting blip into a public accusation of contradiction; reportingbrokenasstalehides a hijack behind a shrug. The three states carry different consequences and MUST NOT be conflated.
Key ceremonies
A key that lives on a human's own device joins an identity chain by proving
possession over a challenge. The proof envelope, its closed seven-member payload,
its canonical signing input, the roleSet grammar, position binding, and
chain-walk verification are all specified in
PROTOCOL, Key possession. This
section specifies the surface a ceremony operator serves and the obligations a
holder's tool carries.
The rules below are the hosted ceremony leg: a ceremony operator custodying
the chain mints the nonce, and the envelope's audience is that host's
authority. The verifier byte-compares audience against its own configured
authority, never against anything request-derived, and rejects on mismatch. A
proof audienced to one host does not verify at any other. The
controller-verified leg needs no ceremony surface: the controller's tool mints
the nonce, delivers {did, roleSet, prevCID, nonce} to wherever the candidate
key lives, and audience byte-equals the payload's did.
Carriage and resolution
A hosted ceremony reaches its holder as a carriage: an authority and a code, and nothing else. The code is the ceremony's identifier. It selects the ceremony at resolution and travels beside the envelope at presentation. The signing context is not in the carriage; a tool resolves the code before signing.
Short code. The human-typeable form is <authority>/<code>. The code is
operator-minted. Codes SHOULD be drawn from the 32-symbol alphabet
ABCDEFGHJKLMNPQRSTUVWXYZ23456789, uppercase letters and digits with the
confusable glyphs (0/O, 1/I) removed, and SHOULD carry at least 8
characters. An operator using a different charset SHOULD hold entropy at or
above 32^8. Grouping punctuation belongs to the display: an operator SHOULD
strip characters outside the code alphabet and normalize case before comparing,
so ABCD-2345, abcd 2345, and ABCD2345 name one code. A code lives exactly
as long as its ceremony, and an operator SHOULD rate-limit resolution and
presentation per code and per source.
URI carriage. One HTTPS URL naming the same resolution, the resolution
endpoint below with its code member. A QR code and a deep link are that URI
verbatim and land at the same resolution as a typed code.
Resolution. A tool resolves with
GET https://<authority>/.well-known/dfos-key-proof?code=<code>. A live code
answers the signing context:
{
"present": "…", // absolute URL of the presentation endpoint
"nonce": "…", // the verifier-minted challenge the payload carries
"audience": "…", // the authority the envelope names
"purpose": "did:dfos:key-add",
"adopts": { "did": "…", "handle": "…", "displayName": "…" },
"roleSet": "…", // canonical role-set string the ceremony introduces
"prevCID": "…", // the chain's current head
"expiresAt": "…",
"relay": "…", // OPTIONAL, an advisory oracle, below
}The resolved audience MUST byte-equal the resolving authority, and the
present URL's authority MUST byte-equal it too, so a resolution cannot
redirect a ceremony off the host the human typed. adopts names the identity
the introduction targets; a tool MUST NOT sign on a resolution that omits any of
its three members. A holder that signs against a stale prevCID re-resolves the
same ceremony for the current head and re-signs.
The relay member, when present, is an absolute https URL of a relay serving
the key= reverse index. It is the operator naming an oracle for the
one-key-one-DID pre-flight below: a holder with no oracle of its own SHOULD
check against it for that ceremony only and MUST NOT adopt it as a standing
peer. A tool ignores resolution members it does not recognize.
A code is consumed with its ceremony. Neither the code, the nonce, nor any resolution member is a session, a pairing, or a credential.
Holder obligations
- Render before signing. A holder MUST show its human, before signing, the
audience, the ceremony purpose, the adopting identity (DID, handle, and display name from the resolution), and theroleSet. It MUST refuse to sign when any of these is absent from the resolution, and MUST refuse an audience its human did not initiate. A proof is consent, and consent that was never displayed was never given. - One key, one DID. A holder SHOULD refuse to sign a key proof for a key
any identity's chain has ever declared and proved, its own DID included. A
relay's
key=reverse index is has-ever-proved across all three key sets and its rows survive rotation and deletion, so proving one key into two chains publishes a permanent public link between them. An unproved declaration of the key in another chain is void, never indexes, and obligates nobody. - Fresh bytes only. A holder signs a payload it constructed itself from a carriage it resolved, never payload bytes supplied ready-made by anyone else.
- Comparable fingerprints. Where a human compares a key across two
surfaces, both SHOULD render the same word fingerprint: the first six
bytes of
SHA-256over the key's multikey string (the UTF-8 bytes of thez…form), each byte rendered through the PGP Word List, the even list for bytes at even offsets and the odd list for odd offsets. The multikey string stays the identifier everywhere bytes are matched; the word form is for eyes.
Presentation verification
A verifier receiving an envelope at presentation:
- Size cap. Reject an envelope over 4 KiB before parsing.
- Header gates.
typMUST be exactly the registered value the ceremony requires;algMUST be the algorithm of the payload key's Multikey type; acritmember rejects; an embedded key member rejects; a presentkidrejects. - Payload schema, over canonical bytes. Exactly the seven members
PROTOCOL specifies, each a
string,
roleSetin the canonical grammar; any absent, any extra, any non-string, or any non-canonicalroleSetmember rejects. The verifier then recomputes the canonical signing input from the parsed members and byte-compares it against the payload octets presented; a mismatch rejects. A signature covers whatever octets arrived, so this is what makes the payload a function of its members. - Audience. On the hosted leg,
audienceMUST byte-equal the verifier's own configured authority. On the controller-verified leg,audienceMUST byte-equal the payload'sdid. - Position.
didMUST name the chain this ceremony introduces to;roleSetMUST equal the role set the ceremony grants;prevCIDMUST equal the chain's current head. AprevCIDmismatch is a stale envelope, refused without prejudice; the holder re-signs against the current head. - Freshness.
timestampMUST fall within the verifier's acceptance window (RECOMMENDED: 300 seconds, either side). - Nonce. The nonce MUST be one this verifier minted, for this ceremony, not yet consumed, checked and consumed atomically so two racing presentations cannot both pass.
- Signature. The JWS MUST verify against the payload's
publicKeyMultibase.
A proof that passes all eight is exactly one fact: the named key was held, and
consented to joining the named chain in the named roles at the named position, at
this verifier, inside this window. The operator that adopts it embeds the bytes
verbatim in the introducing operation's keyProofs member: the artifact verified
at presentation is the artifact the chain carries. From there the envelope is
re-checked at every replay by
PROTOCOL, Chain-walk verification,
which is what decides whether the introduction counts.
Conformance
The envelopes here are covered by cross-implementation vectors in the TypeScript
and Go reference packages: deterministic proofs from fixed seeds, byte-compared
across languages, plus an adversarial set. For API authentication that set is wrong-case
method, path mismatch including query-string and trailing-slash variants,
body-hash mismatch, padded and non-canonical bodyHash encodings, stale and
forward-dated iat with the age and forward-skew bounds tested separately,
credential-CID mismatch, an aud: "*" leaf and an aud: "*" parent (both MUST
reject), action-token case sensitivity, a read:* grant presented against a
read:profile route (which MUST fail coverage), a query-bearing path vector
that pins the HTML-escaping-off rule, and the typ-confusion pair, a request proof
presented where an identity proof is required and the reverse, both of which MUST
reject at the header gate. The identity canonical is the request canonical with
credentialCID removed and nothing else, pinned as bytes.
Protocol-level vectors live in
packages/protocol-verify/,
and relay behavior is exercised by
packages/relay-conformance/.
Those suites are the conformance definition.
Source
The reference TypeScript implementations are
@metalabel/dfos-client
(the sign-in and API-proof client kits) and
packages/dfos-protocol/src/credentials/
(the proof envelopes). Their Go twins are in
packages/dfos-protocol-go/.
The examples/siwd-demo
relying party is the worked consumer.
Related specifications
- Protocol: encoding, chains, views, the time basis, key possession, and the signature verification profile
- Credentials: authorization credentials, the resource grammar, and revocation
- Relay: the HTTP relay, including the signing mailbox this document's profile B deposits into
- Content Model: document schemas and the credit vocabulary
- DID Method:
did:dfos: the W3C DID method registration - Guarantees: what holds without trusting a server, what is a chosen view, and what the operator can read