Local applications
A CLI, an agent, or a desktop app has no domain to serve a dfos-app.json from,
so it signs in on the loopback tier: a redirect to localhost, 127.0.0.1,
or ::1. It shows no name and no domain, and it comes in two shapes.
The two shapes
Anonymous loopback names no client. Nothing is claimed on its behalf and
nothing can be, so it gets identity and nothing else — a signed challenge, the
user's DID, and no standing grant. A set pairing identity with a credential
scope is refused outright rather than quietly narrowed. This is the shape a
local dev server signs in with, and it is the right smoke test for the flow
itself.
Key-proven loopback names a client_did and proves, in the same request,
that it holds that DID's key. That proof is what a credential can be issued
against, so this shape asks for credential scopes like any other tier.
What a key-proven request carries
Beyond the ordinary parameters:
client_did— the protocol DID your local client wants the credential issued to. A per-install identity, minted once and reused, so the consent a user gives names the same party every time.client_proof— the ask proof: a JWS over the same canonical challenge bytes the user will sign, made by a key that is current in that DID's auth set, checked before the consent screen renders at all. It carries its own type,did:dfos:siwd-ask, distinct from thedid:dfos:siwda user's sign-in signature carries, and the verifier requires it exactly.client_chain— that DID's identity chain, carried inline. Send it on first use; once DFOS holds the chain, the request needs onlyclient_didand the ask proof. It is verified by exactly the rules adfos-app.jsoncarriage is, derivation of the namedclient_didincluded.
Two limits apply to a carried chain. The encoded parameter may not exceed 8KiB, which a per-install identity — a genesis plus a few rotations — stays well under, on top of the 100-operation cap. And it must extend what DFOS already holds for that DID: an older copy or a diverging one is refused.
In code
@metalabel/dfos-client/siwd builds this request for you.
mintSiwdClientIdentity mints the per-install identity once; persist its private
key and chain, because minting again yields a different DID.
createSiwdLoopbackLoginRequest takes that identity and your loopback redirect
and returns the authorize URL with the challenge, client_did, ask proof, and —
when you pass it — the chain. readSiwdCallback and verifySiwd finish the
flow exactly as on a hosted app. Where those keys live is
Key custody.
What key custody does not prove
It proves key control and stops there. A verified key is not a verified author, publisher, or binary. Nothing on the DFOS side knows what the software is, who wrote it, or where the user got it — and a key that leaked to a different program on the same machine is still a current key. Treat the tier as "this is a consistent party across sign-ins," never as "this is the software it says it is."
The consent screen says exactly that much and no more.
What the user sees
There is no domain line on either loopback shape, because there is no domain that would be true.
An anonymous loopback request is headed "An app on your device" and carries a single limit row — Nothing about this app can be verified — plus one line of advice: continue only if you just started a sign-in yourself.
A key-proven request is headed Local application and gets two rows, the
proof and its limit in that order: Holds the key for did:dfos:…, and Where
it runs cannot be verified. The DID is shortened head-and-tail to keep the row
one line; in full it is the target of the View on explorer link under the
heading. No name is accepted from a local client, and none is shown.
A key-proven client can be trusted by the user, and an anonymous one never can: trust is tied to a client DID, and an anonymous request names none. See Trust, and the second sign-in. Rotating a key within your app identity's chain keeps the same DID and changes nothing. Minting a new identity does: every user who trusted the old one sees the full re-consent card.
The same "Local application" label follows the grant into Settings → Connected apps, where the user can revoke it like any other. A credential issued here is a platform-managed grant, not a self-custodied session — see Sessions, grants, and keys.
A shorter credential life
A loopback credential expires sooner than a hosted app's; the numbers are in How a credential lives. Build for re-consent as a normal event on this tier.
The worked example: dfos login
The DFOS CLI implements this whole tier, and reading what it does is the fastest way to understand the shape of your own client:
# sign in as the active identity, asking only to prove who they are
dfos login
# ask for a scope that returns a credential
dfos login alice --scope read:profile
# no browser available (a container, an SSH session): print the URL and wait
dfos login --no-browser --timeout 10m
What happens, in order:
- It finds the authorize endpoint from the subject's own chain. The chain is
fetched, re-verified locally, and read for a
DfosAuthorizationServerservice entry — the sign-in server that speaks for that DID. One entry or none: anything ambiguous falls back to an explicit--authorize-urlrather than picking. - It presents a per-install client identity. Minted on first login, its key in the keystore, its DID stable across logins. That DID, its chain, and an ask proof signed by its current auth key go out with the request.
- It listens on a local port and opens the consent screen in a browser.
- It relays the fragment back to itself. A credential arrives in the URL fragment, which a browser never sends to a server, so the listener answers with a page whose script posts the whole URL back and scrubs the address bar.
- It verifies before it stores anything — the returned artifact's type, the signer against a freshly re-fetched chain, and the challenge consumed last, so nothing invalid can spend it. A credential issued to any DID other than this installation's is refused as inert.
Then the credential lands on disk with 0600 and every later call presents it
with a fresh request proof, exactly
as a server-side app would.
The full command reference — flags, storage paths, the JSON output — is the CLI documentation.
Graduating to a domain
A client that starts as a local application and later grows a hosted half
keeps the same DID: bind a domain to that identity, serve a dfos-app.json
carrying the same chain, and the very next request resolves as a hosted app.
What changes is only what the consent screen can say — a domain leading the card
— and the longer credential life of a hosted app. The scopes, the credential,
the request proofs, and the API access are identical on both tiers.
Next
- Sign in with DFOS — the flow itself, and both tiers in one page.
- Scopes and credentials — what to ask for, and how a credential lives and dies.
- Key custody — where your client's keys live, and what losing one costs. The same auth key that signs a request proof signs the ask proof.
- Troubleshooting — including what a refused key-proven request means.
- Loopback clients — the normative rules, including the ask proof's registered type.
- Glossary — ask proof, tier, sign-in server, and the rest of the vocabulary.