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
| Key | What it can do |
|---|---|
| Controller key | Sign chain operations — add or rotate keys, bind a domain, delete. The identity's authority. |
| Auth key | Sign 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:
| Backend | Location | When it is used |
|---|---|---|
| OS keychain | system keychain/keyring | Default, 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_didis 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_chaininto 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:
-
Keep the 24 words offline, apart from the machine that holds the keys.
-
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 drilldfos recover --vault drill --peer prod --dry-runRead 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:
-
On the controller-key machine, add a fresh auth key —
device-pubkeythenadd-key --auth-key, exactly as above. -
Set
DFOS_APP_KIDandDFOS_APP_PRIVATE_KEYto the new key and redeploy. Verify the derived public key before you go further. -
Remove the compromised key from the chain's auth set, signed by the controller key.
-
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.jsonThe 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.
-
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.