X

Using the Web Crypto API for Browser-Side Encryption

The Web Crypto API lets a browser encrypt data before it is stored or sent elsewhere. That can be useful for a private local draft or an application designed so its server stores ciphertext instead of the user's plaintext. It does not, by itself, make a web app secure: the way you create, retain, recover, and use the key matters just as much as the encryption call.

This guide shows a small text-encryption example using a passphrase, PBKDF2, and AES-GCM. It is intended for small values, not large files or a complete account-security system.

When browser-side encryption is useful

Encrypt data in the browser when there is a clear boundary you want to protect, such as keeping an offline note unreadable without a user's passphrase or sending ciphertext to a service that should not receive the plaintext. In the latter design, the passphrase or key must stay outside the server's reach; otherwise the server can decrypt the data too.

If the server needs to search, validate, transform, or use the original value, client-side encryption may not fit. It also cannot replace sign-in, server-side authorization, secure transport, or careful handling of application code.

Choose an authenticated encryption mode

Use the browser's cryptographic primitives rather than implementing an algorithm yourself. The example uses AES-GCM, an authenticated mode: decryption fails if the ciphertext or authentication tag has been changed. The Web Crypto API exposes these operations through crypto.subtle.

The example derives a 256-bit AES key from a passphrase with PBKDF2 and a fresh random salt. It also generates a fresh 12-byte initialization vector (IV) for each encrypted value. The salt and IV are not secret, but they must be kept with the ciphertext so it can be decrypted. Never reuse an IV with the same AES-GCM key.

The API is available only in secure contexts such as HTTPS (with localhost treated as a secure context for development). Use crypto.getRandomValues() for the salt and IV; do not substitute Math.random().

Encrypt and decrypt a small text value

The following browser code returns a JSON-serializable envelope containing the format version, salt, IV, and ciphertext. Keep the format version stable for existing records; if you change the key-derivation parameters, introduce a versioned format and retain a compatible decryption path for older records.

const encoder = new TextEncoder();
const decoder = new TextDecoder("utf-8", { fatal: true });
const KDF_ITERATIONS = 600_000;

function toBase64(bytes) {
  let binary = "";
  for (const byte of bytes) {
    binary += String.fromCharCode(byte);
  }
  return btoa(binary);
}

function fromBase64(value) {
  if (typeof value !== "string") {
    throw new TypeError("Expected a base64 string.");
  }

  const binary = atob(value);
  return Uint8Array.from(binary, (character) => character.charCodeAt(0));
}

async function deriveKey(passphrase, salt) {
  if (typeof passphrase !== "string" || passphrase.length === 0) {
    throw new TypeError("Enter a passphrase.");
  }

  const passwordKey = await crypto.subtle.importKey(
    "raw",
    encoder.encode(passphrase),
    "PBKDF2",
    false,
    ["deriveKey"],
  );

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

async function encryptText(plaintext, passphrase) {
  if (typeof plaintext !== "string") {
    throw new TypeError("Plaintext must be a string.");
  }

  const salt = crypto.getRandomValues(new Uint8Array(16));
  const iv = crypto.getRandomValues(new Uint8Array(12));
  const key = await deriveKey(passphrase, salt);
  const ciphertext = await crypto.subtle.encrypt(
    { name: "AES-GCM", iv },
    key,
    encoder.encode(plaintext),
  );

  return {
    version: 1,
    salt: toBase64(salt),
    iv: toBase64(iv),
    ciphertext: toBase64(new Uint8Array(ciphertext)),
  };
}

async function decryptText(envelope, passphrase) {
  if (
    !envelope ||
    envelope.version !== 1 ||
    typeof envelope.salt !== "string" ||
    typeof envelope.iv !== "string" ||
    typeof envelope.ciphertext !== "string"
  ) {
    throw new TypeError("Invalid encrypted record.");
  }

  const salt = fromBase64(envelope.salt);
  const iv = fromBase64(envelope.iv);
  const ciphertext = fromBase64(envelope.ciphertext);

  if (salt.length !== 16 || iv.length !== 12 || ciphertext.length < 16) {
    throw new TypeError("Invalid encrypted record.");
  }

  const key = await deriveKey(passphrase, salt);
  const plaintext = await crypto.subtle.decrypt(
    { name: "AES-GCM", iv },
    key,
    ciphertext,
  );

  return decoder.decode(plaintext);
}

Use the functions with a passphrase collected from the user:

const envelope = await encryptText("A private note", passphrase);
const restoredText = await decryptText(envelope, passphrase);

Store or send the envelope, not the original text or passphrase. A wrong passphrase and a modified ciphertext both cause AES-GCM decryption to fail; handle that rejection at the UI boundary with a clear, generic message rather than silently returning partial data.

The code uses 600,000 PBKDF2-HMAC-SHA-256 iterations. OWASP lists that count for PBKDF2 password storage when FIPS-140 compliance is required; it is not a universal performance setting for browser encryption. Measure the delay on the slowest device you support, and do not let untrusted record data select an arbitrary iteration count.

Decide where the key and ciphertext belong

If a user forgets the passphrase, the encrypted value cannot be recovered unless you designed a separate, secure recovery mechanism. Do not save the passphrase beside the ciphertext or put a decryption key in a public frontend bundle. A non-extractable CryptoKey is not a defense against malicious code in the same page: that code may still ask the browser to use it.

Browser storage is not a security boundary against scripts running on the same origin. An XSS flaw can expose data after it is decrypted, capture a passphrase as it is entered, or use the app's decryption code. If you store envelopes in localStorage or IndexedDB, protect the application against script injection and treat browser-profile access as a separate risk. See our guide to security headers for small web applications for one layer of defense in depth.

When sending ciphertext to a service, still authenticate users and check authorization on the server for every record. Encryption does not stop an unauthorized user from requesting, replacing, or deleting records if the server permits those actions. Keep server credentials out of browser code too; our guide to environment variables and secrets in web projects explains why a frontend build cannot keep a secret from its visitors.

For large files, this example is the wrong shape: SubtleCrypto.encrypt() works on a complete input buffer, and base64 adds overhead. Use a design intended for large data, define how keys are backed up or rotated, and get a security review before using browser-side encryption for sensitive production data.

References

Categories: Development
Related Post