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

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_uri on 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 its redirect_uris allowlist. 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=identity it 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

  1. 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.
  2. Deposit. POST /signing/v0/requests at the subject's relay, authorized by a mailbox:<subject id> / deposit credential, obtained at a prior profile-A consent via the deposit scope or from a published open grant.
  3. Sign. The subject's signing agent polls, applies the signer obligations, and responds with the signed artifact.
  4. Collect. The third party polls GET /signing/v0/requests/{cid}/response and 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, timestamp in whole-second .000Z form, and, when did is present, did equal 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 domain prominently. 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.

  1. Decode the JWS. Extract the challenge payload, kid header (DID URL of signing key), and signature. Before any signature work, apply the header gates: the protected header typ MUST equal the exact string "did:dfos:siwd"; the protected header alg MUST equal the exact string "EdDSA"; a crit member 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.

  2. 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 kid from the effective authKeys at 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 services entries.

  3. 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.

  4. 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.

  5. Validate the domain. Confirm the domain in the challenge equals the verifier's own domain (the bare hostname it serves).

  6. Validate the nonce, last. Recover the expected nonce from the verifier's own prior state and confirm the challenge's nonce matches, 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_chain is present, client_did MUST 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 mismatched client_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 treat client_did as 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.

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, iat

Additive 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:

  1. Size. Reject a proof token over 4096 bytes or a credential token over 262144 bytes, before any decode.
  2. Decode the proof JWS and apply the Signature verification profile header gates: typ MUST be exactly did:dfos:request-proof, alg exactly EdDSA, a crit member rejects, an embedded key member (jwk, x5c, …) rejects. A missing or non-string typ or kid rejects; the kid MUST contain #.
  3. Payload schema. All six members present: method a non-empty uppercase string, host a non-empty lowercase string, path a string beginning with /, bodyHash a 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), credentialCID a non-empty string, iat a positive integer. A present jti MUST be a non-empty string of at most 256 UTF-8 bytes. Unknown members are ignored.
  4. Freshness. Compute now as integer Unix seconds (floor), the same basis as iat, so the boundary does not turn on sub-second precision. Two bounds, both verifier policy: the proof's age now − iat MUST NOT exceed the acceptance window W (RECOMMENDED 60 seconds), and its forward skew iat − now MUST 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| ≤ W conflates them, so a proof forward-dated by the full window is replayable for 2W. A skew beyond S, an age beyond W, or a configuration with W + S > 300 all reject.
  5. Request binding. method equals the received request's method; host equals 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. path byte-equals the received origin-form request target; bodyHash equals the verifier's re-encoding of the SHA-256 of the received body octets (zero octets when there is no body). Any mismatch rejects.
  6. Resolve the presenter named by the kid's DID to its identity state at the chain head. Unresolvable → unverifiable. Deleted → reject. No key matching the kid fragment 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.
  7. 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.
  8. Credential chain. Verify the presented credential in full under CREDENTIALS: signatures, schema, CID integrity, linear delegation, depth, audience linkage, monotonic attenuation, with exp evaluated against the basis (now, this being an ephemeral presentation) and revocation checked at every level against the verifier's current knowledge.
  9. 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's credentialCID, compared as that canonical string form. The leaf's aud MUST be a named DID equal to the kid's DID portion. Further, no credential in the presented chain, leaf or any parent, may carry aud: "*". A public parent would let anyone self-issue a byte-identical leaf audienced to their own key, present the public parent as its prf, and pass steps 6 to 8 with a key they own, which is a complete proof-of-possession bypass.
  10. Subject selection. The chain's root iss is 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:profile serves the profile of exactly the DID that rooted the credential, and the credential is what selects that subject. A route parameter never does.
  11. Attenuation coverage. The route's required resource is api:<host> for an account-level route and api:<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. Some att entry on the leaf MUST cover the required resource under the api: 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.

  • typ is "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 order method, host, path, bodyHash, iat, under the same member rules, the same registered additive member jti, and the same canonical signing input discipline (minimal JSON, HTML escaping off, iat a bare integer).
  • Carriage is the same Authorization: DFOS <jws> header. On this family's own surfaces there is no X-Credential: an identity proof has no credentialCID to bind, so on any route where X-Credential means "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 assigns X-Credential a 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. The kid'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 jti discipline. 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 is controller, 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: http with scheme: dfos, literally accurate: the wire is Authorization: DFOS <jws>, and the scheme token is matched case-insensitively per HTTP carriage (lowercase is RECOMMENDED, matching OpenAPI's registered-scheme convention). x-dfos-typ names the envelope the scheme carries, and under this convention it is REQUIRED on every scheme: dfos security 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. apiKey is OpenAPI's honest generic type for "a token in a named header", and nothing declared this way is a bearer artifact.
  • x-dfos-actions on 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 registered jti member on every write-shaped route and record it by an atomic insert-if-absent into a replay cache keyed on the presenter DID and jti, 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-seen jti is the replayed verdict, 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. exp is 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-Override and kin) MUST NOT be honored; Content-Encoding is settled before hashing by construction; and Content-Type decides how identical octets parse, so a body-bearing route MUST pin exactly one media type, rejecting others before parsing, or bind the normalized Content-Type as 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 through Referer headers, 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-did

dfos-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:

  1. Resolve the chain to its identity state at the head under the core's verification rules and read the DfosOrigin entry. 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.
  2. Query the domain: the DNS method, the HTTPS method with its app-description fallback, or both.
  3. 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 verifies broken. 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 DfosOrigin entry 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 bound to 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 stale as broken turns every hosting blip into a public accusation of contradiction; reporting broken as stale hides 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 the roleSet. 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-256 over the key's multikey string (the UTF-8 bytes of the z… 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:

  1. Size cap. Reject an envelope over 4 KiB before parsing.
  2. Header gates. typ MUST be exactly the registered value the ceremony requires; alg MUST be the algorithm of the payload key's Multikey type; a crit member rejects; an embedded key member rejects; a present kid rejects.
  3. Payload schema, over canonical bytes. Exactly the seven members PROTOCOL specifies, each a string, roleSet in the canonical grammar; any absent, any extra, any non-string, or any non-canonical roleSet member 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.
  4. Audience. On the hosted leg, audience MUST byte-equal the verifier's own configured authority. On the controller-verified leg, audience MUST byte-equal the payload's did.
  5. Position. did MUST name the chain this ceremony introduces to; roleSet MUST equal the role set the ceremony grants; prevCID MUST equal the chain's current head. A prevCID mismatch is a stale envelope, refused without prejudice; the holder re-signs against the current head.
  6. Freshness. timestamp MUST fall within the verifier's acceptance window (RECOMMENDED: 300 seconds, either side).
  7. 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.
  8. 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.

  • 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