# Field-level encryption in JavaScript: how to do it without shooting yourself in the foot

*Published on 2026-05-27T00:00:00.000Z*

*By Dan Draper — CEO and Founder*

Ask an LLM how to encrypt a field in TypeScript and you'll get a working AES-GCM snippet — and a hidden pile of footguns around keys, modes, and ORM plugins. Here's what actually goes wrong, and how @cipherstash/stack avoids it on Node.js, Next.js, Cloudflare Workers, and Deno.

## Content

Ask ChatGPT, Claude, or Gemini how to do field-level encryption in a JavaScript app and you will get the same answer, give or take a few imports:

```ts
import crypto from 'node:crypto'

const key = Buffer.from(process.env.FIELD_KEY!, 'base64') // 32 bytes
const iv  = crypto.randomBytes(12)                         // GCM nonce

const cipher = crypto.createCipheriv('aes-256-gcm', key, iv)
const ct = Buffer.concat([cipher.update('alice@example.com', 'utf8'), cipher.final()])
const tag = cipher.getAuthTag()

// store iv + tag + ct alongside the row
```

This works. It is also doing four risky things at once, and once you wire it into a real app — through an ORM, across multiple processes, with a key that lasts longer than the demo — at least one of them will bite. This post walks through what AI tutorials don't tell you about field-level encryption in JavaScript: where the keys actually have to live, why mode choice matters more than people admit, why ORM "encrypt-on-save" plugins are the worst of all worlds, and what to use instead.

## What "field-level encryption" actually buys you

Traditional encryption-at-rest techniques like disk encryption and TDE protect against someone walking off with a hard drive. They do nothing once an attacker has a database connection, a backup, or an over-permissioned admin account — which is how almost every real breach unfolds. They also do nothing for the most common case: your application has a single, broadly-privileged connection to the database, so an authorization bug *inside the app* sees plaintext the same way a legitimate request does. Field-level encryption (FLE) means the sensitive columns are ciphertext *inside* the database. A leaked dump, a compromised read replica, a curious DBA, an IDOR bug in your API — all see opaque bytes. The key lives in your application, not in the database.

For regulated data — PII under GDPR/CCPA, PHI under HIPAA, cardholder data under PCI DSS — FLE is one of the few controls that survives a database compromise or mitigates against application vulnerabilities. It is also one of the easiest to do badly.

## The browser key-management trap

The first thing the AI-generated snippet doesn't tell you is *where the key comes from*. That matters a lot, because the strength of the entire scheme reduces to "wherever the key is, that's the trust boundary."

It's very tempting to push encryption into the browser. "Encrypt on the client, server never sees plaintext" sounds like end-to-end encryption. It almost never is.

- If the key is shipped in the JavaScript bundle, every user — including the attacker — has it.
- If the key is fetched from the server at runtime, the server holds it and the threat model is identical to encrypting on the server.
- If the key is derived from a user password in the browser, every password reset means re-encrypting everything the user owns — and you have no way to share that data with a second user, a support agent, or a backup process without effectively giving them the password too.
- If the key sits in `localStorage`, every XSS bug is a key compromise.

Real client-side encryption — the kind that protects against your own server — requires user-bound keys: passkeys with the WebAuthn PRF extension, hardware-backed keychains, or per-user wrapped keys with explicit recovery flows. That is a different product, and it has different tradeoffs (lose the device, lose the data).

For 95% of "we should encrypt the SSNs" projects, the right boundary is the **application runtime**, not the browser DOM. Encrypt in your Next.js server actions, your Express route, your Cloudflare Worker, your Deno edge function — anywhere you control. The key lives in a KMS, never in the database, never in the bundle.

## Mode choice, and the attacks that come for free if you get it wrong

Once the key has a home, the next question is *how* to encrypt. The AI snippet picks AES-GCM, which is the right call. It is worth understanding why, because a surprising number of production systems are still on the wrong side of this.

You want an **authenticated** mode — also called AEAD (Authenticated Encryption with Associated Data). AEAD gives you confidentiality *and* integrity in one step.

The headline pitfall in AES-GCM is the nonce (sometimes called the Initialisation Vector, or IV). Every encryption with the same key needs a *unique* nonce. If two messages are ever encrypted under the same (key, nonce) pair, GCM breaks catastrophically — the XOR of the two plaintexts leaks immediately, and the authentication key is recoverable, which means an attacker can forge new ciphertexts at will. This is not "a little weakened" — it is total. And the standard nonce is only 96 bits, so "just generate a random one every time" gets uncomfortably close to a collision once you're encrypting at scale.

There are two clean ways out of this trap, and CipherStash uses one of them:

- **AES-256-GCM-SIV** — "Synthetic IV." A nonce-misuse-resistant variant of GCM, only fractionally slower, where reusing a nonce is no longer catastrophic; the worst case is that two identical plaintexts produce identical ciphertexts. This is what `@cipherstash/stack` uses under the hood.
- **XChaCha20-Poly1305** — extended-nonce ChaCha20 with a Poly1305 MAC. 192 bits of nonce instead of 96 makes random nonce collisions a non-issue, and it is faster than AES on hardware without AES-NI (mobile, some edge runtimes).

The modes you will see in legacy code, and what goes wrong:

| Mode | Footgun |
|---|---|
| **ECB** | Encrypts identical plaintext blocks to identical ciphertext blocks. The [ECB penguin](https://en.wikipedia.org/wiki/Block_cipher_mode_of_operation#Electronic_codebook_(ECB)) is the canonical demo. Never use it for anything. |
| **CBC without a MAC** | Vulnerable to padding-oracle attacks and bit-flipping. The original [Vaudenay attack](https://www.iacr.org/cryptodb/data/paper.php?pubkey=384) recovers plaintext byte-by-byte from a server that just tells you "padding error" vs "not found." |
| **GCM with a reused nonce** | Catastrophic, as above — full authentication-key compromise. Use GCM-SIV (or an extended-nonce mode like XChaCha20-Poly1305) if you cannot guarantee uniqueness. |
| **MAC-then-encrypt** | Subtly broken; AEAD modes do encrypt-then-MAC internally, which is the order you actually want. |

The hard part isn't picking the mode. The hard part is keeping it correct as your code base grows: a per-record fresh nonce on every write, never reused, never derived from the plaintext, never zero. The Node `crypto` snippet at the top gets this right with `crypto.randomBytes(12)`; the question is whether the seventh engineer to touch the encryption helper still gets it right after refactoring it for "performance." Using a nonce-misuse-resistant mode like GCM-SIV is how you stop having to trust them.

## Field encryption breaks your queries (and your ORM hates that)

The other thing the snippet does not tell you is that the moment a column becomes ciphertext, every query that used to touch it stops working. `WHERE email = $1` returns nothing. `ORDER BY last_name` becomes "order by random bytes". `LIKE '%@cipherstash.com'` is meaningless. `UNIQUE` constraints become uniqueness-of-ciphertext, not uniqueness-of-plaintext — so two identical emails encrypted with different nonces both insert, silently.

The naive workaround — encrypting the *ciphertext itself* deterministically (same plaintext → same stored bytes) so equality lookups keep working — collapses your data and your search index into one structure. ORM "encrypt-on-save" plugins almost universally pick this trade for you without saying so. Real searchable-encryption schemes (like CipherStash's [EQL](https://github.com/cipherstash/encrypt-query-language)) keep the two separate: the stored ciphertext stays non-deterministic, while *separate* encrypted search structures sit alongside it as indexable columns the planner can use for equality, range, and free-text search — all without the database ever seeing plaintext.

If you skip this problem, you will rediscover it the first time someone asks "can you just look up the user by email for me?"

## Key rotation and envelope encryption

The final thing the snippet leaves out is what happens on day 200, when the engineer who wrote it has left and the security team wants the encryption key rotated.

If you have a single key in `process.env.FIELD_KEY` and a million encrypted rows, rotating it means re-encrypting all million rows in a single migration window — or running with two keys forever and a custom "which key was this written with?" tag on every row. Nobody does this. The key never rotates.

The grown-up answer is **envelope encryption**:

- A long-lived **Key Encryption Key (KEK)** lives in a KMS (AWS KMS, Azure Key Vault, CipherStash ZeroKMS, or a dedicated key-management service).
- Each record — or batch, depending on design — gets its own short-lived **Data Encryption Key (DEK)**, generated on the fly.
- The DEK encrypts the field; the KEK encrypts the DEK; the wrapped DEK ships alongside the ciphertext.
- Rotating the KEK rewraps the DEKs (cheap) without rewriting the underlying ciphertext.

This is also how AWS and every serious encryption product handle keys internally. It is not technology you want to build yourself in 200 lines of TypeScript.

## The problem with existing KMS

Most cloud KMS offerings — AWS KMS being the canonical example — were designed for envelope encryption at the *object* level: encrypt this S3 bucket, decrypt this disk, wrap this RSA private key. They are excellent at that. They are not designed for the per-record, per-value access patterns that field-level encryption produces, and that mismatch shows up in two ways.

The first is throughput. AWS KMS has no real batch API: every DEK you generate is one network round-trip and one rate-limited request. Encrypting a million-row table or doing a bulk re-key is impractical without batching the load yourself, which leads almost every team to the same workaround — **data key caching**. A single DEK is generated once and reused to encrypt many records.

Caching keeps ingest fast, but it makes the cache itself an attack vector: anything that can read process memory now sees a key that decrypts thousands of records, not one. It widens the breach surface area, and — because the same DEK is used across many rows — it also makes forensics much harder. You can no longer ask "exactly which records did the compromised key open?", because the answer is "every record that DEK ever encrypted."

The second problem is that caching only helps the side of the workload that already runs in bulk: writes. Query patterns almost never match insertion patterns — a `WHERE user_id = $1` lookup touches one record at a time, in an order nobody predicted at insert time — so the cache rarely helps reads, which is where most production load is.

The fix is a KMS designed for the FLE access pattern: native batch operations, no caching trade-off, and one DEK per value. [ZeroKMS](https://cipherstash.com/docs/stack/cipherstash/kms) is that KMS, and it sits underneath `@cipherstash/stack`.

## Why ORM "encrypt-on-save" plugins make this worse

The Node ecosystem has plenty of plugins that promise field-level encryption with one line in your schema: `prisma-field-encryption`, `typeorm-encrypted`, `mongoose-field-encryption`, `@47ng/cloak`, and the long tail. They are well-meaning and they almost all share the same fundamental shape:

- A single static key from an environment variable.
- AES-CBC (in older plugins) or AES-GCM with the **same key for every value in the table** — no envelope encryption, no per-record key derivation.
- Deterministic encryption applied to the **ciphertext itself** so equality lookups keep working — collapsing the stored data and the search index into one structure.
- No key rotation story beyond "stop the world and re-encrypt."
- No authenticated metadata — the encrypted value can usually be silently swapped between rows without the ORM noticing.

The result is that an ORM-plugin-encrypted database raises the bar for an attacker from "read the column" to "read the column *and* the `FIELD_KEY` env var", which on a typical breach is the same compromise. The plugins are convenient enough that they get reached for anyway, because the alternative looks like writing the snippet at the top of this post yourself, except worse because now it lives in five places.

What you want is an FLE layer that:

1. Uses AEAD with per-record nonces, always.
2. Manages keys through a KMS — your KMS — with envelope encryption built in.
3. Lets you search on encrypted columns *without* falling back to single-key deterministic encryption.
4. Works in your application runtime *and* in your edge runtime, because half your code now runs in Workers.
5. Is something an engineer who is not a cryptographer can use without booby-trapping themselves.

## What `@cipherstash/stack` does instead

[`@cipherstash/stack`](https://github.com/cipherstash/stack) is an open-source TypeScript SDK for field-level encryption. Under the hood:

- AEAD (AES-256-GCM-SIV) with a fresh nonce per encryption — nonce reuse is non-catastrophic by construction.
- Envelope encryption against [ZeroKMS](https://cipherstash.com/docs/stack/cipherstash/kms), which brokers a fresh per-value DEK for every encryption and decryption — no caching trade-off, no shared-DEK blast radius. Optionally rooted in your own AWS KMS account. CipherStash never sees your keys or your plaintext.
- Searchable encryption via [EQL](https://github.com/cipherstash/encrypt-query-language) — equality, free-text, range, and JSONB queries over ciphertext, using Postgres indexes the planner uses automatically.
- Identity provider integration with support for Auth0, Okta, Clerk, and Supabase Auth — so the encryption client knows *which* end user a request is on behalf of, without you wiring it up.
- Runs in Node.js (Express, NestJS, vanilla), Next.js server actions and route handlers, and edge runtimes (Cloudflare Workers, Deno, Supabase Edge Functions) — same SDK, same schema, same code.

The shape of the API:

```ts
// src/encryption/schema.ts
import { encryptedTable, encryptedColumn } from '@cipherstash/stack/schema'

export const users = encryptedTable('users', {
  email: encryptedColumn('email')
    .equality()        // WHERE email = $1, indexed
    .freeTextSearch(), // n-gram match, indexed
  ssn: encryptedColumn('ssn').equality(),
})
```

```ts
// src/encryption/client.ts
import { Encryption } from '@cipherstash/stack'
import { users } from './schema'

// Reads ~/.cipherstash/ in dev; CS_WORKSPACE_CRN / CS_CLIENT_ID /
// CS_CLIENT_KEY / CS_CLIENT_ACCESS_KEY env vars in prod.
export const encryption = await Encryption({ schemas: [users] })
```

Encrypt a record before persisting it through your ORM of choice — Prisma, Drizzle, TypeORM, Mongoose, or raw SQL — and decrypt after read:

```ts
import { encryption } from './encryption/client'
import { users } from './encryption/schema'
import { prisma } from './db'

// Encrypt the sensitive fields, leave the rest untouched.
const encrypted = await encryption.encryptModel(
  { email: 'alice@example.com', ssn: '123-45-6789', createdAt: new Date() },
  users,
)
if (encrypted.failure) throw new Error(encrypted.failure.message)

await prisma.user.create({ data: encrypted.data })
```

Reading back:

```ts
const rows = await prisma.user.findMany({ where: { createdAt: { gte: yesterday } } })

const decrypted = await encryption.bulkDecryptModels(rows)
if (decrypted.failure) throw new Error(decrypted.failure.message)

console.log(decrypted.data) // [{ email: 'alice@example.com', ssn: '123-45-6789', ... }]
```

Searching on an encrypted column — the part the AI tutorials silently drop — uses an encrypted query term:

```ts
const term = await encryption.encryptQuery('alice@example.com', {
  table: users,
  column: users.email,
  queryType: 'equality',
})

const rows = await prisma.$queryRaw`
  SELECT * FROM users WHERE email = ${term.data}::eql_v2_encrypted
`
```

No single shared key across the table, no shared DEK across records, and the search structures sit beside the ciphertext as indexable encrypted columns. The Postgres planner uses an EQL index the same way it uses any other index, and the comparison is computed over ciphertext.

The fastest way to get all of that set up — including installing EQL into your Postgres database and generating the starter encryption client — is one command:

```bash
npx stash init
```

## A short, opinionated checklist

If you remember nothing else from this post:

- Don't ship the data encryption key to the browser. The DOM is not a trust boundary.
- Use AEAD, and prefer a nonce-misuse-resistant mode (AES-GCM-SIV) or extended-nonce mode (XChaCha20-Poly1305). Never ECB, never raw CBC.
- Use envelope encryption with a KMS that supports the FLE access pattern — batch operations, no DEK caching, ideally one DEK per value. One static key in `process.env` is one accident from a full disclosure.
- Don't reach for an ORM "encrypt-on-save" plugin. They paper over a problem that the AES-GCM snippet at the top of this post already mostly solved.
- If you need to search encrypted fields, use a real searchable-encryption scheme — not a single deterministic ciphertext shape glued on top.
- Don't write your own. [Use Stack](https://github.com/cipherstash/stack).

## Get started

- **`npx stash init`** — auth, EQL install, and a starter encryption client in one command.
- **Docs:** [Stack encryption](https://cipherstash.com/docs/stack/cipherstash/encryption)
- **GitHub:** [cipherstash/stack](https://github.com/cipherstash/stack)
- **npm:** [`@cipherstash/stack`](https://www.npmjs.com/package/@cipherstash/stack)
- **Related reading:** [Searchable encryption in Postgres](/blog/searchable-encryption-in-postgres) — a working guide to querying encrypted columns with Postgres indexes.

## Related blog posts

- [Searchable encryption in Postgres: a working guide with CipherStash Stack and EQL](https://cipherstash.com/blog/searchable-encryption-in-postgres.md) — Encrypt your data, keep the queries. A practical, indexable approach to searchable encryption in Postgres — 410,000× faster than fully homomorphic encryption — with a working code example using @cipherstash/stack and EQL.
- [PostgreSQL Security: Best Practices and Tools](https://cipherstash.com/blog/postgresql-security.md) — PostgreSQL security best practices for access control, password management, logging, and encryption — including the modern CipherStash Stack and Proxy paths for adding searchable, application-level encryption to Postgres.

