Skip to main content

@deco.Encrypted

Leverage the @deco.Encrypted decorator to automatically encrypt and decrypt values upon inserting and accessing values from the DB. This works by defining a pseudo-column, whose value is stored in a separate, encrypted field that is persisted to the DB. Any time you access the decorated property, you will receive the payload of decrypting the corresponding encrypted field. Any time you make changes, those will persist to the DB, encrypted and stored in the corresponding encrypted field:

export default class User extends ApplicationModel {
@deco.Encrypted()
public secret: DreamColumn<User, 'encryptedSecret'>
public encryptedSecret: DreamColumn<User, 'encryptedSecret'>
...
}

const user = await User.firstOrFail()
user.secret = 'abc123'
await user.save()

const reloaded = await User.firstOrFail()
console.log(reloaded.secret)
// 'abc123'

By default, a given property that is decorated with the @deco.Encrypted decorator will be expected to have a column in the DB with the same name, but with the word encrypted up front, and camel-cased. This means that if your decorated property is secret, the expected corresponding column in your database will need to be called encryptedSecret. This column will store the encrypted data for retrieval later. To store it in a differently named column, pass that column's name: @deco.Encrypted({ column: 'secretCiphertext' }).

Configuration​

Dream encrypts and decrypts with the encryption.columns keys set in your conf/dream.ts file. This is separate from the cookie encryption configured in conf/app.ts. The generated conf/dream.ts already sets the current key from COLUMN_ENCRYPTION_KEY; without it, reading or assigning an encrypted property throws:

// conf/dream.ts
export default async (app: DreamApp) => {
...
app.set('encryption', {
columns: {
current: {
algorithm: 'aes-256-gcm',
key: AppEnv.string('COLUMN_ENCRYPTION_KEY'),
},
},
})
}

Generate encryption keys with pnpm psy g:encryption-key.

note

Adding @deco.Encrypted to a column that already has data requires more than a rename. A plain rename leaves existing rows as plaintext, which the decorator's getter will fail to decrypt. Use DreamMigrationHelpers.encryptColumn to transform the existing data in the same migration.

Rotating keys​

Dream supports a legacy key beside current, so you can switch keys without breaking existing rows. New values are encrypted with current; if Dream is unable to decrypt a value using the current key, it will attempt to use the legacy key before failing:

// conf/dream.ts
app.set('encryption', {
columns: {
current: {
algorithm: 'aes-256-gcm',
key: AppEnv.string('COLUMN_ENCRYPTION_KEY'),
},
legacy: {
algorithm: 'aes-256-gcm',
// an environment variable your app adds to hold the previous key
key: AppEnv.string('LEGACY_COLUMN_ENCRYPTION_KEY'),
},
},
})

The legacy key only keeps old rows readable; it does not rewrite them. Before dropping legacy, re-encrypt the existing rows under the new key. Once every server in an environment encrypts with the new current, run reencryptEncryptedColumns() for every model with encrypted properties, in that environment, from the console or a script:

await User.reencryptEncryptedColumns()

It walks every row of the model's table, including rows hidden by default scopes such as soft delete, and writes each value back encrypted with current, without running hooks or touching updatedAt. Run it per environment rather than from a migration, since each environment rotates its keys on its own schedule. Once it has run for every model, drop legacy and deploy again. Reading a row still encrypted with a dropped key throws DecryptionError. See rotating column encryption keys for the full workflow.

Sync after adding the decorator​

After adding @deco.Encrypted() and its migration, run pnpm psy sync so the generated types list the plaintext property (e.g. secret) under the model's virtualColumns. Until then it is not assignable in .create()/UpdateableProperties, and tsc reports TS2353 ... 'secret' does not exist even though the model declares the field.

Params and OpenAPI​

extractParams and model-derived OpenAPI request bodies use the public property name (the plaintext, e.g. secret), not the encrypted DB column name. The accepted type is string or string | null, depending on whether the backing encrypted column is nullable, unless the decorator declares an openapi shape (see structured content below). extractParams enforces that type in TypeScript; at runtime it checks only the top-level type, not the properties of an object shape.

Writing the encrypted column directly​

The raw encrypted column (encryptedSecret) has a setter that throws DoNotSetEncryptedFieldsDirectly if you try to assign it directly — write through the plaintext property (secret) instead. The writers that bypass custom setters (setAttribute/setAttributes and updateAttributes) accept a value for encryptedSecret only when it is ciphertext that the app's column keys decrypt, or null, and otherwise throw the same error.

setAttribute('secret', ...) neither encrypts nor stores the value. To store a new value, assign the property (user.secret = 'new secret') or pass it to setAttributes, each of which encrypts it.

Not queryable in a where clause​

An @deco.Encrypted column is not queryable in a where clause. The plaintext property is virtual, not a DB column, so it isn't in the model's Whereable type — where({ secret: ... }) is a compile error. This is correct: the plaintext never exists in the database, and the stored ciphertext is non-deterministic (a fresh IV per write), so even matching the raw encryptedSecret column can't find a row by its plaintext value, and no single-statement compare-and-set UPDATE ... WHERE can filter on the value being replaced.

For an existence/idempotency check, fetch candidate rows by their queryable columns and compare the decrypted property in memory. A true server-side equality lookup needs a separate deterministic (blind-index) column you maintain yourself; Dream does not add one.

Structured content: :encrypted vs :jsonb​

A column that needs both encryption and structured content uses the :encrypted type (which generates a text backing column), not :jsonb. JSONB earns its place by being queryable — GIN indexes, path operators, an index on field->>'key' — and ciphertext is opaque, so none of that survives; what's left is a value you can only read whole.

The encrypted column carries structured content directly — Dream JSON-serializes the value on write and parses it on read, so the plaintext property round-trips objects and arrays as-is, with no model-layer parse/stringify. The generated declaration types the plaintext property from its text backing column (DreamColumn<Place, 'encryptedHouseRules'>, i.e. string | null), so widen it by hand to the structured type and declare the same shape on the decorator. Without openapi, Psychic casts the param and documents the property as a string:

// app/models/Place.ts
@deco.Encrypted({ openapi: { type: ['object', 'null'], properties: { petsAllowed: 'boolean' } } })
public houseRules: HouseRules | null

The decision is whether the data warrants encryption at all: if it does, use :encrypted; if not, it stays jsonb and keeps its query ergonomics.