How to Encrypt Sensitive Fields in Your Database
· 7 min read
Most managed databases already encrypt their disks. That protects you if someone steals a drive, but not if someone gets a SQL dump, a backup file or read access through your app, because the database decrypts everything for any query. Field-level encryption closes that gap: sensitive columns are encrypted before they reach the database.
Which fields to encrypt
Encrypt data that causes real harm if it leaks and that you rarely search or sort by: government ID numbers, bank details, health information, phone numbers, addresses, third-party access tokens and private notes. Leave IDs, timestamps and foreign keys readable so the database can still index and join.
Don't encrypt passwords: hash them instead. See how to store user passwords.
Envelope encryption
If one key encrypts every field and sits next to the app, that key is a single point of failure. Envelope encryption splits the job. Data is encrypted with a data key, and the data key is encrypted ("wrapped") by a key-encryption key that lives in a key management service. The database only ever holds wrapped data keys, which are useless without the service.
- Generate a random 32-byte data key in your server.
- Encrypt the field locally with AES-256-GCM using that data key.
- Send only the data key to the key management service to wrap it, and store the wrapped key next to the ciphertext.
- To read: unwrap the data key, decrypt locally, and discard the data key.
With AMARACUS, wrapping and unwrapping are the encrypt and decrypt endpoints with base64 data:
import { randomBytes } from "node:crypto";
const API = "https://api.amaracus.tech/v1";
const headers = {
Authorization: `Bearer ${process.env.AMARACUS_API_KEY}`,
"Content-Type": "application/json",
};
const key = process.env.AMARACUS_KEY_ID;
const dataKey = randomBytes(32);
// ...encrypt the field locally with dataKey (AES-256-GCM)...
// Wrap: store wrappedKey next to the ciphertext
const wrapRes = await fetch(`${API}/encrypt`, {
method: "POST",
headers,
body: JSON.stringify({ key, data: dataKey.toString("base64"), encoding: "base64" }),
});
const wrappedKey = (await wrapRes.json()).data.envelope;
// Unwrap when you need to read the field
const unwrapRes = await fetch(`${API}/decrypt`, {
method: "POST",
headers,
body: JSON.stringify({ key, envelope: wrappedKey, outputEncoding: "base64" }),
});
const sameDataKey = Buffer.from((await unwrapRes.json()).data.plaintext, "base64");For small fields such as a phone number, you can skip the local step and send the value itself to /v1/encrypt, then store the envelope. That costs one operation per write and per read, so envelope encryption is cheaper for large or often-read records.
Searching encrypted fields
You can't run WHERE email = ? against ciphertext, because the same value encrypts differently every time; that's what the random IV is for. The usual fix is a blind index: store an HMAC of the normalized value in a separate column and search on that.
import { createHmac } from "node:crypto";
// A separate secret, not the encryption key
const indexKey = Buffer.from(process.env.BLIND_INDEX_KEY_BASE64, "base64");
export function blindIndex(email) {
return createHmac("sha256", indexKey).update(email.trim().toLowerCase()).digest("hex");
}
// INSERT ... (email_encrypted, email_index) VALUES ($1, $2)
// SELECT ... WHERE email_index = $1 -- with blindIndex(input)A blind index shows which rows share a value, so only add one where that is acceptable. Never add one to fields with few possible values, such as country or year of birth, because the hashes are easy to guess.
Rotating keys
Rotate the key-encryption key on a schedule and after any suspected leak. In AMARACUS, POST /v1/keys/{keyId}/rotate creates a new version. New envelopes use it, and older envelopes still decrypt because each one records its version. To retire an old version entirely, re-wrap the stored data keys in a background job.
Frequently asked questions
Isn't database encryption at rest enough?
It protects against stolen disks and storage-level backups. It doesn't protect against SQL injection, a leaked database password or a careless export, because the database decrypts data for anyone who can query it.
Can I still search encrypted data?
Exact-match lookups work with a blind index, an HMAC of the value stored in its own column. Range queries, sorting and partial matches don't work on encrypted fields.
Does field-level encryption slow my app down?
Local AES-GCM takes microseconds per field. The main cost is the call that wraps or unwraps a data key, which you can reduce by caching unwrapped data keys in memory for a short time.
What happens if I lose the encryption key?
The data is gone for good. That is why the key-encryption key belongs in a managed service with access control and backups, not in an environment variable on one server.