Back to Hub
SECURITY •

Encrypt, Isolate, Verify: Browser Security That Holds Up.

Browser security has two halves: cryptography (WebCrypto gives you real AES, HKDF, and ECDH with no libraries) and isolation (sandboxes that contain untrusted code). This guide covers both — with the honest threat models, because crypto without a threat model is just math.

Real encryption with WebCrypto

No libraries, no hand-rolled ciphers: crypto.subtle gives you audited primitives. The workhorse is AES-GCM (authenticated encryption — it detects tampering):

async function encryptNote(plaintext, password) {
  // 1. Stretch the password into a key (never use it directly)
  const base = await crypto.subtle.importKey(
    'raw', new TextEncoder().encode(password), 'PBKDF2', false, ['deriveKey']);
  const salt = crypto.getRandomValues(new Uint8Array(16));
  const key = await crypto.subtle.deriveKey(
    { name: 'PBKDF2', salt, iterations: 600_000, hash: 'SHA-256' },
    base, { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']);
  // 2. Encrypt with a fresh random IV every time
  const iv = crypto.getRandomValues(new Uint8Array(12));
  const ct = await crypto.subtle.encrypt(
    { name: 'AES-GCM', iv }, key,
    new TextEncoder().encode(plaintext));
  return { salt, iv, ct }; // store all three
}

Three rules that matter more than the code: never reuse an IV with the same key, never skip the KDF step (raw passwords aren't keys), and store the salt alongside the ciphertext — it's not secret.

One key, many purposes: HKDF

Deriving your encryption key and your HMAC key from the same master secret is a classic mistake. HKDF derives independent, purpose-bound sub-keys from one master:

const fileKey = await crypto.subtle.deriveKey(
  { name: 'HKDF', hash: 'SHA-256', salt,
    info: new TextEncoder().encode('file-encryption-v1') },
  masterKey, { name: 'AES-GCM', length: 256 },
  false, ['encrypt', 'decrypt']);

The info parameter is the whole point: different info strings → cryptographically independent keys. Compromise of one purpose never leaks another.

The honest threat model: client-side encryption protects data at rest from other origins, stolen backups, and curious server admins. It does not protect against a compromised device, malicious browser extensions, or XSS in your own page — if an attacker runs JS in your origin, they can read your keys. "Zero-knowledge" in a web app means the server never sees plaintext, not that the client is invulnerable.

Sandboxing untrusted code: iframes done right

Running user plugins or third-party widgets? The sandboxed iframe is the battle-tested primitive — it strips capabilities by default and you add back only what's needed:

<iframe
  sandbox="allow-scripts"
  src="https://plugins.example.com/widget.html"
  csp="default-src 'none'; script-src 'self'">
</iframe>

Without allow-same-origin, the iframe gets an opaque origin — it can't touch your DOM, cookies, or storage even if its script is malicious. Communicate across the boundary with postMessage and always validate event.origin. This is the pattern every serious plugin system (and every online code playground's preview pane) is built on.

Fenced frames: the stricter sibling

Fenced frames (<fencedframe>) go further than sandboxed iframes: the embedder can't even observe what's inside — no reading its size, URL, or events. The honest context: this was built for privacy-preserving advertising (so ad tech can't fingerprint users across sites), it's Chromium-only, and for most app plugin systems a sandboxed iframe is the right, portable choice.

ShadowRealm: lightweight JS sandboxes (emerging)

Sometimes an iframe is too heavy — you want to run untrusted JavaScript (a user formula, a plugin script) in the same page. ShadowRealm, a Stage 3 TC39 proposal, creates a fresh JS realm with its own globals and no DOM access:

// Proposed API — verify current browser support before using:
const realm = new ShadowRealm();
const result = await realm.importValue('./plugin.js', 'run');
// Plugin code runs isolated: no window, no document, no fetch
// unless you explicitly hand it wrapped capabilities.

Until it standardizes, the practical equivalents are sandboxed iframes, workers with no sensitive data, or vetted sandboxing libraries. Don't build production security on a proposal.

E2EE media: WebRTC insertable streams

Insertable streams let you encrypt WebRTC audio/video frames before they hit the network — true end-to-end encryption for calls:

const sender = pc.addTrack(videoTrack);
const streams = sender.createEncodedStreams();
const transform = new TransformStream({
  transform(frame, controller) {
    // AES-GCM the frame bytes with a key exchanged out-of-band
    controller.enqueue(encryptFrame(frame.data, sharedKey));
  }
});
streams.readable.pipeThrough(transform).pipeTo(streams.writable);

The critical honesty: E2EE protects media in transit, but WebRTC still needs signaling (exchanging SDP offers/answers requires a server or manual copy-paste) and key exchange (the shared key must reach both peers securely — ECDH via WebCrypto is the standard answer). "P2P" never means "no server at all."

Security checklist for any browser app: ① HTTPS everywhere (WebCrypto won't even expose some APIs on http), ② Content Security Policy headers, ③ sandboxed iframes for untrusted content, ④ KDF before encryption, fresh IVs, ⑤ validate every postMessage origin. Get these five right and you're ahead of most of the web.

Prototype Security Patterns Safely.

WebCrypto, sandboxing, and isolation — experiment in a local-first browser IDE.

Launch NitroIDE