The master key

Where it lives

Provisioning mints it; rotating it is a deliberate act rather than a routine one; the reference names the binding. It seals only the d1 backend’s rows.

One master key per environment, held as a Cloudflare Secrets Store entry named <project>-<env>-secrets-encryption-keys, bound into the Worker as SECRETS_ENCRYPTION_KEYS.

The key never leaves the Worker. Every encrypt and decrypt runs in-process.

The CLI does not hold it. Which is why every value-touching command is a dispatch to that environment’s manager Workflow rather than a local write — a design consequence you will see in the shape of the rotation ledger, and in why pithy secrets cannot print a value even if you asked it to.

The name is authenticated

Every seal binds the secret’s stored name as AES-GCM additional authenticated data. It is not encrypted; it is bound.

Without that, a ciphertext is valid under the key alone. One lifted from one row and written into another opens cleanly, and the envelope has no opinion about whether it belongs there. Every decision about which secret a caller gets then lives in the query above it — and a bug in that query becomes a disclosure rather than a failure.

With the name bound, a moved ciphertext does not open. Inside the primitive. However that query is later rewritten.

The stored name is the right thing to bind because it is the row’s identity — unique in the table, and for a keyspace member the whole <entry>/<key>. So one tenant’s credential does not open under another tenant’s key.

The bound value is prefixed with a versioned context string for domain separation: a ciphertext from some future construction under the same master key will not open here, and the reverse. A later change to what is bound is therefore a deliberate, visible break rather than a silent one.

Two version axes, and they are not the same axis

The encryption-key version — which master key encrypts new writes, and which key decrypts a given row. Every still-valid key stays in the set, so rows not yet re-encrypted keep opening through the overlap window.

The value version — which value of a secret is current, when a secret carries more than one. A separate concern entirely.

Both are carried in the same uniform { currentVersion, versions } envelope, with versions the still-valid set and currentVersion the active pointer. Version keys are stringified integers, so reading the key set is inherently a full-set read — every still-valid version, never current-only.

Rotating the master key

  1. A fresh AES-256 key is generated and added as the next version, which becomes current. Every prior key is kept.
  2. Rows are re-encrypted in batches under the new current key.
  3. Old keys are pruned — and only once no row references an old version.

Pruning with a row still on an old key is the one way to lose data here, which is why it is a separate step with its own precondition rather than the tail of step two.

A single-key config has nothing to prune, and the prune reports that rather than doing nothing quietly.

No compatibility path for unbound ciphertexts

Ciphertexts written before the name binding existed carry no authenticated data and do not open. They fail as secrets/crypto_failed, like any other unreadable row.

Accepting an unbound ciphertext “just this once” would leave the exact hole the binding closes permanently reachable — a flag that must never be set is better as a branch that does not exist.

What a failure looks like

secrets/crypto_failed is what a name mismatch produces, and what a wrong key produces, and what a corrupted row produces. One code, because from the outside they are one situation: this value cannot be read here.

The distinguishing detail goes in detail, which the HTTP codec strips.

ESC