DEV Community

Cover image for How to encrypt user data in the browser with the Web Crypto API
Aniruddha
Aniruddha

Posted on AI-assisted

How to encrypt user data in the browser with the Web Crypto API

When someone saves a note in your app, it usually travels to your server over HTTPS and then sits in a database as plain text. HTTPS protects it on the way there. After that, anyone who can run a query can read it, and so can whoever ends up with a leaked backup.

You can avoid that by encrypting the data in the browser before it's sent. Your server stores bytes it can't read, and the key never leaves the user's device.

I built this for CanvasKeep, a diagram app I work on, where drawings are encrypted in the browser before they're saved. I expected to need a crypto library. I didn't. Everything here uses the Web Crypto API that already ships in every modern browser.

We'll build a small page that encrypts a note, keeps the key around between reloads, and lets the user recover that key on another device with a passphrase.

Prerequisites

You'll need to be comfortable with JavaScript, async/await, and Uint8Array (an array of bytes).

Any modern browser works. If you want to run the tests I wrote alongside this, you'll also need Node.js 20 or later, which has the same crypto.subtle API built in.

AES-GCM in two minutes

AES is a symmetric cipher. One key encrypts, and the same key decrypts.

GCM is the mode we'll run it in, and it's the reason I'd pick it as the default. Besides hiding the data, it attaches a 16-byte authentication tag. Change a single bit of the encrypted output and decryption fails loudly. Older modes like AES-CBC don't do that on their own. They'll happily hand you corrupted plaintext.

GCM also needs an IV (initialization vector): 12 random bytes, new ones every time you encrypt. The IV isn't secret. You store it right next to the ciphertext.

The one rule you can't break is this: never reuse an IV with the same key. If two messages share a key and an IV, an attacker can learn things about both of them and forge new ones. Fresh random IVs from crypto.getRandomValues() are fine for a huge number of messages per key, so as long as you generate a new one on every call, you won't have to think about this again.

Generating a key

export function generateKey(extractable = false) {
  return crypto.subtle.generateKey(
    { name: "AES-GCM", length: 256 },
    extractable,
    ["encrypt", "decrypt"],
  );
}
Enter fullscreen mode Exit fullscreen mode

The first argument picks the algorithm and size. AES comes in 128, 192, and 256 bits, and all three are considered secure. The third argument lists what the key may be used for. A key created with only "encrypt" can't decrypt anything.

The middle argument is the interesting one. With extractable set to false, your code can use the key, but it can never read the key's bytes. crypto.subtle.exportKey just throws.

Why bother? Say your page has an XSS bug, or a bad npm package sneaks some code in. That code can still use the key while the tab is open, and I don't want to pretend otherwise. What it can't do is copy the key and walk off with it. It's a smaller win than it sounds, but I'll take it.

You get back a CryptoKey object, not raw bytes, and you pass that object to everything else.

Encrypting text

The encryption itself is a single call. Most of the code is about getting bytes in and storing the result somewhere you can read it back.

const IV_BYTES = 12;
const FORMAT_VERSION = 1;

// Output layout: [version (1 byte)] [iv (12 bytes)] [ciphertext + 16-byte auth tag]
export async function encryptText(key, text) {
  const iv = crypto.getRandomValues(new Uint8Array(IV_BYTES)); // new IV every time
  const plaintext = new TextEncoder().encode(text);
  const ciphertext = await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, plaintext);

  const out = new Uint8Array(1 + IV_BYTES + ciphertext.byteLength);
  out[0] = FORMAT_VERSION;
  out.set(iv, 1);
  out.set(new Uint8Array(ciphertext), 1 + IV_BYTES);
  return out;
}
Enter fullscreen mode Exit fullscreen mode

We make a fresh IV (with crypto.getRandomValues, never Math.random()), turn the string into UTF-8 bytes because crypto.subtle only speaks bytes, and encrypt. The result already has the auth tag glued onto the end.

Then we pack it all into one Uint8Array, so it can be stored or uploaded as a single thing:

+---------+----------------+-------------------------------+
| version |       IV       |   ciphertext + auth tag (16)  |
| 1 byte  |    12 bytes    |      same length as text + 16 |
+---------+----------------+-------------------------------+
Enter fullscreen mode Exit fullscreen mode

That first version byte looks like overkill for a demo. I'd still add it. If you ever change the algorithm, the key size, or the layout, your code checks the first byte and knows how to read the old data. CanvasKeep's encrypted diagrams start with one for exactly that reason, and it costs one byte.

One thing that surprised me the first time: encrypt the same text twice and you get two completely different outputs, because the IV changes. That's the point. Someone looking at your database can't even tell that two notes say the same thing.

Decrypting, and what happens when someone changes the data

Decryption unpacks in the same order:

export async function decryptText(key, packed) {
  const bytes = new Uint8Array(packed);
  if (bytes[0] !== FORMAT_VERSION) {
    throw new Error(`Unknown format version: ${bytes[0]}`);
  }
  const iv = bytes.slice(1, 1 + IV_BYTES);
  const ciphertext = bytes.slice(1 + IV_BYTES);
  const plaintext = await crypto.subtle.decrypt({ name: "AES-GCM", iv }, key, ciphertext);
  return new TextDecoder().decode(plaintext);
}
Enter fullscreen mode Exit fullscreen mode

Now flip one bit and see what happens:

const key = await generateKey();
const packed = await encryptText(key, "Hello, encrypted world");

const tampered = packed.slice();
tampered[20] ^= 1; // flip one bit

await decryptText(key, tampered); // throws OperationError
Enter fullscreen mode Exit fullscreen mode

It throws an OperationError. A wrong key throws the same error.

The error tells you nothing about what went wrong, which is annoying while debugging and deliberate on the browser's part. In a real app, catch it and show the user something plain, like "This couldn't be decrypted."

Where to keep the key

A key that vanishes on reload is useless, so it has to live somewhere.

My first instinct was localStorage. But localStorage only stores strings, so you'd have to export the key to put it there, which means making it extractable, which throws away the protection we just set up.

IndexedDB is the better home, because it can store a CryptoKey object as is. The non-extractable flag survives the trip.

const DB_NAME = "my-app-keys";
const STORE = "keys";

function openDB() {
  return new Promise((resolve, reject) => {
    const req = indexedDB.open(DB_NAME, 1);
    req.onupgradeneeded = () => req.result.createObjectStore(STORE);
    req.onsuccess = () => resolve(req.result);
    req.onerror = () => reject(req.error);
  });
}

export async function saveKey(id, key) {
  const db = await openDB();
  return new Promise((resolve, reject) => {
    const tx = db.transaction(STORE, "readwrite");
    tx.objectStore(STORE).put(key, id);
    tx.oncomplete = () => resolve();
    tx.onerror = () => reject(tx.error);
  });
}

export async function loadKey(id) {
  const db = await openDB();
  return new Promise((resolve, reject) => {
    const req = db.transaction(STORE, "readonly").objectStore(STORE).get(id);
    req.onsuccess = () => resolve(req.result ?? null);
    req.onerror = () => reject(req.error);
  });
}
Enter fullscreen mode Exit fullscreen mode

I checked this in Chrome. After a reload, loadKey hands back a CryptoKey with extractable: false, exportKey refuses to touch it, and decryption still works.

There's a catch, and it's a big one. That key exists in one browser on one machine. Clear site data, or open the app on a laptop instead of a desktop, and the key isn't there. Neither is the user's data.

I hit exactly this with CanvasKeep. The key lived in one browser's IndexedDB, so when someone opened their diagrams in a second browser, that browser had no key and couldn't decrypt anything. Fixing it is what the next section is about.

Backing the key up with a passphrase

The idea: encrypt the key itself with a second key that comes from something the user remembers. That encrypted copy can sit on your server, because without the passphrase it's just noise.

Turning a passphrase into a key

You can't use a passphrase as a key directly. It's too short and too guessable. PBKDF2 stretches it by hashing it over and over, so every guess an attacker makes is slow.

const PBKDF2_ITERATIONS = 600_000; // OWASP recommendation for PBKDF2-HMAC-SHA256

async function deriveKeyFromPassphrase(passphrase, salt) {
  const material = await crypto.subtle.importKey(
    "raw",
    new TextEncoder().encode(passphrase),
    "PBKDF2",
    false,
    ["deriveKey"],
  );
  return crypto.subtle.deriveKey(
    { name: "PBKDF2", salt, iterations: PBKDF2_ITERATIONS, hash: "SHA-256" },
    material,
    { name: "AES-GCM", length: 256 },
    false,
    ["wrapKey", "unwrapKey"],
  );
}
Enter fullscreen mode Exit fullscreen mode

The salt is 16 random bytes, different for each user, so nobody can precompute guesses for everyone at once. Like the IV, it isn't secret.

600,000 iterations is what OWASP currently recommends for PBKDF2 with SHA-256. You'll notice the pause. For something a user does once per device, that's fine.

Wrapping the key

"Wrapping" is just encrypting a key with another key. Web Crypto has a method for it:

export async function wrapKeyWithPassphrase(key, passphrase) {
  const salt = crypto.getRandomValues(new Uint8Array(16));
  const iv = crypto.getRandomValues(new Uint8Array(IV_BYTES));
  const wrappingKey = await deriveKeyFromPassphrase(passphrase, salt);
  const wrapped = await crypto.subtle.wrapKey("raw", key, wrappingKey, { name: "AES-GCM", iv });
  return {
    salt: toBase64(salt),
    iv: toBase64(iv),
    wrappedKey: toBase64(new Uint8Array(wrapped)),
    iterations: PBKDF2_ITERATIONS,
  };
}
Enter fullscreen mode Exit fullscreen mode

That object is what you send to your server. Nothing in it is secret on its own.

Getting it back on another device

On the new device, the user types the passphrase and you rebuild the key:

export async function unwrapKeyWithPassphrase(backup, passphrase) {
  const wrappingKey = await deriveKeyFromPassphrase(passphrase, fromBase64(backup.salt));
  return crypto.subtle.unwrapKey(
    "raw",
    fromBase64(backup.wrappedKey),
    wrappingKey,
    { name: "AES-GCM", iv: fromBase64(backup.iv) },
    { name: "AES-GCM", length: 256 },
    false,
    ["encrypt", "decrypt"],
  );
}
Enter fullscreen mode Exit fullscreen mode

See the false near the bottom? The key comes back non-extractable, ready to drop into IndexedDB. A wrong passphrase makes unwrapKey throw, since the wrapped key is protected by GCM too.

The part that tripped me up

wrapKey needs to read the key's bytes to encrypt them, so it only works on an extractable key. That seems to clash with everything I said earlier about keeping keys non-extractable.

It works out if you do things in order. Create the key as extractable, make the backup straight away, then store a non-extractable copy and drop the original:

export async function lockKey(key) {
  const raw = await crypto.subtle.exportKey("raw", key);
  return crypto.subtle.importKey("raw", raw, { name: "AES-GCM" }, false, ["encrypt", "decrypt"]);
}
Enter fullscreen mode Exit fullscreen mode

Once that's done, nothing on the page can export the key again.

The snippets above lean on two small helpers for converting bytes to base64 and back:

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

export function fromBase64(base64) {
  const binary = atob(base64);
  const bytes = new Uint8Array(binary.length);
  for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
  return bytes;
}
Enter fullscreen mode Exit fullscreen mode

The finished page

Here's everything wired together. To keep it self-contained, localStorage stands in for the server. In a real app that line would be a fetch() to your API, and only the ciphertext would go over the wire.

<textarea id="note">Meeting notes: launch on Friday.</textarea>
<button id="save">Encrypt and save</button>
<button id="load">Load and decrypt</button>

<h2>What the server would store</h2>
<pre id="stored">(nothing yet)</pre>

<h2>Decrypted</h2>
<pre id="output">(nothing yet)</pre>

<script type="module">
  import { generateKey, encryptText, decryptText, toBase64, fromBase64 } from "./crypto.js";
  import { saveKey, loadKey } from "./key-store.js";

  const $ = (id) => document.getElementById(id);

  async function getKey() {
    let key = await loadKey("notes");
    if (!key) {
      key = await generateKey(false); // non-extractable
      await saveKey("notes", key);
    }
    return key;
  }

  $("stored").textContent = localStorage.getItem("note") ?? "(nothing yet)";

  $("save").onclick = async () => {
    const key = await getKey();
    const packed = await encryptText(key, $("note").value);
    // Stand-in for a fetch() to your API: only ciphertext goes over the wire.
    localStorage.setItem("note", toBase64(packed));
    $("stored").textContent = localStorage.getItem("note");
  };

  $("load").onclick = async () => {
    const stored = localStorage.getItem("note");
    if (!stored) return;
    const key = await getKey();
    try {
      $("output").textContent = await decryptText(key, fromBase64(stored));
    } catch {
      $("output").textContent = "Could not decrypt: wrong key or the data was changed.";
    }
  };
</script>
Enter fullscreen mode Exit fullscreen mode

Serve the folder with any static server (npx serve is fine). ES modules won't load from a file:// URL. Type a note and hit "Encrypt and save", and the middle box fills with base64 that means nothing without the key. Reload, hit "Load and decrypt", and your note is back.

The encrypted notes page showing the base64 ciphertext and the decrypted note

Mistakes I'd watch out for

Reusing an IV is the big one. Call crypto.getRandomValues for a new IV on every single encrypt, no exceptions.

Exporting keys into localStorage is the next. It works, and it quietly undoes the point of non-extractable keys. Store the CryptoKey in IndexedDB instead.

Don't invent your own crypto, either. No clever XOR tricks, no Math.random() for keys or IVs. crypto.subtle and crypto.getRandomValues are all you need.

Plan for lost keys from day one. If the key is gone, the data is gone, and users will not find that as interesting as you do.

And be honest about what this protects. It keeps data safe on your server and in your backups. It does nothing for a page that's already running malicious code while the user is on it.

Where to go from here

The next step I'd take is one key per document. Give each document its own key and wrap that key with the user's main key. Then you can share one document without exposing the rest. That's how CanvasKeep handles diagrams.

Passphrases get forgotten, so a recovery key is worth adding too: wrap the same key a second time with a long random code the user writes down somewhere.

Once each document has its own key, you can share it with a link that carries the key in the URL. That's the next article.

Wrapping up

None of this needs a library. AES-GCM encrypts and catches tampering, non-extractable keys can't be read out by scripts, IndexedDB keeps them across reloads, and PBKDF2 plus wrapKey gets users back in on a new device. The full code, tests included, is about 150 lines.

I use this approach in CanvasKeep, a diagram app where drawings are encrypted in your browser before they're saved.

Top comments (0)