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 both and puts both in the same 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 machineidentity 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 key of the form did:dfos:xxx#key_yyy, in 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 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 second controller key, which makes the 1-of-N mitigation below unavailable; 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.

There is no backup and no recovery

State this plainly to yourself before you go further: the CLI has no key export, no mnemonic seed phrase, and no encrypted backup today. Key backup/recovery is on the deferred list, not shipped.

So if the machine holding your controller key is gone — lost laptop, wiped keychain, deleted container — 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.

The mitigation is 1-of-N, and it is set up in advance

An identity can hold up to 256 controller keys and 256 auth keys, and any one current key in a role set can sign — a protocol property, not a CLI one, laid out under key rotation in the DID method specification. Put a controller key on a second machine and losing the first one is an inconvenience rather than an ending — the survivor can keep signing and can rotate the lost key out.

The handoff never moves a private key. The second machine (B) generates its own keypair locally and only its public half crosses to the machine holding a controller key (A):

# 1. On B: get the chain locally.
dfos identity fetch my-app --peer prod --name my-app

# 2. On B: generate a device key. The private seed stays on B.
dfos identity device-pubkey
# ID: key_...
# Public key: z6Mk...

# 3. Hand the id + public key to A — copy/paste, QR, air-gap. Public only.

# 4. On A: add it, signed with A's held controller key.
dfos identity add-key --controller-key --id key_... --pubkey z6Mk... --peer prod

# 5. On B: re-fetch so B sees its now-in-chain key.
dfos identity fetch my-app --peer prod

Two things about this that bite:

  • device-pubkey defaults to the auth role. The role is decided by A's add-key flags (--auth-key vs --controller-key), not by B. For an availability backup you want --controller-key; granting one is a higher-trust act, since a controller can rotate, delete, and add further keys.
  • B must re-fetch after add-key propagates. In between, B holds a private key that is not yet in the published set, and a publish attempt reports "no held auth key" until B syncs.

Do this before you deploy, not after. There is no way to add a key once every device key is lost — add-key itself must be signed by a held controller key.

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. Force the file-store backend so a seed lands on disk where you can read it:

# writes the seed to ~/.dfos/keys/ and prints the id + public Multikey
DFOS_NO_KEYCHAIN=1 dfos identity device-pubkey --as my-app --json

# graft it into the chain's auth set, signed by the keychain controller key
dfos identity add-key --auth-key --id key_<from above> --pubkey z6Mk<from above>

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 at ~/.dfos/keys/<did>__<key> 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/<did>__<key>

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/<did>__<key> \
| 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

    DFOS re-checks your document periodically anyway, so a rotation is picked up on its own within a few hours — the re-patch is how you make it prompt.

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.