A ZenStack v3 community plugin that provides transparent field-level encryption and decryption using the @encrypted attribute.
- AES-256-GCM encryption via the Web Crypto API (no native dependencies)
- Transparent encrypt-on-write, decrypt-on-read through ZenStack's
onQueryplugin hook - Key rotation — add previous keys to a fallback list so existing data can still be decrypted while new writes use the latest key
- Custom encryption — bring your own encrypt/decrypt functions for KMS integration, envelope encryption, etc.
- Nested writes — handles
create,createMany,update,updateMany,upsert, andconnectOrCreateacross relations
The plugin hooks into the ZenStack ORM's query lifecycle via onQuery. When a write operation (create, update, etc.) is performed, the plugin inspects the schema for fields marked @encrypted and encrypts their values before they reach the database. When data is read back, encrypted fields are automatically decrypted before being returned to the caller.
Write path: app → plugin encrypts @encrypted fields → database
Read path: database → plugin decrypts @encrypted fields → app
Encrypted values are stored as a base64 string with the format {metadata}.{ciphertext}, where metadata includes the encryption version, algorithm, and a key digest (used for key rotation lookups). Each encryption uses a random 12-byte IV, so the same plaintext produces different ciphertext every time.
Note: Because the plugin operates at the ORM level, direct Kysely query builder calls (
client.$qb) bypass encryption entirely.
# npm
npm install zenstack-encryption
# pnpm
pnpm add zenstack-encryptionAdd a plugin block to your .zmodel file. This makes the @encrypted attribute available in your schema:
plugin encryption {
provider = 'zenstack-encryption'
}Apply @encrypted to any String field you want to encrypt at rest:
model User {
id String @id @default(cuid())
email String @unique
name String?
secretToken String @encrypted
posts Post[]
}
model Post {
id String @id @default(cuid())
title String
content String? @encrypted
author User @relation(fields: [authorId], references: [id])
authorId String
}npx zenstack generateimport { ZenStackClient } from '@zenstackhq/orm';
import { encryption } from 'zenstack-encryption';
import schema from './schema.js';
// Pass a string secret — it will be derived to a 32-byte key via SHA-256
const client = new ZenStackClient(schema, {
plugins: [
encryption({
key: process.env.ENCRYPTION_SECRET!,
}),
],
});
// Or pass a raw 32-byte Uint8Array if you already have one
// encryption({
// key: new Uint8Array(Buffer.from(process.env.ENCRYPTION_KEY!, 'base64')),
// })
// Fields are encrypted/decrypted transparently
const user = await client.user.create({
data: {
email: 'alice@example.com',
secretToken: 'super-secret-value',
},
});
console.log(user.secretToken); // → "super-secret-value" (decrypted)When you need to rotate encryption keys, pass old keys via previousKeys. The plugin will use the primary key for new writes, but try all keys (key + previousKeys) when decrypting. Both strings and Uint8Array keys can be mixed:
const plugin = encryption({
key: 'new-secret', // used for all new encryptions
previousKeys: ['old-secret'], // tried during decryption alongside key
});This enables zero-downtime key rotation:
- Deploy with both keys configured (new key as primary, old key in
previousKeys) - Existing data encrypted with the old key is still readable
- New writes use the new key
- Optionally re-encrypt old data by reading and updating records
For integration with AWS KMS, HashiCorp Vault, or any other encryption provider, pass custom encrypt and decrypt functions:
import type { FieldDef } from '@zenstackhq/orm/schema';
const plugin = encryption({
encrypt: async (model: string, field: FieldDef, plaintext: string) => {
// Call your encryption service
return await myKms.encrypt(plaintext);
},
decrypt: async (model: string, field: FieldDef, ciphertext: string) => {
// Call your decryption service
return await myKms.decrypt(ciphertext);
},
});The model and field parameters let you use different keys or strategies per model/field.
You can also add the plugin to an existing ZenStackClient instance using $use:
const baseClient = new ZenStackClient(schema);
const client = baseClient.$use(encryption({ key }));When passing a string as key, the plugin derives a 32-byte key using SHA-256. This is not a password-based key derivation function — it does not use salting or iterations. Your string secret should be high-entropy (e.g. a random 32+ character token from a secrets manager, not a human-chosen password).
# Good: generate a random secret
openssl rand -base64 32
# Bad: weak password
ENCRYPTION_SECRET="password123"If you need to derive keys from low-entropy passwords, use a proper KDF (PBKDF2, Argon2) yourself and pass the resulting Uint8Array directly.
- ORM only — only applies to ORM CRUD operations, not direct Kysely query builder calls via
client.$qb - String fields only —
@encryptedcan only be applied toStringfields. Applying@encryptedto non-String fields will log a warning at runtime and be ignored. - No encrypted filtering — encrypted fields cannot be used in
whereclauses,orderBy, or unique constraints. Since encryption is non-deterministic (each encryption produces different ciphertext due to random IVs), queries likewhere: { secretField: 'value' }will never match. If you need to search by a field, don't encrypt it — or store a separate non-encrypted hash for lookups. - Storage overhead — encrypted values are larger than the original plaintext. Expect roughly 80 bytes of overhead per field (IV + GCM tag + metadata + base64 encoding), plus ~37% expansion of the plaintext itself. A 100-character plaintext becomes ~215 characters. Ensure your database columns use
TEXTor a sufficiently largeVARCHAR.
MIT