Providing encryption keys
A field declared compliances: [{type: encrypted}] is encrypted with AES-256-GCM on write and
decrypted on read. The key is not part of the schema — the schema names a key, the deployment
supplies it — so declaring the field is half the job, and this page is the other half.
A deployment with no key does not fall back to plaintext. Writes to an encrypted field are
refused, 500 with details.code encryption_key_missing, and the boot log names every field
that will refuse. That is deliberate: a value declared confidential is never stored in the clear,
and never under a key that is not a secret.
Three statements, one name
Section titled “Three statements, one name”A field says which key protects it. The datastore says where that key comes from. The deployment overrides that where it must. The key name is what joins them.
1. The field names a key
Section titled “1. The field names a key”entities: - name: employee fields: - name: date_of_birth type: date compliances: - type: pii - type: encrypted key: pii_key # this field's key
- name: annual_ctc type: money compliances: - type: pii - type: encrypted key: financial_key # a different key, same entity - type: mask value: '***'
- name: ssn type: string compliances: - type: encrypted # no key: → "default"
- name: passport_no type: string compliances: - type: pii # classified, no at-rest choice → encrypted under "default"Two fields on one entity can hold genuinely different keys. That is the whole point of the name: a compromise of one does not read the other, and they can be rotated and destroyed separately.
A field with no key: uses default, which is also what an implicitly-encrypted classified field
(pii, phi, pci, gdpr) uses — so a deployment that has not thought about key separation
needs default and nothing else.
2. The datastore says where the key comes from
Section titled “2. The datastore says where the key comes from”On the datastore that holds those entities:
datastores: - name: main type: postgresql encryption: keys: - name: pii_key key: vault://secret/erp/{cept}/pii-key # one key per tenant - name: financial_key key: vault://secret/erp/{cept}/financial-key - name: default key: vault://secret/erp/{cept}/default - name: signing key: vault://shared/signing-key # one key, deployment-wideOne entry per key name. Two rules and nothing else to learn:
- A value beginning
vault://is read from the secret store, under the name after the scheme. Anything else is the key material. - A vault path may name the request’s tenancy —
{customer},{env},{product},{tenant},{cept}— and that is what makes a key per tenant. A path with no placeholder is one secret for the whole deployment.
That second rule is the important one: whether a key is shared is stated in the document, not inferred from code. An operator reading the block knows which without reading Go.
The declaration site is a datastore, but the key namespace is not per datastore — the names are merged across every datastore in the composed schema, because a field asks for a name and nothing else. Two datastores declaring the same name with different sources is refused at boot, naming both; declaring it identically in two places is fine.
3. The deployment overrides by name
Section titled “3. The deployment overrides by name”properties: encryption: keys: - name: financial_key key: vault://the-operators-own-path # a different vault path - name: imported key: "literal key material" # no vault at allMerged by name, and the deployment wins — the same layering as the datastore type:, where the
schema states the intent and the deployment holds the physical truth. A name only the product
declares keeps the product’s source; a name only the deployment declares is the deployment’s alone.
A key whose vault secret is missing or empty is an error naming the path it resolved to, never a
silent fallback to some other key:
field-encryption: key "payments": vault secret "secret/erp/acme/test/shop/t1/payments" is empty.
A malformed entry is named at boot and skipped — it does not take the well-formed keys with it — and the fields that wanted it then refuse their writes:
field-encryption: properties.encryption.keys[1] (payments): no key — give it material, or a vault://… sourcefield-encryption: datastores declare key "pii_key" with two different sources ("vault://a" and "vault://b");a field asking for it could mean eitherWhat the key material becomes
Section titled “What the key material becomes”Whatever the source gives is a passphrase, not a raw key: it is stretched to a 32-byte AES-256 key with SHA-256, bound to the key id, so two names never collapse onto one key even when the material behind them is the same. A tenant-scoped key binds the tenant as well. You do not need to supply 32 bytes, and a long random string is a better passphrase than a short one.
Vault reads are cached per (tenant, key id), so a key costs one round trip per tenant per key rather than one per write.
Without the block: the legacy ladder
Section titled “Without the block: the legacy ladder”A deployment that has declared no keys at all — neither on a datastore nor in properties — keeps
the older arrangement, unchanged. A key name not covered by the block falls through to it too.
| # | Source | Configure | Use it for |
|---|---|---|---|
| 1 | Vault, by convention | Register a secret store (vault.url, or vault.file for the file-backed local one); the key lives at fieldkeys/<customer>/<env>/<product>/<tenant>/<name>, or under the bare <name> for a tenant that has none | A deployment predating the encryption: block |
| 2 | Local passphrase | properties.fieldencryptionkey | A deployment with no vault yet. Warns at boot |
| 3 | Development key | properties.fieldencryptiondevkey: true | Local development only. Never a deployment |
Here per-tenant keys are a convention with a probe rather than a declaration: the service asks
the vault whether fieldkeys/<cept>/<name> exists, uses it if so, and otherwise falls back to the
service-wide secret — saying so once per tenant:
field-encryption: this tenant has NO key of its own, so its encrypted fields share one key withevery other tenant on this deployment — a single compromise reads them all, and the tenant cannotbe crypto-shredded. Create the vault secret to give it one secret=fieldkeys/acme/test/shop/t1/defaultWithout a vault there is nothing to probe, so the local passphrase derives a different key per tenant by itself. One passphrase, many keys.
Prefer the encryption: block for anything new. It says in the document what the probe has to
discover at runtime, it lets one key be deliberately shared and another deliberately not, and it
can point at a customer-held path the convention has no way to express.
A missing or empty secret is an error naming the secret, not a silent fallback:
field-encryption: vault secret "payments" is empty.
The local passphrase
Section titled “The local passphrase”With no vault registered, one passphrase serves every key name:
properties: fieldencryptionkey: "a long random string from your secret manager"The boot log says what this costs, once per process:
field-encryption: using the LOCAL key from properties.fieldencryptionkey — no vault isregistered, so the key is only as protected as the boot config and rotation is manual.Different key names still derive different AES keys (the id is bound into the derivation), so
default and payments are not interchangeable even when both come from this one passphrase.
The development key
Section titled “The development key”properties: fieldencryptiondevkey: trueThis uses a constant compiled into the binary. Anyone holding the binary can decrypt every field it wrote, which is why it is never the default and why the boot log repeats it on every start. It exists so a developer can run the service without inventing a key, and for nothing else.
Rotating a key without re-encrypting
Section titled “Rotating a key without re-encrypting”Every ciphertext carries the id of the key that wrote it. Rotation therefore does not require rewriting rows first:
-
Publish the next key as a new entry named
<key>.v<n>—default.v2,payments.v3. With theencryption:block that is another entry beside the first:datastores:- name: mainencryption:keys:- name: defaultkey: vault://secret/erp/{cept}/default- name: default.v2key: vault://secret/erp/{cept}/default-v2 # the new oneOn the legacy ladder it is instead a vault secret at the tenant’s path (
fieldkeys/<cept>/default.v2), or locally a block under the key’s name:properties:fieldencryptionkeys:default:v2: "the next passphrase" -
Point the current version at it — this part is the same either way:
properties:fieldencryptionkeyversion:default: 2 -
Restart. New rows are written under
default.v2; rows written underdefaultkeep decrypting, because their envelope says which key made them. -
Rewrite the old rows when convenient — reading and writing a row back re-encrypts it under the current id. Until then both coexist.
Keep the old entry for as long as any row still carries its id. Deleting it makes those rows undecryptable, and nothing will warn you first.
What a key id means at rest
Section titled “What a key id means at rest”Ciphertext is stored as $enc$<key id>$<base64>, so the key that wrote a value is known when
reading it, whatever the current key is. That is what makes every change on this page
non-breaking — a row written before any of it keeps decrypting, because its id still resolves the
way it did when it was written.
| Id in the envelope | Resolved by |
|---|---|
payments | the declared entry payments — or, with no encryption: block, the legacy ladder |
t:payments | the same, with the tenant of the reading request |
payments.v2 | the declared entry payments.v2 |
The hash salt
Section titled “The hash salt”compliances: [{type: hash}] is one-way, so it takes no key — but it does take a salt, from the
vault secret hash-salt, or properties.fieldhashsalt without a vault. Without one the hashes
are unsalted and the boot log names the fields:
boot: fields declare hash but no salt is configured (vault secret hash-salt orproperties.fieldhashsalt); their hashes are unsalted fields=customer.email_hashChanging the salt changes every future hash, so existing hashes stop matching new ones. Treat it as set-once.
Tokenized fields need a store, not a key
Section titled “Tokenized fields need a store, not a key”compliances: [{type: tokenize}] does not encrypt. The value is filed elsewhere and the row holds
an opaque tk_ token, so the configuration is a store:
properties: tokenizer: vault # production: the value goes to the configured vaultThe vault tokenizer files each value under tokens/<customer>/<env>/<product>/<tenant>/<token>, so
only that tenant’s requests can reverse a token; reads are cached in process for five minutes.
tokenizer: memory keeps tokens in the process and loses them on restart — development and test
only. With neither, writes to a tokenized field are refused with tokenizer_missing, the same way
encryption refuses.
Checking what a deployment is using
Section titled “Checking what a deployment is using”The boot log is the authority, and it says one of these once per process:
| Log line | Meaning |
|---|---|
declared keys are serving keys=… from_schema=… from_properties=… | The encryption: block is in force, and the line says which names came from the schema and which from the deployment |
| (nothing) | No block; a vault is registered and serving keys by convention — this case is silent |
using the LOCAL key from properties.fieldencryptionkey | No block, no vault |
using the built-in DEVELOPMENT key | Not a deployment |
NO KEY CONFIGURED — writes to every encrypted field will be refused | Nothing configured. Also names the fields |
Errors naming an entry (properties.encryption.keys[1] (payments): no key) sit above whichever of
these applies: those entries were skipped, and only the fields that asked for them are affected.
If writes are being refused, the error carries the reason rather than a generic failure:
{"errors":[{"code":"create_failed","details":{"code":"encryption_key_missing"}}]}What turning encryption on does to existing rows
Section titled “What turning encryption on does to existing rows”Nothing, until a row is rewritten. The read path leaves a value it cannot decrypt exactly as it found it, so a table of plaintext rows stays readable after the declaration is added, and rows become ciphertext as they are written. Re-encrypting the rest is a migration you run deliberately — there is no background pass.
The reverse is not true: once a value is encrypted, removing the key makes it unreadable. Removing
the encrypted declaration does not decrypt anything either — it stops new writes being encrypted
while the old ciphertext stays as it is.
Related
Section titled “Related”- Field protection and data classification — the
compliances:vocabulary, whatencryptedmeans alongside masking, and thedecryptreveal action that gates who sees plaintext - Configuration — every
properties.*andvault.*key in one table - Errors —
encryption_key_missing,tokenizer_missing