Skip to content
yutils

Web Crypto API — SHA, HMAC, AES, and JWT in the Browser Without Libraries

Native crypto.subtle in browsers — digest, sign/verify, encrypt/decrypt, key import/export, and why getRandomValues replaces Math.random for security.

~9 min read

Need a SHA-256 hash in the browser? The reflex is to pull in crypto-js, jsSHA, or bcryptjs. But every modern browser (and Node 15+, Deno, Bun, Cloudflare Workers) ships a standard crypto.subtle — the Web Cryptography API. Zero dependencies, native speed, HTTPS required. This guide covers the five most common patterns: digest, HMAC, secure random, AES, and signing a JWT.

Why Web Crypto

  • 0 KB bundle — crypto-js alone adds 50+ KB gzipped. Web Crypto is already in the browser.
  • Native speed — C++ implementation, often 10–100× faster than JS libraries.
  • Standard — W3C Web Cryptography API (2017). Same API in browsers, Node, Deno, and Workers.
  • Safer — keys are CryptoKey objects, not raw bytes. Built-in constant-time comparison.

Prerequisite — Secure Context

crypto.subtle only exists on HTTPS or localhost. On http://example.com it's undefined. Raw IPs don't qualify either.

if (!window.isSecureContext) {
  console.warn("crypto.subtle requires HTTPS");
}
if (!crypto?.subtle) {
  throw new Error("Web Crypto not available");
}

1. Digest — SHA-256/384/512

The most common use. One-way hash of text or bytes.

async function sha256(text) {
  const data = new TextEncoder().encode(text);
  const hash = await crypto.subtle.digest("SHA-256", data);
  return Array.from(new Uint8Array(hash))
    .map((b) => b.toString(16).padStart(2, "0"))
    .join("");
}

await sha256("hello");
// "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"

TextEncoder converts to UTF-8 bytes. digest returns an ArrayBuffer; wrap as Uint8Array and hex-encode. Cross-check the result with SHA Hash. Swap the algorithm string for SHA-1, SHA-384, SHA-512. Avoid SHA-1 for security (checksums only).

2. HMAC — sign and verify

Sign a message with a shared secret. Used for webhooks, JWT signatures, session tokens.

async function hmacSign(message, secret) {
  const enc = new TextEncoder();
  const key = await crypto.subtle.importKey(
    "raw",
    enc.encode(secret),
    {name: "HMAC", hash: "SHA-256"},
    false,
    ["sign"]
  );
  const sig = await crypto.subtle.sign("HMAC", key, enc.encode(message));
  return Array.from(new Uint8Array(sig))
    .map((b) => b.toString(16).padStart(2, "0"))
    .join("");
}

verify takes a key, message, and received signature bytes and returns a boolean. It's constant-time by construction — safer than a JS ===. Compare results against HMAC Generator for the same inputs.

3. Secure random — getRandomValues

Math.random() is seeded predictably — never use it for tokens, CSRF secrets, or UUIDs. Use crypto.getRandomValues:

// 16 secure random bytes
const bytes = new Uint8Array(16);
crypto.getRandomValues(bytes);

// Standards-compliant UUID v4
const id = crypto.randomUUID();
// "550e8400-e29b-41d4-a716-446655440000"

// 32-char hex token
const token = Array.from(crypto.getRandomValues(new Uint8Array(16)))
  .map((b) => b.toString(16).padStart(2, "0"))
  .join("");

crypto.randomUUID() follows RFC 4122 v4. For bulk generation, including v7 and ULID, UUID / ULID Generator handles it.

4. AES — symmetric encryption

Protect data stored in the browser (vaults, E2E messaging) or encrypt a short string under a secret.

async function encrypt(plaintext, password) {
  const enc = new TextEncoder();

  // 1. password -> key (PBKDF2)
  const salt = crypto.getRandomValues(new Uint8Array(16));
  const keyMaterial = await crypto.subtle.importKey(
    "raw", enc.encode(password), "PBKDF2", false, ["deriveKey"]
  );
  const key = await crypto.subtle.deriveKey(
    {name: "PBKDF2", salt, iterations: 600000, hash: "SHA-256"},
    keyMaterial,
    {name: "AES-GCM", length: 256},
    false,
    ["encrypt"]
  );

  // 2. AES-GCM encrypt with random IV
  const iv = crypto.getRandomValues(new Uint8Array(12));
  const ct = await crypto.subtle.encrypt(
    {name: "AES-GCM", iv}, key, enc.encode(plaintext)
  );

  // Bundle salt + iv + ciphertext (decryption needs both)
  return {
    salt: Array.from(salt),
    iv: Array.from(iv),
    ct: Array.from(new Uint8Array(ct))
  };
}

Key points:

  • Use AES-GCM. Authenticated encryption (AEAD). Safer than CBC.
  • Fresh IV (nonce) every time, 12 bytes. Reusing (key + IV) destroys GCM security.
  • PBKDF2 with 600,000 iterations (2026 OWASP recommendation) when deriving from a user password.

5. Signing a JWT

Issue an HS256 JWT with Web Crypto alone — no library.

async function signJwt(payload, secret) {
  const enc = new TextEncoder();
  const header = {alg: "HS256", typ: "JWT"};

  const b64url = (obj) =>
    btoa(JSON.stringify(obj))
      .replace(/=/g, "").replace(/\+/g, "-").replace(/\//g, "_");

  const signingInput = b64url(header) + "." + b64url(payload);

  const key = await crypto.subtle.importKey(
    "raw", enc.encode(secret),
    {name: "HMAC", hash: "SHA-256"},
    false, ["sign"]
  );
  const sig = await crypto.subtle.sign("HMAC", key, enc.encode(signingInput));

  const sigB64 = btoa(String.fromCharCode(...new Uint8Array(sig)))
    .replace(/=/g, "").replace(/\+/g, "-").replace(/\//g, "_");

  return signingInput + "." + sigB64;
}

Same pattern works for HS384 and HS512 (swap the hash). For asymmetric algorithms like RS256 or ES256 use generateKeyPair + exportKey with the PKCS8/SPKI formats. Verify with JWT Encoder (HMAC) or JWT Decoder.

Handling keys — the CryptoKey object

Web Crypto keys are CryptoKey objects, not raw bytes. With extractable: false the bytes can never reach JS — immune to key-leak attacks.

  • importKey — raw/JWK/PKCS8/SPKI → CryptoKey
  • exportKey — CryptoKey → raw/JWK/PKCS8/SPKI (extractable only)
  • generateKey — new key
  • deriveKey — derive from another key or password (PBKDF2 etc.)

Long-term keys can be stored in IndexedDB as CryptoKey objects directly — the browser serializes the wrapper, not the bytes.

Common pitfalls

1. Forgetting it's async

Every crypto.subtle.* returns a Promise. Without await you'll be hashing "[object Promise]" by accident.

2. Comparing ArrayBuffers with ===

Always false. Wrap as Uint8Array and compare bytes — or better, use crypto.subtle.verify for authenticated data, which is constant-time.

3. Standard base64 vs base64url

btoa emits standard base64 with +, /, and padding. JWT and URLs need base64url — convert manually.

4. Reusing an IV

Same key + same IV in AES-GCM leaks plaintext. Generate a fresh 12- byte IV every encryption.

5. Using Math.random for tokens

Predictable. Always crypto.getRandomValues or crypto.randomUUID for anything security-sensitive.

6. Assuming the API exists

Modern browsers (Chrome 37+, Firefox 34+, Safari 11+, Edge 12+) all support it, but legacy environments and some webviews don't. Check crypto.subtle exists.

Node, Deno, Workers compatibility

  • Node 15+: globalThis.crypto.subtle or import {webcrypto} from "crypto"
  • Deno: global crypto.subtle.
  • Cloudflare Workers: global crypto.subtle.
  • Bun: global crypto.subtle.

Edge functions, SSR, serverless — the same code works everywhere. On Cloudflare Workers, native crypto is effectively the only option.

Summary

  • crypto.subtle is the standard Web Crypto API, available wherever you have HTTPS. No library needed.
  • Core primitives: digest, sign/verify, encrypt/decrypt, deriveKey, generateKey. All async.
  • AES-GCM with a fresh IV. PBKDF2 with 600,000 iterations. Skip AES-CBC.
  • Secure random comes from crypto.getRandomValues and crypto.randomUUID, not Math.random.
  • CryptoKey objects hide raw bytes — safe to store in IndexedDB.
  • Same API across Node, Deno, Workers, Bun. SSR and edge functions included.
Back to guides