Skip to content
Talk to our solutions team

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.

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.

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-wide

One 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.

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 all

Merged 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://… source
field-encryption: datastores declare key "pii_key" with two different sources ("vault://a" and "vault://b");
a field asking for it could mean either

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.

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.

#SourceConfigureUse it for
1Vault, by conventionRegister 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 noneA deployment predating the encryption: block
2Local passphraseproperties.fieldencryptionkeyA deployment with no vault yet. Warns at boot
3Development keyproperties.fieldencryptiondevkey: trueLocal 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 with
every other tenant on this deployment — a single compromise reads them all, and the tenant cannot
be crypto-shredded. Create the vault secret to give it one secret=fieldkeys/acme/test/shop/t1/default

Without 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.

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 is
registered, 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.

properties:
fieldencryptiondevkey: true

This 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.

Every ciphertext carries the id of the key that wrote it. Rotation therefore does not require rewriting rows first:

  1. Publish the next key as a new entry named <key>.v<n> — default.v2, payments.v3. With the encryption: block that is another entry beside the first:

    datastores:
    - name: main
    encryption:
    keys:
    - name: default
    key: vault://secret/erp/{cept}/default
    - name: default.v2
    key: vault://secret/erp/{cept}/default-v2 # the new one

    On 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"
  2. Point the current version at it — this part is the same either way:

    properties:
    fieldencryptionkeyversion:
    default: 2
  3. Restart. New rows are written under default.v2; rows written under default keep decrypting, because their envelope says which key made them.

  4. 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.

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 envelopeResolved by
paymentsthe declared entry payments — or, with no encryption: block, the legacy ladder
t:paymentsthe same, with the tenant of the reading request
payments.v2the declared entry payments.v2

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 or
properties.fieldhashsalt); their hashes are unsalted fields=customer.email_hash

Changing the salt changes every future hash, so existing hashes stop matching new ones. Treat it as set-once.

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 vault

The 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.

The boot log is the authority, and it says one of these once per process:

Log lineMeaning
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.fieldencryptionkeyNo block, no vault
using the built-in DEVELOPMENT keyNot a deployment
NO KEY CONFIGURED — writes to every encrypted field will be refusedNothing 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.