Skip to main content

Key custody

Your app's identity is a signed chain, and the keys that sign it are the whole authority. This page is about where they live and what happens when one is gone.

Read it before you deploy. Everything else in the recipe is reversible; the loss described here is not.

Two roles, two homes​

KeyWhat it can do
Controller keySign chain operations — add or rotate keys, bind a domain, delete. The identity's authority.
Auth keySign request proofs and credentials. Everything the running server does.

The split is protocol-level rather than a CLI convention: which role may sign which operation is fixed by the DFOS DID method specification, and what an auth key signs on each API call is API-AUTH.

dfos identity create mints one key, declares it as controller, auth, and assert, and keeps it in one place. For a deployed app you want a second auth key whose secret you can actually hold, so the two roles live on two different machines: the controller key stays on your workstation, and the server gets a key that only ever signs request proofs. A compromised deployment is then a revoke-and-re-add, never a new identity.

Which machine runs what:

  • Controller-key machine — identity create, identity add-key, identity bind-domain, identity well-known --patch. Steps 2, 3, 4, and 5 of the setup recipe all sign or read from the chain.
  • Server — nothing from the CLI. It holds one auth key's secret in its environment and signs request proofs with it.

Where the keys actually live​

The CLI stores each Ed25519 seed under an account of the form key:<public Multikey> — older versions used did:dfos:xxx#key_yyy, and those are still read. A process uses exactly one of two backends:

BackendLocationWhen it is used
OS keychainsystem keychain/keyringDefault, when an OS keychain is reachable
File store~/.dfos/keys/Keychain probe fails, or DFOS_NO_KEYCHAIN is set

On startup the CLI probes the OS keychain with a test write/read/delete cycle. If it succeeds, keys go in the keychain. If it fails — the common case on headless Linux, in containers, and in CI, where no keychain daemon is running — the CLI prints a warning to stderr and falls back to the file store.

dfos status # the `Keys:` line reports `keychain` or `file (<path>)`

Run that before you assume. "It lands in your keychain" is true on a laptop and false on most build hosts. The two backends never read each other: a key written under DFOS_NO_KEYCHAIN is invisible to a run without it, and the reverse. The CLI also never prints a keychain-held seed back out.

The file store is plaintext​

Each key is written to its own file under ~/.dfos/keys/, containing the hex-encoded 32-byte Ed25519 seed in plaintext — it is not encrypted. The directory is 0700 and each file 0600, so the protection is filesystem permissions and nothing more.

  • A seed file grants full signing authority for that key to anyone who can read it. Treat ~/.dfos/keys/ like an SSH private key directory.
  • There is no passphrase, no encryption at rest, and no hardware backing. Disk theft, a permissive backup, a synced home directory, or root on the box all expose the seeds.
  • If you need encryption at rest, run on a host with a working OS keychain or put ~/.dfos/keys/ on an encrypted volume.

Minting an identity without the CLI​

mintSiwdClientIdentity from @metalabel/dfos-client/siwd mints an app identity in JavaScript — one Ed25519 keypair and a genesis operation naming it — and returns the did, the privateKey, and a chain you serve verbatim as identity_chain in your app description. The identity is not tier-specific: one minted this way is an ordinary app identity, and the consent screen, the credential, and the request proofs are identical either way.

import { mintSiwdClientIdentity } from '@metalabel/dfos-client/siwd';

const identity = await mintSiwdClientIdentity();
// persist identity.privateKey AND identity.chain — this call is not repeatable

What changes is custody, and it changes in three ways that all land on this page.

Nothing here touches storage. There is no keychain, no file store, and no dfos status to tell you which one you got — the two backends above simply do not apply. You persist the private key yourself, wherever you decide, with whatever protection you give it.

Minting again recovers nothing. The call is not repeatable in the sense that matters: run it twice and you have a different DID, so every user re-consents and every credential the old DID earned belongs to an identity nothing will ever present again. Losing the key you persisted is therefore worse than the loss described below, not milder.

The chain is one key with one controller. The CLI's chain operations — add-key, bind-domain, well-known --patch — sign from its own keystore, so an identity minted this way does not get them. No vault phrase behind the key it has, so the recovery below does not reach it; no second controller key; no rotation, so a leaked key means a new identity rather than a revoke-and-re-add; and no origin binding, so your domain never attests this DID and the consent screen has one less checked fact to lead with.

The CLI is the recommended path for a hosted app, for exactly those reasons. Reach for this when a Go binary genuinely cannot run where the identity has to be created.

Backup and recovery​

The backup is a vault phrase. A vault is a named seed the CLI derives key material from: dfos vault create mints a 24-word BIP-39 phrase and prints it once, and every identity minted from that vault derives its keys from it. Write those words down somewhere offline. They are the only copy of that seed — there is no second one on any machine, with any relay, or at DFOS.

dfos vault create app # 24 words, printed once
dfos identity create --name my-app --vault app # mints from that seed

A vault is consulted at mint time and never again, so the order matters. An identity create run with --no-vault, or run on a machine that holds no vault at all, mints a standalone key: legal and complete, with no phrase behind it. dfos vault show <name> lists every key a vault minted; a held key no vault names is standalone.

If the machine holding your controller key is gone — lost laptop, wiped keychain, deleted container — recovery on a fresh one is two commands:

printf '%s' "$PHRASE" | dfos vault import restored
dfos recover --vault restored --peer prod

recover rederives the keys the phrase covers, asks a relay's identity index which of them a chain has ever declared, and reinstalls the private keys and chains it finds — including a key a rotation left behind. --dry-run reports what a real run would restore and writes nothing; --scan-depth N widens the walk when the report says it stopped short. The derivation path, the gap limit, and what one relay's silence does and does not mean are in the CLI documentation.

A standalone key has no second copy​

For a key no vault minted, the keystore that held it was the only copy. Here is exactly what you lose and what survives.

Gone permanently:

  • You can never sign another chain operation for that identity. The published client_did is frozen as it stands.
  • You cannot rotate or revoke the server's auth key. If that key ever leaks, your only remedy is retiring the identity.
  • You cannot bind or re-bind a domain, and you cannot re-patch identity_chain into your app description after a change.

Still works:

  • Credentials already issued to your app keep working — they are addressed to the DID, and the DID still resolves.
  • The server keeps signing request proofs with the auth key it already holds, so live API access does not break.
  • The app description keeps serving, and new sign-ins keep succeeding, until you need to change something signed.

The failure is not loud. Nothing breaks the day you lose the key; you find out months later, the first time you need to rotate something.

Drill the phrase before you need it​

An identity can hold many controller keys and many auth keys, and any one current key in a role set can sign (see the DID method specification). Putting a key on a second machine is availability rather than backup, and it is not something the CLI arranges between two machines you own: identity add-key signs only for a key the machine running it already holds, and a key on another device enters a chain by proving itself to a key-add ceremony that an operator custodying the chain runs (key possession, and the key ceremonies walkthrough).

So for a hosted app whose identity the CLI minted, the resilience in your hands is the phrase — and a drill that proves the phrase is the right one:

  1. Keep the 24 words offline, apart from the machine that holds the keys.

  2. Periodically, on a scratch machine or a disposable container with its own HOME, adopt the phrase and predict the restore:

    printf '%s' "$PHRASE" | dfos vault import drill
    dfos recover --vault drill --peer prod --dry-run

    Read the report: it names each identity the phrase reaches and each key a real run would install. A phrase you have never exercised is a backup you are guessing about.

Set this up before you deploy, not after.

Getting the auth key onto a deployed server​

This is the second half of step 2, and the demo's canonical deployment was provisioned by exactly this sequence.

1. Mint a second auth key whose secret you can hold. identity add-key signs only for a key held in the same keystore as the controller key, and only the file store gives you a seed you can read. So this path needs the whole identity on the file store, with DFOS_NO_KEYCHAIN=1 set from vault create onward, as step 2 does. With it still set:

# writes the seed to ~/.dfos/keys/ and prints the key id and public Multikey
dfos identity device-pubkey --as my-app

# declare it in the auth set, signed by the controller key in the same store
dfos identity add-key --as my-app --auth-key --pubkey z6Mk<from above>

The tradeoff is that the controller key and the vault phrase sit in plaintext files on your workstation too, under the protections described in The file store is plaintext. An identity whose controller key is in the OS keychain cannot add a key whose seed you can read; mint a new one under the file store instead.

2. Name the key. DFOS_APP_KID is the full DID URL — did:dfos:<id>#key_<id> — for the new key. Its DID half is your client_did, and it must match the client_did in your app description: the platform issues the credential to the DID it resolves from that file, and the API checks that the proof's signer is that same DID.

3. Convert the secret. The seed file is ~/.dfos/keys/key%3A<public Multikey>, named for the z6Mk… value device-pubkey printed, and it holds 64 hex characters. DFOS_APP_PRIVATE_KEY wants the same 32 bytes as 43 base64url characters. This format mismatch is a silent trap — paste the file contents raw and the only symptom is a 401 from the API several steps later, with nothing local to compare against.

node -e "console.log(Buffer.from(require('fs').readFileSync(process.argv[1],'utf8').trim(),'hex').toString('base64url'))" \
~/.dfos/keys/key%3Az6Mk<from above>

Pipe it straight into your platform's secret store rather than through a clipboard or a shell history — with the Vercel CLI, for instance:

node -e "console.log(Buffer.from(require('fs').readFileSync(process.argv[1],'utf8').trim(),'hex').toString('base64url'))" \
~/.dfos/keys/key%3Az6Mk<from above> \
| vercel env add DFOS_APP_PRIVATE_KEY production --sensitive

4. Delete the seed file. Once the deployment's environment holds it, that is the copy that matters, and a second copy sitting unencrypted in ~/.dfos/keys/ is pure downside.

5. Redeploy, then check that it landed. Environment changes do not reach running functions until the next deployment. Then prove the secret is the right one by deriving its public half back and eyeballing it against the chain — the technique is in Troubleshooting.

The key never leaves the server. A browser cannot hold one non-extractably, so the supported shape is a backend-for-frontend: the browser holds an ordinary session, and the backend signs.

Rotating after a compromise​

A leaked auth key is the case this whole two-key arrangement was built for, and it is a routine operation:

  1. On the controller-key machine, add a fresh auth key — device-pubkey then add-key --auth-key, exactly as above.

  2. Set DFOS_APP_KID and DFOS_APP_PRIVATE_KEY to the new key and redeploy. Verify the derived public key before you go further.

  3. Remove the compromised key from the chain's auth set, signed by the controller key.

  4. Re-patch and redeploy your app description so the chain you publish carries the rotation:

    dfos identity well-known my-app --patch public/.well-known/dfos-app.json

    The chain you serve must extend the one DFOS already holds, so make sure no older deploy is still serving the pre-rotation chain; DFOS refuses it.

  5. Make DFOS pick up the rotation now. It is not immediate. DFOS verifies your request proofs against the chain it holds, which updates at the next sign-in that issues your app a credential, or periodically on its own. Until then the compromised key can still sign proofs DFOS accepts. Right after the redeploy, sign in to your own app once with a credential scope and approve it. If you cannot, contact DFOS.

Standing credentials survive a rotation. They are addressed to the DID, not to a key, so your users do not have to sign in again.

A leaked controller key is not a rotation, it is an incident: whoever holds it can add their own keys and bind their own domain. If you hold another controller key, rotate the compromised one out immediately. If you do not, the identity is no longer solely yours and the honest move is to mint a new one and republish.

Next​

  • Set up Sign in with DFOS — the full recipe, with this page's provisioning as step 2.
  • Origin binding — what a controller key signs when you claim a domain.
  • Troubleshooting — diagnosing a 401, including the derive-the-public-key-back check.
  • Glossary — controller key, auth key, request proof, and the rest of the vocabulary.