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
CryptoKeyobjects, 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.subtleorimport {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.subtleis 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.getRandomValuesandcrypto.randomUUID, notMath.random. - CryptoKey objects hide raw bytes — safe to store in IndexedDB.
- Same API across Node, Deno, Workers, Bun. SSR and edge functions included.