Tech Analysis · Nov 4, 2026 · 8 min read

Web Crypto API: AES-GCM Patterns for Next.js Apps

Tech Analysis

Practical patterns for client-side AES-GCM encryption using the Web Crypto API in Next.js 15. Covers PBKDF2 key derivation, IV management, complete code examples, server integration, and production tr

Web Crypto API: AES-GCM Patterns for Next.js Apps

The Web Crypto API Fundamentals

The Web Crypto API exposes cryptographic operations through window.crypto.subtle, allowing developers to perform encryption directly in the browser without external dependencies. It became widely available with Chrome 37, Firefox 34, Safari 7, and Edge 12, achieving over 96% global browser support by late 2024. The API operates exclusively with Promises and uses the browser's native cryptographic libraries, delivering both performance and resistance to common JavaScript-side implementation mistakes found in libraries like CryptoJS.

All operations run in constant-time where possible and isolate key material from JavaScript memory when using non-extractable keys. For most web applications, the primary interface is the SubtleCrypto object. Keys are represented as CryptoKey objects rather than raw bytes, preventing accidental logging or transmission.

Common operations include generateKey(), encrypt(), decrypt(), importKey(), deriveKey(), and digest(). The API supports AES-GCM, AES-CBC, RSA-OAEP, ECDSA, and several hashing algorithms. Because it runs asynchronously, all calls return Promises, requiring careful handling in React components and server actions.

AES-GCM: Algorithm Choice and Parameters

AES-GCM is the recommended symmetric encryption algorithm for most client-side use cases. It provides both confidentiality and authenticity through a 128-bit authentication tag, eliminating the need for separate HMAC steps required by AES-CBC. The Web Crypto API implements AES-GCM with 128, 192, or 256-bit keys. For production systems, AES-GCM-256 is standard.

Critical parameters include a 12-byte (96-bit) initialization vector (IV) and a tag length of 128 bits. Reusing an IV with the same key completely breaks security under GCM mode. The API enforces this requirement by throwing an error on decryption if authentication fails. Typical ciphertext expansion is 16 bytes for the authentication tag plus the IV size when transmitting both.

Compared to AES-CBC, GCM offers built-in integrity checking and better performance on hardware supporting AES-NI instructions. Trade-offs include slightly larger output size and the strict IV uniqueness requirement. For applications handling medical records, financial notes, or end-to-end encrypted messaging, AES-GCM provides the necessary security properties without adding complex custom authentication code.

Key Derivation with PBKDF2

Raw user passwords should never be used directly as AES keys. The Web Crypto API provides PBKDF2 for secure key derivation. Current OWASP recommendations specify a minimum of 600,000 iterations of PBKDF2-HMAC-SHA256 when using a 256-bit output key. This slows brute-force attacks while remaining usable on modern devices.

A typical derivation flow starts with a user-supplied password, a unique salt per user (at least 16 random bytes), and the iteration count. The derived key is then used to create an AES-GCM CryptoKey object with extractable: false. Storing the salt alongside the ciphertext is safe because salts are not secret.

async function deriveKey(password, salt, iterations = 600000) {
  const encoder = new TextEncoder();
  const baseKey = await crypto.subtle.importKey(
    "raw",
    encoder.encode(password),
    "PBKDF2",
    false,
    ["deriveKey"]
  );

  return crypto.subtle.deriveKey(
    {
      name: "PBKDF2",
      salt: salt,
      iterations: iterations,
      hash: "SHA-256"
    },
    baseKey,
    { name: "AES-GCM", length: 256 },
    false,
    ["encrypt", "decrypt"]
  );
}

This function produces a non-extractable AES-GCM key suitable for immediate use. Applications often combine this with a second factor or derive separate keys for encryption and authentication when implementing more complex schemes.

Complete Encryption and Decryption Example

Here is a production-ready implementation that encrypts JSON payloads. The example generates a fresh IV for every encryption, combines the ciphertext with the IV for transmission, and uses base64 for safe transport over JSON APIs.

async function encryptPayload(payload, password) {
  const encoder = new TextEncoder();
  const salt = crypto.getRandomValues(new Uint8Array(16));
  const iv = crypto.getRandomValues(new Uint8Array(12));

  const key = await deriveKey(password, salt);
  const encodedData = encoder.encode(JSON.stringify(payload));

  const ciphertextBuffer = await crypto.subtle.encrypt(
    {
      name: "AES-GCM",
      iv: iv,
      tagLength: 128
    },
    key,
    encodedData
  );

  const ciphertext = new Uint8Array(ciphertextBuffer);
  const combined = new Uint8Array(salt.byteLength + iv.byteLength + ciphertext.byteLength);
  
  combined.set(salt, 0);
  combined.set(iv, salt.byteLength);
  combined.set(ciphertext, salt.byteLength + iv.byteLength);

  return btoa(String.fromCharCode(...combined));
}

async function decryptPayload(combinedBase64, password) {
  const combined = Uint8Array.from(atob(combinedBase64), c => c.charCodeAt(0));
  const salt = combined.slice(0, 16);
  const iv = combined.slice(16, 28);
  const ciphertext = combined.slice(28);

  const key = await deriveKey(password, salt);

  const decryptedBuffer = await crypto.subtle.decrypt(
    {
      name: "AES-GCM",
      iv: iv,
      tagLength: 128
    },
    key,
    ciphertext
  );

  const decoder = new TextDecoder();
  return JSON.parse(decoder.decode(decryptedBuffer));
}

This code handles the complete round-trip. The combined array approach ensures all necessary material travels together. Error handling for OperationError during decryption indicates either wrong password or tampered data. In a real application, wrap these calls with try/catch and provide user-friendly messages without leaking timing information.

Integration with Next.js 15 Server Actions

Next.js 15 server actions provide a clean way to receive encrypted payloads. The client encrypts before calling the action, and the server stores the ciphertext without ever seeing the plaintext or the user's password-derived key. This creates a zero-knowledge architecture suitable for healthtech or fintech startups.

Client component usage looks like this:

"use client";

async function saveSecureNote(content, password) {
  const encrypted = await encryptPayload({ content, createdAt: new Date().toISOString() }, password);
  
  await saveNoteAction(encrypted);
}

The corresponding server action only receives the base64 string and stores it alongside a user identifier and the salt (if stored separately). Retrieval follows the inverse pattern: the server returns the ciphertext, the client prompts for the password, and decryption happens entirely in the browser.

When using React Server Components, avoid any attempt to decrypt on the server. The password never leaves the client. For applications requiring server-side search, consider storing searchable metadata in plaintext while keeping sensitive fields encrypted. This is a deliberate and documented trade-off.

Production Considerations and Trade-offs

Client-side encryption shifts key management responsibility to the end user. If a user forgets their password, data becomes permanently inaccessible. Many production systems add account recovery mechanisms using WebAuthn passkeys as a second factor or implement key escrow with strong organizational controls. Each choice carries security and usability implications.

  • Performance: Encryption of a 50KB document typically completes in low single-digit milliseconds on recent laptops. Deriving a key with 600,000 PBKDF2 iterations takes 300-800ms on mobile devices, which can be cached for the session.
  • IV collisions: The 96-bit IV space is large enough that random generation is safe at internet scale. Never use counters without proper namespacing when multiple clients encrypt under the same derived key.
  • Browser compatibility: Always test on Safari 16.4+, as older iOS versions had subtle bugs in Web Crypto with large ArrayBuffers.
  • Memory pressure: Large documents (multiple megabytes) should be processed in streams where possible, although the current Web Crypto API does not yet offer streaming encryption in all browsers.

Logging practices must exclude any plaintext or password material. Error messages returned from crypto.subtle should be caught and replaced with generic failures. For compliance (HIPAA, SOC 2), document that decryption keys never reach your servers and maintain audit logs of client-side operations where possible.

Advanced Patterns: Hybrid Encryption and Key Rotation

For sharing encrypted data between users, hybrid encryption is standard. One party generates a random AES-GCM key, encrypts the payload, then encrypts that key using the recipient's public RSA-OAEP or EC key. The Web Crypto API supports both RSA-OAEP and ECDH for this purpose.

Key rotation follows a similar pattern to server-side systems but must be orchestrated client-side. A common approach stores a key-encryption key (KEK) derived from the user's password and uses it to re-wrap data encryption keys (DEKs) when rotation occurs. The following steps are typical:

  1. Derive current KEK from password
  2. Decrypt all DEKs with current KEK
  3. Derive new KEK from new password
  4. Re-encrypt DEKs with new KEK
  5. Store updated wrapped DEKs

This process happens entirely in the browser. Applications implementing this pattern often show a progress bar because re-encrypting thousands of records can take several seconds. Libraries such as webcrypto-core from the Node.js ecosystem can help abstract some of these flows when sharing code between server and client.

Another advanced technique uses the WebCrypto API alongside the Storage API or IndexedDB to cache derived keys for the duration of a session. The CryptoKey object can be stored in IndexedDB using the structured clone algorithm, but only if created with extractable: true, which reduces security. Most production implementations accept the trade-off only for short-lived session keys and re-derive from the password on every full page load.

Measuring and Monitoring in Production

Successful deployments instrument encryption performance without logging sensitive data. Track metrics such as average key derivation time, encryption throughput in kilobytes per second, and decryption failure rates. High decryption failure rates often indicate poor user experience with password entry rather than cryptographic errors.

Tools like Sentry can capture non-sensitive metadata around encryption calls. Combine this with feature flags (using Vercel Edge Config or LaunchDarkly) to gradually roll out new iteration counts or algorithm choices. When increasing PBKDF2 iterations from 310,000 to 600,000, monitor mobile conversion rates because the added latency can be noticeable on lower-end Android devices.

Regularly audit client-side bundles to ensure no accidental inclusion of development-only polyfills that might weaken cryptographic primitives. The Web Crypto API is not available in Node.js by default until version 19 with the --experimental-global-webcrypto flag or through the node:crypto webcrypto submodule in Node 22. This makes testing challenging; many teams maintain a small test harness that runs the same encryption code in both browser and Node environments using the official Web Crypto polyfill from the W3C.

By following these patterns, teams building SaaS products can offer strong privacy guarantees while keeping implementation complexity manageable. The combination of Next.js server actions, the Web Crypto API, and careful key derivation creates production systems where user data remains protected even in the event of a full database breach.

Keep reading

Related Research