StackPractices
advanced By Mathias Paulenko

Encryption at Rest: AES-256, KMS, Envelope Encryption

Encrypt stored data correctly: AES-256-GCM, KMS-managed keys, envelope encryption, key rotation, and field-level encryption for databases and backups.

Topics: security

Introduction

A database backup ends up in a public S3 bucket. An EBS snapshot gets shared with the wrong account. A decommissioned disk leaves the data center unwiped. In every one of those cases the attacker never touches your application — they walk away with raw storage. Encryption at rest is what makes that storage worthless to them: without the key, the ciphertext is noise.

What it doesn’t protect against is just as important to know upfront. If your application decrypts data on demand, anyone who compromises the app gets plaintext. Encryption at rest defends the storage layer — disks, snapshots, backups, exports — not the access path. You still need authentication, authorization, and injection defenses on top of it.

This guide covers the pieces you need in production: AES-256-GCM for the actual cipher, a key management service (KMS) for key custody, envelope encryption to keep KMS calls cheap, field-level encryption for your most sensitive columns, and a key rotation strategy that doesn’t lock you into a rewrite later.

Encryption Layers

“Encrypt the data” can mean four different things depending on where you apply it. Each layer defends against a different threat, and none of them is a full substitute for the others:

Mermaid flowchart TD diagram
  • Field-level is the only layer that hides data from someone with legitimate database access. A DBA running SELECT * on the users table sees ciphertext for the sensitive columns.
  • Database-level (TDE) makes the data files on disk unreadable, but anyone who can authenticate to the database sees plaintext — the engine decrypts transparently.
  • Volume and hardware level are usually free (enable EBS encryption, LUKS, or rely on self-encrypting drives) and only cover the physical theft scenario. Turn them on everywhere, but don’t treat them as your answer to data exposure.

The practical rule: enable the cheap layers at the bottom by default, and spend your effort budget on field-level encryption for the columns that actually hurt if leaked.

When to Use

Encryption at rest isn’t a binary decision — it’s a per-field, per-store one. Apply it when:

  • You store regulated data. Cardholder data (PCI-DSS), health records (HIPAA), or personal data of EU residents (GDPR art. 32 “appropriate technical measures”) all push you toward field-level encryption with managed keys.
  • You run multi-tenant SaaS. Per-tenant keys let you crypto-shred one tenant’s data (delete their key, their ciphertext is unrecoverable) and limit the blast radius of a key compromise.
  • Backups or exports leave your control. Database dumps, log archives, and object-store replicas are the classic leak path. Encrypt before they leave, not after.
  • The threat model includes insiders. If “a DBA or a support engineer reads production tables” is in your threat model, transparent database encryption does nothing — you need application-level encryption.

And the cases where it buys you little:

  • Fields you must search, sort, or join on. Ciphertext can’t be indexed in any useful way. Either use a blind index (covered below) or leave the field unencrypted.
  • Non-sensitive, public-by-design data. Encrypting everything adds key-management overhead, complicates debugging, and can mask which data actually needs protection.
  • Ephemeral data with a short TTL. Session caches and queue payloads often carry less risk than the complexity cost of encrypting them — though compliance frameworks may still require it, so check your obligations first.

AES-256-GCM

AES-256 in Galois/Counter Mode is the default choice for encrypting data at rest. GCM is an AEAD construction: one pass gives you confidentiality plus an authentication tag that fails decryption if a single bit of the ciphertext was modified. Compare that to AES-CBC, which silently accepts tampered input unless you bolt on a separate HMAC — a source of real-world bit-flipping and padding-oracle attacks.

Three details matter in practice:

  • IV uniqueness is mandatory. A 96-bit (12-byte) random IV per encryption. Reusing an IV with the same key is catastrophic in GCM: it breaks confidentiality and lets an attacker recover the authentication subkey to forge messages. crypto.randomBytes gives you a CSPRNG-backed IV — don’t derive IVs from timestamps or counters without careful thought.
  • The auth tag goes with the ciphertext. You need tag (16 bytes) at decrypt time via decipher.setAuthTag. Store it next to the ciphertext and IV.
  • AAD binds ciphertext to context. cipher.setAAD() lets you authenticate metadata (tenant ID, table name, key version) without encrypting it — an attacker can’t move ciphertext between rows or tenants undetected.
// crypto/aesGcm.ts — AES-256-GCM encryption and decryption
import crypto from 'crypto';

const ALGORITHM = 'aes-256-gcm';
const IV_LENGTH = 12; // 96-bit IV for GCM (recommended)
const TAG_LENGTH = 16; // 128-bit auth tag

export interface EncryptedData {
  ciphertext: string;   // base64
  iv: string;           // base64
  tag: string;          // base64
  keyVersion: number;   // for key rotation
}

export function encrypt(plaintext: string, key: Buffer, keyVersion: number = 1): EncryptedData {
  const iv = crypto.randomBytes(IV_LENGTH);
  const cipher = crypto.createCipheriv(ALGORITHM, key, iv);

  const ciphertext = Buffer.concat([
    cipher.update(plaintext, 'utf8'),
    cipher.final(),
  ]);

  const tag = cipher.getAuthTag();

  return {
    ciphertext: ciphertext.toString('base64'),
    iv: iv.toString('base64'),
    tag: tag.toString('base64'),
    keyVersion,
  };
}

export function decrypt(data: EncryptedData, key: Buffer): string {
  const decipher = crypto.createDecipheriv(
    ALGORITHM,
    key,
    Buffer.from(data.iv, 'base64'),
  );

  decipher.setAuthTag(Buffer.from(data.tag, 'base64'));

  const plaintext = Buffer.concat([
    decipher.update(Buffer.from(data.ciphertext, 'base64')),
    decipher.final(),
  ]);

  return plaintext.toString('utf8');
}

If nonces make you nervous — say, keys encrypting billions of records where even a random IV collision becomes plausible — XChaCha20-Poly1305 (192-bit nonce, misuse-resistant) is the usual alternative, available via libsodium rather than Node’s built-in crypto.

Key Management Services

The cipher is the easy part; the key is the problem. An encryption key sitting in an environment variable, a config file, or the same database as the data it protects is one env dump away from total compromise. A KMS keeps the key inside a hardened service and hands out encrypt/decrypt operations instead of key material.

The main options, with the trade-offs that actually drive the choice:

ServiceHSM-backedAuto-rotationBYOKWatch out for
AWS KMSYes (FIPS 140-2 L3)Yearly, opt-in per keyYesPer-request pricing adds up at scale
GCP Cloud KMSYes (FIPS 140-2 L3; L1 for software)Configurable periodYesSeparate key rings per region
Azure Key VaultYes (managed HSM tier)Configurable policyYesKey versioning model differs — plan rotation code accordingly
HashiCorp VaultVia HSM PKCS#11 sealTransit engine supports rotationYes (import)You run and patch it — more control, more ops work

All four support the same core pattern: your app holds a data key, the KMS holds the master key that protects it. The AWS flavor looks like this:

// kms/KmsClient.ts — Abstract KMS interface
interface KmsClient {
  encrypt(dataKey: Buffer): Promise<{ ciphertext: string; keyVersion: number }>;
  decrypt(ciphertext: string, keyVersion: number): Promise<Buffer>;
  rotateKey(): Promise<void>;
}

// AWS KMS implementation
import { KMSClient, EncryptCommand, DecryptCommand } from '@aws-sdk/client-kms';

class AwsKmsClient implements KmsClient {
  private client = new KMSClient({ region: 'us-east-1' });
  private keyId = process.env.KMS_KEY_ID!;

  async encrypt(dataKey: Buffer): Promise<{ ciphertext: string; keyVersion: number }> {
    const command = new EncryptCommand({
      KeyId: this.keyId,
      Plaintext: dataKey,
    });
    const response = await this.client.send(command);
    return {
      ciphertext: Buffer.from(response.CiphertextBlob!).toString('base64'),
      keyVersion: 1, // KMS tracks version internally
    };
  }

  async decrypt(ciphertext: string, _keyVersion: number): Promise<Buffer> {
    const command = new DecryptCommand({
      CiphertextBlob: Buffer.from(ciphertext, 'base64'),
    });
    const response = await this.client.send(command);
    return Buffer.from(response.Plaintext!);
  }

  async rotateKey(): Promise<void> {
    // AWS KMS auto-rotates keys annually when enabled
    // Manual rotation creates a new key version
  }
}

Two things worth noting in this design. First, DecryptCommand doesn’t need the key ID — AWS KMS figures it out from the ciphertext blob, which is why keyVersion on your side is mostly bookkeeping. Second, enabling automatic rotation on the KMS key rotates the key-encrypting key only; your data keys and ciphertext stay untouched, which is exactly what makes the next pattern so convenient. For the broader secret-lifecycle picture (rotation policies, access patterns, audit), the secrets management guide goes deeper.

Envelope Encryption

Calling a KMS over the network for every encrypt/decrypt doesn’t scale: AWS KMS charges per request and enforces request-per-second quotas per key. Envelope encryption sidesteps that by splitting key duties in two:

  • A data encryption key (DEK) — a random 256-bit key generated locally — does the actual AES-256-GCM work.
  • A key encryption key (KEK) — the KMS-managed master key — encrypts the DEK itself.

The encrypted DEK travels with the ciphertext. Decryption means one KMS call to unwrap the DEK, then local crypto:

Mermaid flowchart LR diagram
// crypto/EnvelopeEncryption.ts — Envelope encryption pattern
class EnvelopeEncryption {
  constructor(private readonly kms: KmsClient) {}

  async encrypt(plaintext: string): Promise<EnvelopeEncryptedData> {
    // 1. Generate a data encryption key (DEK)
    const dek = crypto.randomBytes(32); // 256-bit DEK

    // 2. Encrypt the data with the DEK
    const encrypted = encrypt(plaintext, dek);

    // 3. Encrypt the DEK with the KMS (KEK)
    const encryptedDek = await this.kms.encrypt(dek);

    // 4. Zero out the DEK from memory
    dek.fill(0);

    return {
      ...encrypted,
      encryptedDek: encryptedDek.ciphertext,
      dekKeyVersion: encryptedDek.keyVersion,
    };
  }

  async decrypt(data: EnvelopeEncryptedData): Promise<string> {
    // 1. Decrypt the DEK using KMS
    const dek = await this.kms.decrypt(data.encryptedDek, data.dekKeyVersion);

    // 2. Decrypt the data with the DEK
    const plaintext = decrypt(
      { ciphertext: data.ciphertext, iv: data.iv, tag: data.tag, keyVersion: data.keyVersion },
      dek,
    );

    // 3. Zero out the DEK
    dek.fill(0);

    return plaintext;
  }
}

interface EnvelopeEncryptedData {
  ciphertext: string;
  iv: string;
  tag: string;
  keyVersion: number;
  encryptedDek: string;
  dekKeyVersion: number;
}

The granularity question — how many DEKs? — is a real trade-off. A DEK per record (or per field) gives the tightest isolation: leaking one DEK exposes one record. A DEK per tenant is cheaper to rotate and still bounds the blast radius. A single DEK for the whole table is simplest but turns one leak into a full-table exposure. For sensitive columns like SSNs, per-record DEKs are worth the extra KMS calls; for a tenant’s bulk data, per-tenant is usually the right middle ground.

Database Encryption

Transparent Data Encryption (TDE) — built into PostgreSQL via extensions, and native to SQL Server, Oracle, and cloud-managed databases — encrypts the files on disk. It’s worth enabling because it’s essentially free, but remember what it doesn’t do: anyone with valid database credentials sees plaintext, because the engine decrypts pages as it reads them. TDE answers “someone stole the data directory”, not “someone has database access”.

For that second threat, you encrypt at the application layer before values ever reach SQL:

-- Store encrypted data in columns
CREATE TABLE users (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    email TEXT NOT NULL,
    encrypted_ssn BYTEA,        -- encrypted social security number
    encrypted_phone BYTEA,      -- encrypted phone number
    encryption_metadata JSONB   -- { iv, tag, keyVersion, encryptedDek }
);

-- Insert encrypted data
INSERT INTO users (email, encrypted_ssn, encrypted_phone, encryption_metadata)
VALUES (
    'user@example.com',
    '\x' || encode(encrypted_ssn_bytes, 'hex'),
    '\x' || encode(encrypted_phone_bytes, 'hex'),
    '{"iv": "...", "tag": "...", "keyVersion": 1, "encryptedDek": "..."}'::jsonb
);
// db/EncryptedField.ts — Transparent field encryption in the ORM layer
class EncryptedField {
  constructor(private readonly envelopeEncryption: EnvelopeEncryption) {}

  async encryptField(value: string): Promise<string> {
    const encrypted = await this.envelopeEncryption.encrypt(value);
    return JSON.stringify(encrypted);
  }

  async decryptField(storedValue: string): Promise<string> {
    const data = JSON.parse(storedValue) as EnvelopeEncryptedData;
    return this.envelopeEncryption.decrypt(data);
  }
}

// Repository with transparent encryption
class UserRepository {
  constructor(
    private readonly db: Database,
    private readonly encryptedField: EncryptedField,
  ) {}

  async createUser(data: CreateUserInput): Promise<User> {
    const encryptedSsn = await this.encryptedField.encryptField(data.ssn);
    const encryptedPhone = await this.encryptedField.encryptField(data.phone);

    const result = await this.db.query(
      `INSERT INTO users (email, encrypted_ssn, encrypted_phone, encryption_metadata)
       VALUES ($1, $2, $3, $4) RETURNING id, email`,
      [data.email, encryptedSsn, encryptedPhone, null],
    );

    return result.rows[0];
  }

  async getUser(id: string): Promise<User> {
    const result = await this.db.query(
      `SELECT * FROM users WHERE id = $1`,
      [id],
    );

    const row = result.rows[0];
    if (!row) return null;

    return {
      id: row.id,
      email: row.email,
      ssn: await this.encryptedField.decryptField(row.encrypted_ssn),
      phone: await this.encryptedField.decryptField(row.encrypted_phone),
    };
  }
}

Searching encrypted fields: blind indexes

The moment you encrypt a column you lose WHERE email = ?. The standard workaround is a blind index: an extra column holding HMAC-SHA256(indexKey, normalizedValue). Equality lookups hash the query value and match the index column — the database sees only hashes it can’t reverse. The catch is real: HMAC is deterministic, so identical plaintexts produce identical indexes and leak frequency (if 40% of rows share an SSN, that’s visible). Use blind indexes only for low-cardinality lookups you actually need (email, account_id), never for high-entropy fields, and never as a substitute for the AEAD-encrypted value itself. Range queries, sorting, and LIKE remain impossible — design your access patterns around that limit. For a copy-paste implementation of the encrypt/store flow, the encryption at rest recipe covers the basics.

Key Rotation

Keys age out: algorithm deprecations, suspected exposure, or plain policy (“rotate annually”). How painful rotation is depends on what you chose earlier — one more reason envelope encryption is the right default:

  • KEK rotation is cheap. With AWS KMS automatic rotation enabled, the master key rotates yearly and existing ciphertext keeps working — KMS remembers old versions. Re-wrapping DEKs under a new KEK is a metadata operation, not a data migration.
  • DEK rotation is the expensive part. Re-encrypting data requires decrypt-with-old → encrypt-with-new for every record. That’s why keyVersion is stored next to each ciphertext: the rotation job can find and migrate old-version records incrementally.

A safe rotation runbook: write new data with the new key immediately, re-encrypt old records in batches (watch DB load — this runs hot), keep both keys live until migration completes, verify a sample decrypts correctly, and only then retire the old version. If your KMS deletes a key version before all data migrated, that data is gone permanently. The companion key rotation runbook has the full operational checklist.

// crypto/KeyRotation.ts — Rotate encryption keys
class KeyRotationService {
  constructor(
    private readonly kms: KmsClient,
    private readonly db: Database,
  ) {}

  async rotateKeys(): Promise<void> {
    // 1. Find all records with old key version
    const records = await this.db.query(
      `SELECT id, encrypted_data FROM sensitive_data WHERE key_version = $1`,
      [1], // old key version
    );

    for (const record of records.rows) {
      // 2. Decrypt with old key
      const plaintext = await this.decrypt(record.encrypted_data, 1);

      // 3. Re-encrypt with new key
      const reencrypted = await this.encrypt(plaintext, 2); // new key version

      // 4. Update record
      await this.db.query(
        `UPDATE sensitive_data SET encrypted_data = $1, key_version = $2 WHERE id = $3`,
        [reencrypted, 2, record.id],
      );
    }

    console.log(`Rotated ${records.rows.length} records to key version 2`);
  }
}

For large tables, batch this loop (thousands of rows per transaction, not one by one) and run it during low-traffic windows — row-level re-encryption at scale is mostly a database-load problem, not a crypto problem.

Compliance and Key Custody

Encryption at rest shows up in nearly every compliance framework, but the details differ enough to matter:

  • PCI-DSS requires encryption of stored cardholder data and key-management controls: split knowledge and dual control for keys, documented rotation. A managed KMS with audit logs covers most of the key-custody requirements out of the box.
  • FIPS 140-2/140-3 governs the cryptographic module itself, not your usage of it. AWS KMS and GCP Cloud KMS HSMs are validated at FIPS 140-2 Level 3 — relevant if you sell into government or regulated industries where “is the crypto module validated” is a procurement question.
  • HIPAA lists encryption as an “addressable” safeguard — technically optional, but an unencrypted breach is reportable while encrypted data loss generally isn’t. In practice it’s mandatory.
  • GDPR doesn’t name algorithms but demands “appropriate technical measures” (art. 32). The interesting lever is crypto-shredding for the right to erasure: if data is encrypted with per-subject keys, deleting the key is a defensible form of deletion even inside backups you can’t surgically edit.

Whichever framework applies, the recurring requirement is the audit trail: who used which key, when, and on what. Managed KMS services log every Encrypt/Decrypt call (CloudTrail, Cloud Audit Logs, Azure Monitor) — enable those logs, alert on anomalies, and you satisfy most key-usage evidence requests.

Best Practices

  • Use AES-256-GCM for data encryption — authenticated encryption prevents tampering.
  • Never store encryption keys with encrypted data — use a KMS.
  • Use envelope encryption for performance — KMS calls are slow and rate-limited, DEKs are local and fast.
  • Use a unique IV for every encryption operation — never reuse an IV with the same key.
  • Rotate keys regularly — annually, or immediately after a suspected incident.
  • Combine layers — field-level for sensitive columns, volume-level for the disk.
  • Zero out keys from memory after use — mitigate memory dump and crash-dump exposure.
  • Use separate keys per tenant in multi-tenant systems — limits blast radius and lets you crypto-shred.
  • Log key usage in the KMS — you get an audit trail for free with CloudTrail or Cloud Audit Logs.
  • Test decryption before deleting old keys — a deleted key version makes its ciphertext unrecoverable, forever.
  • Prefer HSM-backed KMS — hardware security modules keep the master key out of memory entirely.
  • Don’t encrypt data you need to search or sort on — ciphertext can’t be indexed; use a blind index only when equality lookup is required.

Encryption at rest is one control inside a larger surface — for the adjacent checklist covering auth, transport, and the rest of the API security posture, see the API Security Checklist — Authentication to Encryption.

Common Mistakes

  • Reusing IVs with the same key: catastrophic in GCM — it breaks confidentiality and leaks the authentication subkey, letting attackers forge ciphertexts. Generate a random IV for every encryption.
  • Storing keys in environment variables: env vars leak through crash reports, /proc, debug endpoints, and CI logs. Use a KMS or Vault.
  • Not using authenticated encryption: AES-CBC without HMAC allows bit-flipping attacks and padding oracles. Use GCM, or CBC+HMAC if you’re locked into a library without AEAD.
  • Encrypting everything: encrypting non-sensitive data adds KMS cost, operational complexity, and makes real sensitive data harder to audit. Encrypt what needs protection.
  • No key rotation plan: keys compromised in an incident stay valid forever without versioning and a migration path. Design for rotation from day one.
  • Using one key for all tenants: a single compromised key exposes every tenant’s data. Per-tenant keys bound the damage.
  • Deleting old key versions too early: if any ciphertext still references the retired version, it’s unrecoverable. Inventory key_version usage before disabling or deleting keys.

See Also

Frequently Asked Questions

What is encryption at rest?

Encrypting data where it's stored — disk, database files, backups, snapshots. The ciphertext is unreadable without the key, and the key lives separately in a KMS. If storage is stolen or leaked, the attacker gets noise instead of data.

What is envelope encryption?

A two-key pattern: a randomly generated data encryption key (DEK) encrypts your data locally, and a KMS-managed key encryption key (KEK) encrypts the DEK. You store the encrypted DEK with the ciphertext, so each decrypt costs one KMS call instead of sending all data through the service.

What is AES-256-GCM?

AES with a 256-bit key running in Galois/Counter Mode. Beyond confidentiality, GCM produces an authentication tag that detects any tampering with the ciphertext — which is why it's the recommended mode and why IV reuse with the same key is such a serious failure.

How does key rotation work?

New writes use the new key version; existing data is decrypted with the old version and re-encrypted with the new one, usually in batches. The old key stays alive until every record migrates — that's why keyVersion is stored next to each ciphertext. Managed KMS services rotate the master key automatically without touching your data keys.

Should I encrypt at the database level or application level?

They answer different threats. TDE protects against stolen disks and snapshots but shows plaintext to anyone with database credentials, including a rogue DBA. Application-level field encryption protects the column even inside the database. Use field-level for the few columns that would hurt most (SSN, health data, credentials) and TDE as cheap baseline coverage.

Can I query encrypted fields?

Not with WHERE, ORDER BY, or LIKE on the ciphertext — search and sort are impossible by design. For equality lookups you can add a blind index column (HMAC(key, value)), accepting that it leaks value frequency. Anything richer — ranges, full-text — requires specialized techniques like searchable encryption or a separate index, and usually isn't worth the complexity.