← Back to all articles
// Security · Cryptography
AES-256-GCM Argon2id Web Crypto Envelope Keys TLS 1.3

Application Encryption Architecture: At Rest, In Transit, and Key Hierarchy

Every application that stores data makes four cryptographic decisions: how to protect data at rest, how to protect data in transit, how to handle passwords, and who holds the keys. Get one wrong and the other three don't matter.

Barnabas Waweru  ·  August 26, 2026  ·  15 min read
ansi · wordmark · encryption
███████╗███╗   ██╗ ██████╗██████╗ ██╗   ██╗██████╗ ████████╗██╗ ██████╗ ███╗   ██╗
██╔════╝████╗  ██║██╔════╝██╔══██╗╚██╗ ██╔╝██╔══██╗╚══██╔══╝██║██╔═══██╗████╗  ██║
█████╗  ██╔██╗ ██║██║     ██████╔╝ ╚████╔╝ ██████╔╝   ██║   ██║██║   ██║██╔██╗ ██║
██╔══╝  ██║╚██╗██║██║     ██╔══██╗  ╚██╔╝  ██╔═══╝    ██║   ██║██║   ██║██║╚██╗██║
███████╗██║ ╚████║╚██████╗██║  ██║   ██║   ██║        ██║   ██║╚██████╔╝██║ ╚████║
╚══════╝╚═╝  ╚═══╝ ╚═════╝╚═╝  ╚═╝   ╚═╝   ╚═╝        ╚═╝   ╚═╝ ╚═════╝ ╚═╝  ╚═══╝

The Four Cryptographic Decisions

Most developers think of encryption as a single feature to switch on. In practice it is four separate problems with different algorithms, different threat models, and different failure modes. Mixing them up is how you end up encrypting your logs but storing passwords in MD5.

                    APPLICATION ENCRYPTION SURFACE MAP

┌─────────────────────────────────────────────────────────────────────────┐
│                                                                         │
│   CLIENT                    TRANSIT                     SERVER          │
│                                                                         │
│   Browser / Mobile          TLS 1.3                     Node.js API     │
│   Web Crypto API     ──────────────────────────────►    Express / Next  │
│                       TLS terminates at edge                │           │
│                       (Vercel, Cloudflare, ALB)             │           │
│                                                             │           │
│                                                   ┌─────────▼──────────┐│
│   Decision 1:                                     │   AT REST          ││
│   In-Transit                                      │   AES-256-GCM      ││
│   TLS is the platform's job                       │   per-field DEK    ││
│   (Vercel / CF handle it)                         │   12-byte IV       ││
│                                                   │   AEAD tag         ││
│                                                   └─────────┬──────────┘│
│                                                             │           │
│                                               ┌─────────────▼──────────┐│
│   Decision 2:                                 │   KEY HIERARCHY        ││
│   Key Management                              │   KMS (KEK)            ││
│   Envelope encryption                         │     wraps DEK          ││
│   KMS holds KEK                               │   DEK encrypts field   ││
│   DEK stored beside ciphertext                │   Wrapped DEK in DB    ││
│                                               └─────────────┬──────────┘│
│                                                             │           │
│                                               ┌─────────────▼──────────┐│
│   Decision 3:                                 │   PASSWORDS            ││
│   Password Hashing                            │   Argon2id             ││
│   One-way, memory-hard                        │   19 MiB memory        ││
│   Never decrypt                               │   2 time iterations    ││
│                                               │   Not stored encrypted ││
│                                               └────────────────────────┘│
│                                                                         │
│   Decision 4: Library Selection                                         │
│   Node.js built-in crypto (AES-GCM) · libsodium (sealed boxes)        │
│   @noble/ciphers (zero-dep, audited) · Web Crypto (edge/browser)       │
│                                                                         │
└─────────────────────────────────────────────────────────────────────────┘
Data at Rest
AES-256-GCM. Authenticated encryption gives you confidentiality and integrity in one call. Fresh 12-byte IV per message, mandatory.
Data in Transit
TLS 1.3, terminated at the edge. Vercel and Cloudflare provision and rotate certificates automatically. This is not your code to write.
Passwords
Argon2id. Passwords are hashed, not encrypted. You never need to read one back. Memory-hardness makes GPU-based cracking expensive.
Key Hierarchy
Envelope encryption. A data encryption key (DEK) protects each record. A key encryption key (KEK) in a KMS wraps each DEK. Rotate without re-encrypting data.
// The Rule of Categories

Get the category right before picking an algorithm. Symmetric encryption (AES-256-GCM) is for data you need to read back. Hashing (Argon2id) is for secrets you never decrypt. HMAC is for proving a message came from someone with the shared key. Asymmetric encryption (libsodium sealed box) is for encrypting to a public key without a pre-shared secret. Confusing these categories is a class of bug that no library can prevent.

AES-256-GCM: Data at Rest

OWASP recommends AES with at least 128-bit keys and an authenticated mode. In practice, AES-256-GCM is the right default. The 256-bit key is future-proof against quantum-adjacent attacks. GCM is an authenticated mode, meaning the decryption step will fail loudly if anyone tampered with the ciphertext.

How GCM Authentication Works

GCM (Galois/Counter Mode) combines a stream cipher for encryption with a GHASH authentication function for integrity. Every encryption call produces three outputs: the initialization vector (IV), the ciphertext, and an authentication tag. Decryption verifies the tag before returning any plaintext. Store all three, check all three.

The tag binds to the associated data (AAD) you pass in alongside the plaintext. Pass the row id as AAD and a ciphertext transplanted from another row will fail authentication. That single addition prevents a whole category of row-swap attacks.

// Node.js AES-256-GCM (built-in crypto module)
import { randomBytes, createCipheriv, createDecipheriv } from 'node:crypto' const KEY = Buffer.from(process.env.ENCRYPTION_KEY_B64!, 'base64') // 32 bytes export function encrypt(plaintext: string, rowId: string): string { const iv = randomBytes(12) // fresh per call, always const aad = Buffer.from(rowId) // binds ciphertext to row const cipher = createCipheriv('aes-256-gcm', KEY, iv) cipher.setAAD(aad) const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]) const tag = cipher.getAuthTag() // 16 bytes // iv(12) | tag(16) | ciphertext — store together, never split return Buffer.concat([iv, tag, encrypted]).toString('base64') } export function decrypt(encoded: string, rowId: string): string { const buf = Buffer.from(encoded, 'base64') const iv = buf.subarray(0, 12) const tag = buf.subarray(12, 28) const data = buf.subarray(28) const aad = Buffer.from(rowId) const decipher = createDecipheriv('aes-256-gcm', KEY, iv) decipher.setAuthTag(tag) // must be set BEFORE update/final decipher.setAAD(aad) // final() throws if tag verification fails — let it throw return Buffer.concat([decipher.update(data), decipher.final()]).toString('utf8') }
// IV Reuse is Catastrophic

Reusing an IV with the same key in AES-GCM breaks both confidentiality and integrity in a single exploit. An attacker who sees two ciphertexts encrypted with the same key and IV can XOR them to cancel out the key stream, recover plaintext, and forge authentication tags. Always randomBytes(12) per encryption call. Never use a counter or timestamp as an IV.

Web Crypto API for Edge and Browser

The crypto.subtle API is the same algorithm, different call style. It runs in Cloudflare Workers, Vercel Edge Functions, Deno, and browsers without importing Node.js built-ins. The security rules are identical: random IV, store tag alongside ciphertext, verify before acting on plaintext.

// Web Crypto (edge-compatible)
const key = await crypto.subtle.importKey( 'raw', rawKeyBytes, { name: 'AES-GCM' }, false, ['encrypt', 'decrypt'] ) // Encrypt const iv = crypto.getRandomValues(new Uint8Array(12)) const ciphertext = await crypto.subtle.encrypt( { name: 'AES-GCM', iv, additionalData: rowIdBytes }, key, plaintextBytes ) // ciphertext already includes the 16-byte tag at the end (Web Crypto appends it) // Decrypt — throws DOMException on tag mismatch const plaintext = await crypto.subtle.decrypt( { name: 'AES-GCM', iv, additionalData: rowIdBytes }, key, ciphertext )

Envelope Encryption and Key Hierarchy

Encrypting data directly with a single long-lived key creates a key rotation problem. When you rotate, you must re-encrypt every record. Envelope encryption separates the concern: a data encryption key (DEK) encrypts the record, a key encryption key (KEK) wraps the DEK. Rotation means re-wrapping the DEK with a new KEK, not touching the data.

ENVELOPE ENCRYPTION FLOW

                    KMS (AWS KMS / Google Cloud KMS / Vault)
                              │
                              │  holds KEK (never leaves KMS)
                              │
              ┌───────────────┼───────────────┐
              │               │               │
         GenerateDEK     WrapDEK          UnwrapDEK
              │               │               │
              ▼               ▼               ▼
         random DEK     EncryptedDEK    Plaintext DEK
         (32 bytes)    (KMS ciphertext) (in memory only)
              │               │               │
              │               │               │
              ▼               ▼               ▼
    Encrypt field data   Store beside    Decrypt field data
    with DEK (AES-GCM)   ciphertext      when needed

DATABASE ROW:
┌────────────────┬──────────────────┬─────────────────────────┐
│ id             │ encrypted_dek    │ encrypted_field          │
│ "row-uuid"     │ base64(kms_blob) │ base64(iv|tag|data)     │
└────────────────┴──────────────────┴─────────────────────────┘

KEY ROTATION:
  1. Generate new KEK in KMS
  2. For each row: unwrap DEK with old KEK, re-wrap with new KEK
  3. Update encrypted_dek in DB
  4. Revoke old KEK
  → Data ciphertext is never touched

DEK Per Record or Per Column

Two common shapes. Per-record DEKs give you the finest isolation: a compromised DEK exposes exactly one row. Per-column DEKs are simpler operationally: one DEK protects all rows in a column, which means wrapping once but also means a leaked DEK exposes the whole column. For PHI or PCI data, per-record is safer. For low-sensitivity columns where key management overhead is a concern, per-column works.

Where to Store the Wrapped DEK

Store the encrypted DEK in the same row as the ciphertext. An attacker who gets the row gets the encrypted DEK, but that blob is useless without KMS access. The separation is KMS access vs database access, not physical distance. Keep them together, authenticated with AAD that includes the row ID, so transplanting an encrypted DEK to another row also fails.

// Cost Reality Check

AWS KMS charges $0.03 per 10,000 API calls. Wrapping a DEK on every write and unwrapping on every read can add up at scale. Cache the plaintext DEK in memory for the lifetime of a request, never across requests. For high-throughput writes, generate DEKs in batches and use a local LRU cache with a short TTL for reads.

Google Cloud KMS is priced similarly: $0.03 per 10,000 cryptographic operations. Vault's Transit engine is free if self-hosted, but you pay for the Vault infrastructure.

1
Write Path
Generate 32 random bytes as the DEK. Wrap it with the KMS KEK. Encrypt the field with AES-256-GCM using the DEK. Store encrypted DEK + ciphertext in the same row.
2
Read Path
Fetch the row. Pass the encrypted DEK to KMS for unwrapping. Use the unwrapped DEK to decrypt the field. Never log the DEK. Discard it when done.
3
Rotation
Generate new KEK. Batch-unwrap each encrypted DEK with old KEK, re-wrap with new KEK. Update DB. Revoke old KEK. Data ciphertext is untouched.
4
Breach Response
If the database is exfiltrated without KMS access, all ciphertexts are safe. If KMS access is compromised, revoke the KEK. Rotate DEKs for affected records.

Password Hashing: Argon2id

Passwords are never encrypted. Encryption implies you can get the original value back. For passwords, that is never a valid requirement. You store a hash and verify future inputs against it. The security question is how expensive it is to brute-force the hash.

Why Argon2id and Not bcrypt

bcrypt is time-hard: it runs through a fixed number of iterations. Argon2id is both time-hard and memory-hard: it requires a configurable amount of RAM per hash attempt. A GPU farm can run millions of bcrypt hashes per second because GPUs have many cores and bcrypt needs very little memory per call. Argon2id with 19 MiB memory per attempt means a GPU farm needs 19 MiB of GPU RAM per parallel attempt. At 19 MiB, a 24 GB GPU card can run about 1,260 parallel Argon2id instances. The same card can run tens of thousands of parallel bcrypt instances. The memory cost is the defense.

The OWASP minimum for Argon2id: 19,456 KiB memory (19 MiB), 2 iterations, parallelism 1. Adjust upward if your server has headroom and you want more safety margin.

// Argon2id with node-argon2
import argon2 from 'argon2' const OPTIONS = { type: argon2.argon2id, memoryCost: 19456, // 19 MiB — OWASP minimum timeCost: 2, // iterations parallelism: 1, } export async function hashPassword(password: string): Promise<string> { return argon2.hash(password, OPTIONS) // returns: "$argon2id$v=19$m=19456,t=2,p=1$salt$hash" // salt is random per call — no need to manage it separately } export async function verifyPassword(hash: string, password: string): Promise<boolean> { try { return await argon2.verify(hash, password) // timing-safe internally } catch { return false // corrupt hash format — treat as mismatch } }
Algorithm Memory-Hard OWASP 2024 GPU Resistance Use When
Argon2id Yes (19 MiB+) First choice High Any new system
scrypt Yes (N=2^17) Acceptable High Argon2 native binding unavailable
bcrypt No Legacy only Low Work factor ≥10, migrating legacy system only
PBKDF2 No FIPS only Low FIPS-140-2 compliance required
SHA-256 / MD5 No Never None Never use for passwords
// bcrypt Truncation Bug

bcrypt silently truncates passwords at 72 bytes. A user with a 100-character password and a user with the first 72 characters of that password will both authenticate successfully. If you are stuck with bcrypt, enforce a maximum password length of 72 bytes. Argon2id has no such limit.

TLS in Transit: What the Platform Handles

TLS 1.3 is the in-transit story for most modern applications, and Vercel and Cloudflare handle it automatically. That does not mean you can ignore transit security. You own the internal paths: service-to-service calls, database connections, and any traffic that does not pass through the edge.

Edge Termination

When a user hits https://yourapp.com, TLS terminates at the Vercel or Cloudflare edge. The edge holds your TLS certificate, handles the handshake, and manages cipher suite negotiation. Vercel enforces TLS 1.2 minimum and prefers TLS 1.3. Cloudflare supports TLS 1.3 and 0-RTT resumption for repeat visitors.

Traffic from the edge to your origin (Vercel Functions, containers) goes over a private backbone. Vercel Functions run in the same infrastructure and share it. Cloudflare Workers communicate internally. For origins you control (external API, Railway service), configure TLS on that connection too.

Database Connections

Neon and Supabase require TLS on all connections. The Neon serverless driver over HTTP upgrades to HTTPS automatically. For standard psql connections, sslmode=require is the minimum; sslmode=verify-full with the CA certificate is stronger and verifies the server's identity. This prevents MITM attacks on the database connection from inside your infrastructure.

Internal Service Calls

Service-to-service calls within the same Cloudflare Workers environment share a trusted context. Calls from your backend to an external API need HTTPS. Calls to Stripe, Resend, Infisical, and any third-party service in the stack go over HTTPS, certificate-verified by default in Node.js's fetch and the https module. Never disable certificate verification in production. The rejectUnauthorized: false option in Node.js eliminates TLS protection entirely.

TLS TERMINATION POINTS

User Browser
  │
  │  TLS 1.3 (auto-managed by Vercel / Cloudflare)
  ▼
Edge (Vercel Edge Network / Cloudflare)
  │
  │  Internal backbone (private, encrypted)
  ▼
Vercel Function / Worker
  │
  ├── Neon (sslmode=require or verify-full)
  ├── Supabase (TLS required)
  ├── Stripe API (HTTPS, cert-verified)
  ├── Resend API (HTTPS)
  └── Railway internal service (configure TLS on the Railway side)

CHECK THESE:
  ✓ Edge cert auto-renewed (platform handles it)
  ✓ DB connection string includes SSL flag
  ✓ Third-party fetch uses HTTPS
  ✗ Never: rejectUnauthorized: false in production
  ✗ Never: HTTP URLs for APIs that hold secrets

HMAC, Signatures, and Timing Safety

HMAC-SHA-256 is not encryption. It provides integrity and authentication, not confidentiality. You use it to prove that a message was signed with a shared secret key. Webhooks use it. Stripe, GitHub, and every serious webhook provider sends a signature header. Your job is to verify it before acting on the payload.

Webhook Signature Verification

The standard pattern: the provider computes HMAC-SHA-256 over the raw request body using your webhook secret. You compute the same HMAC on your side and compare. The comparison must be timing-safe. A character-by-character comparison with === leaks timing information that an attacker can use to forge signatures one byte at a time. Use crypto.timingSafeEqual in Node.js.

// Timing-safe webhook verification
import { createHmac, timingSafeEqual } from 'node:crypto' export function verifyWebhookSignature( rawBody: Buffer, signatureHeader: string, secret: string ): boolean { const expected = createHmac('sha256', secret) .update(rawBody) .digest('hex') const sig = Buffer.from(signatureHeader.replace(/^sha256=/, ''), 'hex') const exp = Buffer.from(expected, 'hex') if (sig.length !== exp.length) return false return timingSafeEqual(sig, exp) // constant time — no short circuit }

Stripe's Built-in Verification

Stripe's SDK does this correctly and adds timestamp validation to prevent replay attacks. Call stripe.webhooks.constructEvent(rawBody, sig, secret) and let the SDK throw on failure. The timestamp window defaults to 300 seconds. Do not re-implement this manually for Stripe.

For Clerk webhooks, use the Svix SDK: wh.verify(payload, headers). For GitHub webhooks, the signature header is X-Hub-Signature-256. The pattern is always the same: timing-safe HMAC comparison over the raw body.

The Failure Modes

Most encryption bugs are not algorithm failures. They are implementation mistakes that make correct algorithms insecure. These are the ones that actually appear in production codebases.

IV/Nonce Reuse

Reusing any 12-byte IV with the same AES-256-GCM key allows an attacker to cancel the key stream between two ciphertexts. They get your plaintext via XOR and can forge authentication tags. Always randomBytes(12) per encryption call. The call is cheap. The mistake is not recoverable.

Acting on Plaintext Before the Auth Tag Clears

In Node.js, createDecipheriv for GCM requires you to call decipher.setAuthTag(tag) before any data processing. The actual authentication check happens at decipher.final(). If you call update() and act on the partial output before final(), you have acted on data that has not yet been authenticated. Only use plaintext returned after final() completes without throwing.

Keys in Env Variables in the Client Bundle

Any variable prefixed with NEXT_PUBLIC_ in Next.js or included in the client bundle is readable by every visitor. Encryption keys must live server-side only. If a key ends up in NEXT_PUBLIC_ENCRYPTION_KEY, every user who opens DevTools has your key. Use environment variables without the public prefix, accessed only in Server Components, Route Handlers, or API routes.

Unauthenticated Modes (AES-CBC Without HMAC)

AES-CBC encrypts but does not authenticate. An attacker can flip bits in the ciphertext and predictably alter the plaintext. Worse, padding oracle attacks against AES-CBC have broken real-world systems. Never use CBC without adding an HMAC over the ciphertext (Encrypt-then-MAC). Use GCM instead and avoid the problem entirely.

bcrypt 72-Byte Truncation

bcrypt silently truncates passwords at 72 bytes. A password of 73+ characters hashes identically to its first 72 characters. Enforce a max password length of 72 characters if you use bcrypt. Better: use Argon2id, which has no truncation.

=== for Secret Comparison

String comparison with === short-circuits at the first differing character. This timing side-channel allows an attacker to probe one byte at a time. Use crypto.timingSafeEqual for any comparison involving a secret value: API keys, session tokens, webhook signatures, TOTP codes. argon2.verify is already timing-safe internally; do not bypass it.

Logging PHI or Keys

Sentry, Datadog, and server logs aggregate everything in console.log or Error.message. A plaintext PHI value that appears in a stack trace is a breach waiting to be discovered in your observability platform. Redact before logging. Never log decrypted PHI fields, plaintext DEKs, or encryption keys. Log the row ID, the operation, and the error code instead.

Library Selection

The right answer is the smallest surface area you can use. Node.js built-ins first. Audited third-party libraries for gaps. No DIY primitives, ever.

Library What It Does Well Runtime Audit Status Use When
node:crypto AES-GCM, HMAC-SHA-256, random bytes, timingSafeEqual Node.js only Part of Node.js (OpenSSL) Server-side in any Node.js environment
Web Crypto (crypto.subtle) AES-GCM, ECDH, RSA-OAEP, random values Browser, Edge, Deno, Workers W3C standard, browser-validated Edge functions, browser E2EE, Workers
libsodium / libsodium-wrappers Sealed boxes (X25519 + XSalsa20-Poly1305), secretbox, BLAKE2b Node.js, browser Audited, widely reviewed Encrypt to a public key, no pre-shared secret needed
@noble/ciphers AES-GCM, ChaCha20-Poly1305, pure JS Universal Independently audited (2023) Zero-dependency edge/browser environments
argon2 (npm) Argon2id/i/d hashing Node.js (native binding) Wraps reference C implementation Password hashing in any Node.js server
Google Tink Safe-by-default API over multiple algorithms Multi-language Google security team maintained Multi-language teams, prevents low-level mistakes
// Never Roll Your Own

Writing your own cipher, your own PRNG, or your own key derivation function is a career-defining mistake. The algorithms above have been analyzed by cryptographers for years. A custom implementation will have subtle bugs that only become visible when someone exploits them. Use the vetted primitives. The only customization you should do is parameter selection within the ranges each library documents.

Field-Level Encryption for PHI

When your application stores protected health information (PHI) or payment card data, column-level database encryption (Transparent Data Encryption) is not sufficient. TDE protects the database files on disk, but a compromised application with database access reads plaintext. Field-level encryption moves the boundary: the application encrypts before writing and decrypts after reading, so the database never sees sensitive values in plaintext.

The DoseVault Pattern

Each PHI column gets a per-row DEK. The DEK is generated at write time, wrapped with a KMS-held KEK, and stored in an encrypted_dek column in the same row. The PHI column stores the AES-256-GCM ciphertext with the row's id as AAD. A query that returns 10,000 rows triggers 10,000 KMS unwrap calls on the read path, which is expensive. In practice: cache the unwrapped DEK in memory for the duration of the request, preload DEKs in a batch KMS call before processing a query result set, and never persist an unwrapped DEK to Redis or any external store.

PHI FIELD ENCRYPTION (per-row DEK pattern)

WRITE:
  input: { patientId: "p-123", phone: "+254712345678" }

  1. randomBytes(32)              → dek
  2. kms.encrypt(dek, kekId)     → encrypted_dek
  3. aesGcm.encrypt(phone, dek, { aad: patientId })
                                  → { iv, tag, ciphertext }
  4. INSERT INTO patients (id, encrypted_dek, encrypted_phone)
                   VALUES ("p-123", encrypted_dek, iv|tag|ciphertext)

READ:
  1. SELECT id, encrypted_dek, encrypted_phone FROM patients WHERE id = "p-123"
  2. kms.decrypt(encrypted_dek, kekId)   → dek (in memory only)
  3. aesGcm.decrypt(encrypted_phone, dek, { aad: "p-123" })
                                          → "+254712345678"
  4. discard dek — never log, never cache across requests

BREACH SCENARIO A: database exfiltrated, KMS not compromised
  → attacker has encrypted_dek blobs — useless without KMS
  → PHI is safe

BREACH SCENARIO B: KMS key compromised
  → revoke KEK immediately
  → re-wrap DEKs with new KEK (data ciphertext untouched)
  → PHI exposure limited to window between compromise and revoke

Key Takeaways

These are the principles that survive algorithm changes. The specific numbers (19 MiB, 256-bit, TLS 1.3) will shift over time. The structure will not.

References