The Web Crypto API
Learn Web Crypto for secure random values, SHA-256 hashing, HMAC, PBKDF2, AES-GCM encryption, signatures, and key formats.
- 01Generate browser-safe randomnessUse
crypto.getRandomValues,randomUUID, and rejection sampling instead ofMath.randomfor secrets. - 02Hash, authenticate, and derive keysTurn text into bytes, digest with SHA-256, verify HMACs, and use PBKDF2 only with production iteration counts.
- 03Encrypt, sign, and manage keysUse AES-GCM with unique IVs, feature-detect signing algorithms, and import/export CryptoKeys in standard formats.
Use platform cryptography, not clever code
Web Crypto is the browser and Node runtime interface to audited cryptographic primitives. It works with typed arrays, bytes from TextEncoder, and promises from Promises and async/await. Use it when a frontend needs secure random values, digests, HMACs, encryption, signatures, or key import/export. Do not design your own cryptography.
The Web Crypto API is the standards-based crypto object: crypto.getRandomValues and crypto.randomUUID create secure random values, while crypto.subtle performs async operations such as hashing, key derivation, encryption, signing, and key import/export.
A hash answers “did these bytes stay the same?” It identifies data but does not encrypt it.
- In real life: A fingerprint identifies a file
- In JavaScript: A hash digest identifies data
- In real life: Compare two fingerprints
- In JavaScript: Compare two digests
- In real life: You cannot rebuild the file
- In JavaScript: A hash is one-way
Where the analogy stops: A cryptographic hash needs a specific algorithm and careful byte handling. It does not hide data.
Encryption hides a message until someone with the right key opens it.
- In real life: Put a note in a locked box
- In JavaScript: Encrypt plaintext
- In real life: Use the right key to open it
- In JavaScript: Decrypt ciphertext
- In real life: Keep the key private
- In JavaScript: Protect the CryptoKey
Where the analogy stops: Encryption needs a secure algorithm, key, and fresh nonce. It is not a hash or a signature.
A signature shows who approved data and reveals whether it changed after signing.
- In real life: Sign a letter
- In JavaScript: Sign data with a private key
- In real life: Check the signature
- In JavaScript: Verify with a public key
- In real life: A changed letter fails the check
- In JavaScript: Changed data does not verify
Where the analogy stops: A digital signature needs the right algorithm and key pair. It does not keep the letter secret.
| Tool | What it does | What it does not do |
|---|---|---|
| Encoding | Reversible formatting such as hex, Base64, or UTF-8 bytes. | No key; anyone can decode it. |
| Hashing | One-way, fixed-length digest such as SHA-256. | No decryption; tiny input changes avalanche the output. |
| HMAC | Keyed hash for message authentication. | Both sides need the shared secret; it proves integrity, not secrecy. |
| Encryption | Turns plaintext into ciphertext with a key. | AES-GCM also authenticates the ciphertext and tag. |
| Signing | Private key creates a signature; public key verifies it. | Proves who signed, but the message is still readable unless separately encrypted. |
Practical connections: CSRF, cookies & tokens uses random tokens and PKCE hashes; supply-chain security uses Subresource Integrity hashes; IndexedDB can store non-extractable keys; and Web Storage is where keys and long-lived bearer tokens should usually not go.
Secure random values
NO MATH.RANDOMRandomness is the first place frontend code often goes wrong. Math.random() is fine for animations and sampling demos, but it is not cryptographically secure and must never create tokens, password reset links, CSRF secrets, PKCE verifiers, encryption keys, or IDs attackers can guess. Use crypto.getRandomValues for bytes and crypto.randomUUID() for RFC 4122 version 4 UUIDs.
function bytesToHex(bytes) { return [...bytes].map((byte) => byte.toString(16).padStart(2, "0")).join("");} function randomToken(byteLength = 16, cryptoImpl = crypto) { const bytes = new Uint8Array(byteLength); cryptoImpl.getRandomValues(bytes); return bytesToHex(bytes);} const fixedCrypto = { getRandomValues(bytes) { bytes.set([0xde, 0xad, 0xbe, 0xef].slice(0, bytes.length)); return bytes; },}; console.log(randomToken(4, fixedCrypto));console.log(randomToken(16).length);console.log("differs every run");The first printed token uses an injected fixed source so tests can prove the formatter. The 16-byte token length and real token value differ every run when the default crypto source fills the array. The important property is unpredictability, not a pretty format.
const uuid = crypto.randomUUID();const v4 = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;console.log(v4.test(uuid));console.log(uuid.slice(14, 15));console.log("differs every run");A UUID v4 has a 4 in the version position and 8, 9, a, or b in the variant position. The value itself differs every run, so tests validate it with a regex instead of comparing one literal string.
console.log("65536 bytes", crypto.getRandomValues(new Uint8Array(65536)).length);try { crypto.getRandomValues(new Uint8Array(65537));} catch (error) { console.log(error.name); console.log(error.message);}crypto.subtle and crypto.randomUUID require a secure context such as HTTPS or localhost. In Chrome on http://example.com, crypto.subtle and randomUUID were unavailable, but getRandomValues still worked. The CompleteFrontend Edit & run iframe is sandboxed with an opaque origin, but it is created from localhost/HTTPS, so crypto.subtle is available there.
The 65,536-byte limit is real. Node 22.23.1 throws QuotaExceededError with message The requested length exceeds 65,536 bytes. Chrome 154 throws the same name and says the ArrayBufferView byte length exceeds the entropy available via this API.
Random integers need rejection sampling
STEP THROUGHA common mistake is sample % max. If the random range is not evenly divisible by max, some results get one extra source value. For a 32-bit sample and max = 10, residues 0 through 5 would happen one extra time. Rejection sampling discards the tiny tail so every accepted result has the same count.
Step through rejection sampling. The first fixed draw is rejected because modulo would be biased; the second draw is accepted.
script
const range = 0x100000000; const limit = Math.floor(range / max) * max; const sample = new Uint32Array(1); do { source.getRandomValues(sample); } while (sample[0] >= limit); return sample[0] % max;} const fake = { getRandomValues(array) { array[0] = array[0] === 0 ? 4294967294 : 8; return array; },}; console.log(randomInt(10, fake));2^32 % 10 is 6, so six residues get 429,496,730 source values while the other four get 429,496,729.
Accept 4,294,967,290 values and reject the last six. Each result from 0 to 9 now has exactly 429,496,729 accepted samples.
Hash bytes with SubtleCrypto.digest
REAL VECTORHash functions are deterministic, one-way, and fixed length. SHA-256 returns 32 bytes no matter how large the input is. To hash text, encode it to bytes, await crypto.subtle.digest, then choose a display format such as hex. You already saw those byte-to-text display formats in Text encoding & Base64.
async function sha256Hex(message) { const bytes = new TextEncoder().encode(message); const digest = await crypto.subtle.digest("SHA-256", bytes); return [...new Uint8Array(digest)] .map((byte) => byte.toString(16).padStart(2, "0")) .join("");} sha256Hex("abc").then(console.log);The known SHA-256 test vector for "abc" is ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad. If your helper prints anything else, the bytes or hex conversion are wrong.
async function sha256Hex(message) { const bytes = new TextEncoder().encode(message); const digest = await crypto.subtle.digest("SHA-256", bytes); return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join("");}ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ada52d159f262b2c6ddb724a61840befc36eb30c88877a4030b65cbe86298449c9Both hashes are 64 hex characters. 58 positions differ right now, even when the messages are almost the same.
sha256Hex function as the lesson examples. Hashes are deterministic, not random.Hashes are useful for integrity checks such as Subresource Integrity, cache keys, and comparing content without storing the content itself. They are not encryption and cannot be reversed.
HMAC authenticates; PBKDF2 slows guesses
KEYED HASHESA plain hash says “these bytes match.” It does not say who created the digest. HMAC adds a shared secret key, so a receiver can verify the message came from someone who knows the secret and that the message was not changed. Webhooks often use this pattern.
async function hmacHex(message, secret) { const encoder = new TextEncoder(); const key = await crypto.subtle.importKey("raw", encoder.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign", "verify"]); const signature = await crypto.subtle.sign("HMAC", key, encoder.encode(message)); return [...new Uint8Array(signature)].map((byte) => byte.toString(16).padStart(2, "0")).join("");} async function verifyHmac(message, secret, hex) { const encoder = new TextEncoder(); const key = await crypto.subtle.importKey("raw", encoder.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["verify"]); const signature = Uint8Array.from(hex.match(/../g), (pair) => parseInt(pair, 16)); return crypto.subtle.verify("HMAC", key, signature, encoder.encode(message));} (async () => { const message = "The quick brown fox jumps over the lazy dog"; const signature = await hmacHex(message, "key"); console.log(signature); console.log(await verifyHmac(message, "key", signature)); console.log(await verifyHmac(message + "!", "key", signature));})();Passwords are different. Do not store SHA-256(password). SHA-256 is intentionally fast, which helps attackers guess billions of candidates. Prefer server-side password hashing libraries using Argon2id, bcrypt, or scrypt. Web Crypto has PBKDF2 and HKDF; when PBKDF2-HMAC-SHA-256 is required, current OWASP guidance is about 600,000 iterations or more for new applications. The next snippet uses only 1,000 iterations so the browser demo finishes quickly.
async function deriveDemoKey(password, salt, iterations = 1000) { const encoder = new TextEncoder(); const baseKey = await crypto.subtle.importKey("raw", encoder.encode(password), "PBKDF2", false, ["deriveBits"]); const bits = await crypto.subtle.deriveBits( { name: "PBKDF2", hash: "SHA-256", salt: encoder.encode(salt), iterations }, baseKey, 256, ); return [...new Uint8Array(bits)].map((byte) => byte.toString(16).padStart(2, "0")).join("");} deriveDemoKey("correct horse battery staple", "demo-salt", 1000).then((hex) => { console.log(hex); console.log("demo iterations only");});Browser PBKDF2 is useful for protocols and local encryption designs, but a login system should hash passwords server-side with a unique salt, a slow work factor, rate limits, monitoring, and upgrade paths. Never treat a frontend-only hash as a password storage strategy.
Encrypt with AES-GCM and never reuse an IV
AUTHENTICATEDAES-GCM is the Web Crypto workhorse for symmetric encryption. It uses one shared key to encrypt and decrypt, a 12-byte IV for each message, and an authentication tag that travels with the ciphertext. Reusing an IV with the same key can reveal data and break authentication. Store the IV beside the ciphertext; it is not secret, it just must be unique for that key.
Step through an AES-GCM round trip. The values are deterministic for teaching; production IVs must be random and never reused with the same key.
script
async function aesGcmDemo() { const iv = new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]); const key = await crypto.subtle.importKey("raw", keyBytes, "AES-GCM", false, ["encrypt", "decrypt"]); const encoded = new TextEncoder().encode("meet at noon"); const ciphertext = await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, encoded); const plaintext = await crypto.subtle.decrypt({ name: "AES-GCM", iv }, key, ciphertext); const hex = [...new Uint8Array(ciphertext)].map((byte) => byte.toString(16).padStart(2, "0")).join(""); console.log(hex); console.log(new TextDecoder().decode(plaintext));} aesGcmDemo();async function encryptMessage(text) { const key = await crypto.subtle.generateKey({ name: "AES-GCM", length: 256 }, false, ["encrypt", "decrypt"]); const iv = crypto.getRandomValues(new Uint8Array(12)); const ciphertext = await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, new TextEncoder().encode(text)); return { key, iv, ciphertext: new Uint8Array(ciphertext) };} async function decryptMessage(record) { const bytes = await crypto.subtle.decrypt({ name: "AES-GCM", iv: record.iv }, record.key, record.ciphertext); return new TextDecoder().decode(bytes);}not generated yetnot generated yetEncrypt a message to create a random IV and ciphertext.
When the last byte is tampered, Web Crypto rejects decrypt with OperationError. That rejection is the security feature: do not catch it and continue as if you had trustworthy text. For asymmetric encryption, RSA-OAEP can encrypt small secrets such as a random AES key, while ECDH derives a shared secret that you feed into a KDF. In modern systems, asymmetric crypto usually protects or agrees on symmetric keys; AES-GCM protects the bulk data.
Sign when readers must verify the author
FEATURE DETECTEncryption hides content. Signing proves a holder of the private key approved the message. The message can still be public. Web Crypto widely supports ECDSA with P-256. Chrome 154 and Node 22.23.1 also support Ed25519, but production code should feature-detect because support is newer across the platform.
async function supportsEd25519() { try { const pair = await crypto.subtle.generateKey({ name: "Ed25519" }, true, ["sign", "verify"]); const message = new TextEncoder().encode("hello"); const signature = await crypto.subtle.sign({ name: "Ed25519" }, pair.privateKey, message); const ok = await crypto.subtle.verify({ name: "Ed25519" }, pair.publicKey, signature, message); return ok && signature.byteLength === 64; } catch { return false; }} async function ecdsaDemo() { const message = new TextEncoder().encode("ship release 42"); const pair = await crypto.subtle.generateKey({ name: "ECDSA", namedCurve: "P-256" }, true, ["sign", "verify"]); const signature = await crypto.subtle.sign({ name: "ECDSA", hash: "SHA-256" }, pair.privateKey, message); console.log(await supportsEd25519()); console.log(await crypto.subtle.verify({ name: "ECDSA", hash: "SHA-256" }, pair.publicKey, signature, message)); console.log(await crypto.subtle.verify({ name: "ECDSA", hash: "SHA-256" }, pair.publicKey, signature, new TextEncoder().encode("ship release 43")));} ecdsaDemo();Signatures are how package manifests, update feeds, and authorization statements can be checked without sharing a symmetric secret. They do not hide the message. If the message is confidential, encrypt it separately and verify both confidentiality and authenticity.
CryptoKey objects and key formats
KEY MANAGEMENTWeb Crypto does not hand you raw bytes by default. It hands you CryptoKey objects with type, extractable, algorithm, and usages. Those properties are part of the security boundary: a key meant only for verify cannot sign, and a non-extractable key cannot be exported as raw bytes.
async function inspectKeys() { const aesKey = await crypto.subtle.generateKey({ name: "AES-GCM", length: 256 }, true, ["encrypt", "decrypt"]); console.log(aesKey.type + " " + aesKey.extractable + " " + aesKey.usages.join(",")); console.log((await crypto.subtle.exportKey("raw", aesKey)).byteLength); console.log((await crypto.subtle.exportKey("jwk", aesKey)).kty); const pair = await crypto.subtle.generateKey({ name: "ECDSA", namedCurve: "P-256" }, true, ["sign", "verify"]); console.log((await crypto.subtle.exportKey("spki", pair.publicKey)).byteLength > 0); console.log((await crypto.subtle.exportKey("pkcs8", pair.privateKey)).byteLength > 0);} inspectKeys();| Format | Shape | Use |
|---|---|---|
CryptoKey | Runtime object | Carries type, extractable, algorithm, and usages; may be non-extractable. |
raw | Symmetric key bytes | AES or HMAC bytes. Protect them like secrets when extractable. |
jwk | JSON Web Key | Portable JSON for web protocols; can represent symmetric, public, or private keys. |
spki | Public key | DER SubjectPublicKeyInfo, commonly PEM-wrapped for public keys. |
pkcs8 | Private key | DER PrivateKeyInfo, commonly PEM-wrapped; protect at rest and in transit. |
Non-extractable keys can still be stored by the browser. IndexedDB can persist a CryptoKey object without exposing its raw bytes, which is safer than putting key material in localStorage. The fragment below is intentionally not runnable in the lesson editor because real IndexedDB setup needs a database wrapper and application-specific lifecycle decisions.
async function makeStoredKey() { const key = await crypto.subtle.generateKey( { name: "AES-GCM", length: 256 }, false, ["encrypt", "decrypt"], ); // Store the non-extractable CryptoKey object itself, not raw key bytes. const db = await openIndexedDbSomewhere(); await db.put("keys", key, "message-key"); return key.extractable;}If you do export key material, know the format: raw for symmetric bytes, jwk for JSON Web Keys, spki for public keys, and pkcs8 for private keys. Private key export should be rare, encrypted at rest, and handled outside ordinary page scripts whenever possible.
What not to do
- Do not roll your own crypto. Compose audited primitives and protocols instead.
- Do not use ECB mode. Web Crypto does not expose AES-ECB; use authenticated modes such as AES-GCM.
- Do not reuse AES-GCM IVs with the same key. Generate a fresh 12-byte IV per message.
- Do not store keys in localStorage. XSS can read it. Prefer non-extractable keys in IndexedDB or server-side keys.
- Do not confuse encoding, hashing, encryption, and signing. Each answers a different security question.
| Mistake | Why it is wrong | Safer habit |
|---|---|---|
Math.random() for tokens | Not cryptographically secure | Use crypto.getRandomValues or crypto.randomUUID. |
sample % max | Biased unless the range divides evenly | Use rejection sampling for bounded random integers. |
| Plain SHA-256 passwords | Fast hashes are cheap to brute-force | Use Argon2id/bcrypt/scrypt server-side, or PBKDF2 with current high iterations when required. |
| AES-GCM IV reuse | Reusing an IV with the same key can reveal data and break authentication | Generate a fresh 12-byte random IV per message and store it beside the ciphertext. |
localStorage for keys | Script-readable storage is exposed to XSS | Prefer non-extractable CryptoKeys in IndexedDB or server-side keys. |
| Encoding equals encryption | Base64, UTF-8, and hex are reversible without a key | Name the operation: encode, hash, authenticate, encrypt, or sign. |
- Turn bytes into base64url for a JWK field.
- Compute a SHA-256 digest for a Subresource Integrity value.
- Verify a webhook body with a shared secret.
- Hide a note with AES-GCM and a fresh IV.
- Prove a release manifest came from the private key holder.
- Hash a PKCE code verifier into a challenge.
Sort each real-world task by the primary operation it needs. Some tasks combine operations, but choose the one doing the security work described.
Practice exercises
5 EXERCISESWhich version nibble should the UUID regex require?
const uuid = crypto.randomUUID();
const v4 = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
console.log(v4.test(uuid));
console.log(uuid.slice(14, 15));
console.log("differs every run");A valid crypto.randomUUID() has 4 in the version position, so the second console line prints 4.
Predict the printed number from the deterministic random source.
function randomInt(max, source = crypto) {
const range = 0x100000000;
const limit = Math.floor(range / max) * max;
const sample = new Uint32Array(1);
do {
source.getRandomValues(sample);
} while (sample[0] >= limit);
return sample[0] % max;
}
const fake = {
getRandomValues(array) {
array[0] = array[0] === 0 ? 4294967294 : 8;
return array;
},
};
console.log(randomInt(10, fake));function randomInt(max, source = crypto) {
const range = 0x100000000;
const limit = Math.floor(range / max) * max;
const sample = new Uint32Array(1);
do {
source.getRandomValues(sample);
} while (sample[0] >= limit);
return sample[0] % max;
}
const fake = {
getRandomValues(array) {
array[0] = array[0] === 0 ? 4294967294 : 8;
return array;
},
};
console.log(randomInt(10, fake));The helper rejects 4294967294, draws 8, and prints 8.
Type the first eight hex characters of the digest.
async function sha256Hex(message) {
const bytes = new TextEncoder().encode(message);
const digest = await crypto.subtle.digest("SHA-256", bytes);
return [...new Uint8Array(digest)]
.map((byte) => byte.toString(16).padStart(2, "0"))
.join("");
}
sha256Hex("abc").then(console.log);async function sha256Hex(message) {
const bytes = new TextEncoder().encode(message);
const digest = await crypto.subtle.digest("SHA-256", bytes);
return [...new Uint8Array(digest)]
.map((byte) => byte.toString(16).padStart(2, "0"))
.join("");
}
sha256Hex("abc").then(console.log);The full digest starts with ba7816bf, which is a standard SHA-256 test vector.
Does the tampered verification print true or false?
async function hmacHex(message, secret) {
const encoder = new TextEncoder();
const key = await crypto.subtle.importKey("raw", encoder.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign", "verify"]);
const signature = await crypto.subtle.sign("HMAC", key, encoder.encode(message));
return [...new Uint8Array(signature)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
}
async function verifyHmac(message, secret, hex) {
const encoder = new TextEncoder();
const key = await crypto.subtle.importKey("raw", encoder.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["verify"]);
const signature = Uint8Array.from(hex.match(/../g), (pair) => parseInt(pair, 16));
return crypto.subtle.verify("HMAC", key, signature, encoder.encode(message));
}
(async () => {
const message = "The quick brown fox jumps over the lazy dog";
const signature = await hmacHex(message, "key");
console.log(signature);
console.log(await verifyHmac(message, "key", signature));
console.log(await verifyHmac(message + "!", "key", signature));
})();The valid message verifies true; the tampered message verifies false.
When a ciphertext byte or authentication tag changes, what exception name should decrypt reject with?
Tampering ciphertext or the tag makes decrypt reject with OperationError.
Check your understanding
8 QUESTIONSQuestion 1 of 8Which API should create session tokens or CSRF secrets in browser code?
Choose an answer to see the explanation.
Question 2 of 8Why does a bounded random integer helper reject some 32-bit samples?
Read the code, then predictfunction randomInt(max, source = crypto) { const range = 0x100000000; const limit = Math.floor(range / max) * max; const sample = new Uint32Array(1); do { source.getRandomValues(sample); } while (sample[0] >= limit); return sample[0] % max; } const fake = { getRandomValues(array) { array[0] = array[0] === 0 ? 4294967294 : 8; return array; }, }; console.log(randomInt(10, fake));Choose an answer to see the explanation.
Question 3 of 8What does SHA-256 return for any input size?
Choose an answer to see the explanation.
Question 4 of 8What does this known SHA-256 test vector print first?
Read the code, then predictasync function sha256Hex(message) { const bytes = new TextEncoder().encode(message); const digest = await crypto.subtle.digest("SHA-256", bytes); return [...new Uint8Array(digest)] .map((byte) => byte.toString(16).padStart(2, "0")) .join(""); } sha256Hex("abc").then(console.log);Choose an answer to see the explanation.
Question 5 of 8What does HMAC add to a plain hash?
Choose an answer to see the explanation.
Question 6 of 8What must be unique for every AES-GCM encryption with the same key?
Choose an answer to see the explanation.
Question 7 of 8What does AES-GCM decryption do when the ciphertext or tag is changed?
Read the code, then predictasync function aesGcmDemo() { const keyBytes = new Uint8Array([...Array(32).keys()]); const iv = new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]); const key = await crypto.subtle.importKey("raw", keyBytes, "AES-GCM", false, ["encrypt", "decrypt"]); const encoded = new TextEncoder().encode("meet at noon"); const ciphertext = await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, encoded); const plaintext = await crypto.subtle.decrypt({ name: "AES-GCM", iv }, key, ciphertext); const hex = [...new Uint8Array(ciphertext)].map((byte) => byte.toString(16).padStart(2, "0")).join(""); console.log(hex); console.log(new TextDecoder().decode(plaintext)); } aesGcmDemo();Choose an answer to see the explanation.
Question 8 of 8Which key format normally exports a public key, not a private key?
Choose an answer to see the explanation.
Key takeaways
- Use
crypto.getRandomValuesandcrypto.randomUUIDfor security-sensitive randomness; never useMath.randomfor secrets. crypto.subtleis promise-based and works on bytes, so encode text before hashing, signing, or encrypting.- SHA-256 is deterministic, one-way, and fixed length. HMAC adds a shared secret; PBKDF2 slows derivation but frontend demos use low iterations only for speed.
- AES-GCM needs a fresh 12-byte IV for each message with the same key, and tampering must reject.
CryptoKeymetadata, extractability, usages, and import/export formats are part of key management.- Keep keys out of script-readable storage; consider non-extractable keys in IndexedDB or server-side key management.
Final definition: Web Crypto gives JavaScript audited primitives for random bytes, hashes, authentication, encryption, signatures, derivation, and keys. Your job is to choose the right primitive, feed it bytes, and respect the key and nonce rules.
Up next: Measuring performance, where the focus shifts from keeping users safe to proving which code is actually slow.