The Web Crypto API
Learn Web Crypto for secure random values, SHA-256 hashing, HMAC, PBKDF2, AES-GCM encryption, signatures, and key formats.
- 01Make random values that nobody can guessUse
crypto.getRandomValues,crypto.randomUUID, and rejection sampling instead ofMath.randomfor secrets. - 02Hash, check, and derive keysTurn text into bytes, hash it with SHA-256, check HMAC signatures, and know why passwords need slow hashing on the server.
- 03Encrypt, sign, and handle keysUse AES-GCM with a new IV for each message, check that a signing method works before you use it, and export keys in the right format.
Use the built-in crypto tools, not your own
Cryptography is the maths that keeps data secret and stops anyone from changing it unseen. Web Crypto is a set of cryptography tools built into every browser and into Node.js. Experts wrote and tested these tools. Use them. Do not invent your own.
Web Crypto works on bytes. A byte is a whole number from 0 to 255. You keep bytes in a typed array, such as a Uint8Array. The Typed arrays lesson explains them. To turn text into bytes, you use TextEncoder from Text encoding.
Most Web Crypto tools take a moment to finish. So they return a promise: a value that arrives later. You wait for it with await. See Promises and async/await if that is new.
With Web Crypto, front-end code can:
- make secure random values;
- make a hash, a short summary that changes when the data changes;
- check that a message came from the right sender and was not changed;
- encrypt data (hide it) and decrypt it (unhide it);
- sign data and check signatures;
- import and export keys.
The Web Crypto API is the built-in crypto object. It has two parts:
crypto.getRandomValuesandcrypto.randomUUIDmake secure random values right away.crypto.subtlehashes, derives keys, encrypts, signs, and imports and exports keys. Its methods return promises.
A hash answers one question: "are these bytes still the same?" It does not hide the data.
- In real life: A fingerprint belongs to one person
- In JavaScript: A hash belongs to one piece of data
- In real life: Compare two fingerprints to see if they match
- In JavaScript: Compare two hashes to see if the data matches
- In real life: You cannot rebuild a person from a fingerprint
- In JavaScript: You cannot get the data back from a hash
Encryption hides a message. Only someone with the right key can read it.
- In real life: Put a note in a box and lock it
- In JavaScript: Encrypt the readable message
- In real life: Open the box with the right key
- In JavaScript: Decrypt the scrambled message
- In real life: Keep the key safe
- In JavaScript: Keep the CryptoKey safe
These five tools are easy to mix up. Each row of the table says what one tool does, and what it does not do.
| Tool | What it does | What it does not do |
|---|---|---|
| Encoding | Writes bytes in another form, such as hex, Base64, or UTF-8. | It uses no key, so anyone can turn it back. |
| Hashing | Turns data into a short digest of a fixed size, such as SHA-256's 32 bytes. | You cannot turn a digest back into the data. One changed letter changes the whole digest. |
| HMAC | Mixes a secret key into a hash, so it proves who sent the message. | It does not hide the message. Both sides need the same secret. |
| Encryption | Turns a readable message into scrambled bytes with a key. | Without the key, nobody can read it. AES-GCM also checks that the bytes were not changed. |
| Signing | A private key makes a signature, and a public key checks it. | It proves who signed, but anyone can still read the message unless you also encrypt it. |
Where you will meet these tools on the site:
- CSRF, cookies & tokens makes random tokens and hashes a login secret.
- Supply-chain security uses hashes to check that a downloaded file was not changed.
- IndexedDB can store keys whose bytes no code can read. You will see this near the end.
- Web Storage is a bad place for keys and long-lived login tokens.
Secure random values
NO MATH.RANDOMRandom values are the first place where front-end code often goes wrong. Math.random() is fine for animations and games. It is not safe for secrets, because an attacker can work out its next values. So never use Math.random() for:
- login tokens or session IDs;
- password reset links;
- CSRF tokens;
- PKCE verifiers, the random secret an app sends when you log in with another site;
- encryption keys;
- any ID that an attacker must not guess.
Web Crypto has two safe tools instead:
crypto.randomUUID()makes a random ID in the standard UUID format: 36 characters in five groups, such as3b241101-e2bb-4255-8caf-4136c566a962.crypto.getRandomValues(array)fills a typed array with random bytes. You can turn the bytes into a token or a key.
const requestId = crypto.randomUUID();const tokenBytes = crypto.getRandomValues(new Uint8Array(16));console.log(requestId.split("-")[2][0]);console.log(tokenBytes.byteLength);Line 1 makes a UUID. Line 2 makes 16 random bytes. Line 3 prints the first character of the UUID's third group. It is always 4, because this kind of UUID is called version 4. Line 4 prints 16, the number of bytes.
The bytes are not text yet. The next example turns them into a token you can send or store.
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");In plain words:
- Lines 1 to 3:
bytesToHexwrites each byte as two hex characters. Hex uses the digits0to9and the lettersatof. - Line 5:
randomTokentakes a length and a random source. The source iscryptounless you pass another one. - Line 6 makes an array of empty bytes. Line 7 fills it with random bytes.
- Lines 11 to 16:
fixedCryptois a fake source. It always gives the bytesde,ad,be,ef, so tests always get the same output. - Line 18 uses the fake source and prints
deadbeef. - Line 19 uses the real
cryptoand prints32: 16 bytes become 32 hex characters. The token itself is different on every run.
What matters is that nobody can guess the token. The hex format is only there so you can read it.
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 version 4 UUID has two fixed spots. The third group starts with 4. The fourth group starts with 8, 9, a, or b. The regular expression on line 2 checks exactly that.
The UUID is different on every run. So line 3 checks its shape instead of comparing it with one fixed string, and prints true. Line 4 prints the 15th character, which is the 4.
One call to getRandomValues can fill at most 65,536 bytes. Line 1 below fills exactly 65,536 bytes, which works. Line 3 asks for one byte more, so it throws an error. Lines 5 and 6 print the error's name and message. If you need more bytes, fill them in smaller pieces.
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);}A secure context is a page loaded over HTTPS, or from localhost on your own computer. In browsers:
crypto.subtleandcrypto.randomUUIDwork only in a secure context.crypto.getRandomValuesworks on every page, even a plainhttp://one.- Node.js does not have this rule.
We checked this in Chrome. On http://example.com, crypto.subtle and randomUUID were missing, but getRandomValues worked.
The Edit & run editor on this site runs your code in a sandboxed iframe: a locked-down frame with no origin of its own. It still counts as secure, because the page around it uses HTTPS or localhost. So crypto.subtle works there.
The 65,536-byte limit is real too. Node 22.23.1 throws QuotaExceededError with the message The requested length exceeds 65,536 bytes. Chrome 154 throws an error with the same name. Its message says the array is bigger than the 65,536 bytes this tool can give.
A fair random number from 0 to 9
STEP THROUGHSay you want a fair random whole number from 0 to 9. A 32-bit random sample is a number from 0 to 4,294,967,295. That makes 232 = 4,294,967,296 possible samples.
A common idea is sample % 10: the remainder after dividing by 10. But 4,294,967,296 does not divide evenly by 10. So the results 0 to 5 each come from one more sample than the results 6 to 9. They turn up a tiny bit more often. This unfairness is called bias.
Rejection sampling removes the bias. The code sets a limit: the biggest multiple of 10 that fits, 4,294,967,290. Any sample at or above the limit is thrown away, and the code draws again. Only 6 of the 4,294,967,296 samples are ever thrown away.
Step through the code below. It uses a fake random source, so the draws are always the same: first 4,294,967,294, then 8.
Step through a fair random number from 0 to 9. The first fixed draw is too big and is thrown away; the second draw is used.
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));max = 10, the limit is 4,294,967,290. The first draw is too big, so the loop throws it away. The second draw, 8, is used.The two cards below count how many samples lead to each result.
2^32 % 10 is 6. So six results, 0 to 5, each get 429,496,730 samples. The other four, 6 to 9, each get 429,496,729.
The code keeps 4,294,967,290 samples and throws away the last six. Now every result from 0 to 9 gets exactly 429,496,729 samples.
Make a hash with SHA-256
KNOWN ANSWERA hash function turns any data into a short result of a fixed size. The result is called a digest. A hash function is:
- the same every time: the same bytes always give the same digest;
- one-way: you cannot get the data back from the digest;
- fixed in size:
SHA-256always gives 32 bytes, for one letter or a huge file.
To hash text, take three steps:
- Turn the text into bytes with
TextEncoder. - Hash the bytes with
crypto.subtle.digest, andawaitthe result. - Write the digest as hex, so you can read it.
You met hex and other ways to show bytes 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);Line 2 turns the text into bytes. Line 3 hashes the bytes. Lines 4 to 6 write each byte as two hex characters. Line 9 hashes "abc" and prints the digest.
The SHA-256 digest of "abc" is a well-known test answer: ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad. If your code prints anything else, the bytes or the hex step are wrong.
Try it below. Change one letter in a message, and count how many hex characters of its digest change.
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 digests are 64 hex characters long. Right now 58 of those characters are different, even when the messages are almost the same.
sha256Hex helper as the lesson. A hash is not random: the same message always gives the same digest.Hashes are useful for:
- Subresource Integrity: the browser checks that a downloaded script was not changed;
- cache keys: a short name for a piece of content;
- comparing content without storing the content itself.
A hash is not encryption. There is no way to turn a digest back into the data.
HMAC checks the sender; PBKDF2 slows down guessing
WITH A SECRETA plain hash tells you that the bytes match. It does not tell you who made the hash, because anyone can hash anything. HMAC mixes a secret key into the hash. Only someone who knows the secret can make the right result. Web Crypto calls that result a signature.
When an HMAC signature checks out, you know two things:
- the message came from someone who knows the secret;
- nobody changed the message on the way.
Webhooks often use HMAC. A webhook is a message that one server sends to another, such as a payment service telling your server that a payment went through.
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));})();In plain words:
- Line 3 turns the secret text
"key"into aCryptoKey, the object Web Crypto uses for keys. - Line 4 signs the message. Line 5 writes the signature as hex.
- Line 11 turns the hex back into bytes. Line 12 checks the signature against the message.
- Line 18 prints the signature.
- Line 19 prints
true, because the message is the original. - Line 20 prints
false, because one!was added to the message.
message passes. Add one ! and the check fails.Let crypto.subtle.verify do the check. Do not compare signature strings yourself. HMAC proves who sent a message, but it does not hide it. Anyone can still read the message.
Passwords need a different tool. Do not store SHA-256(password). SHA-256 is built to be fast. That helps attackers: they can try billions of guesses per second.
Instead, hash passwords on the server with a slow password hash, such as Argon2id, bcrypt, or scrypt. Web Crypto has two key derivation functions (KDFs): tools that turn a password or a secret into a key. They are PBKDF2 and HKDF.
PBKDF2 is slow on purpose: it repeats its work many times. Each repeat is called an iteration. If you must use PBKDF2 with SHA-256, OWASP (a web security project) says to use 600,000 iterations or more in new apps. The examples below use only 1,000 iterations, so they finish quickly in your browser.
A salt is a random value mixed in with the password. It makes each result different, even for two people with the same password. A salt is not secret. Make a new salt for each password or each encrypted record. Store it next to the result, together with the settings you used.
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");});Line 3 turns the password into a CryptoKey. Lines 4 to 8 derive 256 bits (32 bytes) from it. Line 5 passes in the salt and the iteration count. Line 12 runs it with 1,000 iterations. Line 13 prints the result as hex. Line 14 prints a reminder that 1,000 is only for the demo.
The next example turns a password into an AES key for encryption. The key is non-extractable: code can use it, but can never read its bytes.
async function deriveAesKey(password) { const salt = crypto.getRandomValues(new Uint8Array(16)); const passwordBytes = new TextEncoder().encode(password); const passwordKey = await crypto.subtle.importKey("raw", passwordBytes, "PBKDF2", false, ["deriveKey"]); const key = await crypto.subtle.deriveKey( { name: "PBKDF2", hash: "SHA-256", salt, iterations: 1000 }, passwordKey, { name: "AES-GCM", length: 256 }, false, ["encrypt", "decrypt"], ); return { salt, key };} deriveAesKey("tea").then(({ salt, key }) => { console.log(salt.byteLength); console.log(key.algorithm.name); console.log("store salt with encrypted data");});Line 2 makes a new 16-byte salt. Line 4 turns the password into a CryptoKey. Lines 5 to 7 derive the AES key from it, using the salt. Line 9, false, makes the key non-extractable.
The code prints 16 (the salt size), then AES-GCM (the key's algorithm), then a reminder: store the salt openly next to the encrypted data. Do not use this for login passwords. Hash those on the server with a password-hashing library.
PBKDF2 in the browser is useful for some protocols and for encrypting data on the user's device. But a login system must hash passwords on the server. The server needs:
- a new salt for each password;
- a slow setting that you can raise later;
- a limit on how often someone can try to log in;
- alerts when someone tries many passwords.
A hash made only in the browser is never a safe way to store passwords.
Encrypt with AES-GCM, with a new IV every time
TAMPER CHECKAES-GCM is the main Web Crypto tool for encryption. It uses one shared key to both encrypt and decrypt. A few words you will see:
- plaintext: the readable message.
- ciphertext: the scrambled bytes you get after encrypting.
- IV (initialization vector): 12 bytes you pick for each message. It is not secret, but it must be new for every message you encrypt with the same key.
- tag: 16 bytes that AES-GCM adds to the end of the ciphertext. Decrypt uses the tag to check that nothing was changed.
Never use the same IV twice with the same key. That can leak the messages, and it lets attackers change them without being caught. Store the IV next to the ciphertext.
The step-through below uses a fixed key and IV, so it gives the same output every time. Never reuse those demo bytes in real code.
Step through an AES-GCM encrypt and decrypt. The fixed bytes keep the output the same; real IVs must be random and never used twice 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();The playground below keeps one key and makes a new random IV each time you click Encrypt. Random IVs make a repeat very unlikely, but not impossible. So limit how many messages one key encrypts, and then switch to a new key.
async function encryptMessage(text, key) { const activeKey = 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 }, activeKey, new TextEncoder().encode(text)); return { key: activeKey, 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 yetClick Encrypt to make a random IV and the encrypted bytes.
OperationError instead.When the last byte is changed, decrypt rejects with OperationError. That error is the safety check doing its job. Do not catch it and carry on as if the text were fine.
AES-GCM uses one shared key. Two other tools use a key pair instead: a public key you can share, and a private key you keep.
RSA-OAEPcan encrypt small secrets, such as a random AES key.ECDHlets two sides agree on a shared secret. You then pass that secret to a KDF to make a key.
Real systems usually use these two only to share or agree on an AES key. AES-GCM then encrypts the actual data.
Sign when readers must check who wrote it
CHECK SUPPORTEncryption hides a message. Signing does something else: it proves who approved the message. A signature uses a key pair:
- the private key belongs only to the author, and makes the signature;
- the public key can go to anyone, and checks the signature.
The message itself can stay public.
ECDSA with the P-256 curve works wherever Web Crypto works. Ed25519 is a newer way to sign. Chrome 154 and Node 22.23.1 support it, but older browsers may not. So your code should first check that it works. This check is called feature detection.
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();In plain words:
- Lines 1 to 11:
supportsEd25519tries to make an Ed25519 key pair (line 3), sign (line 5), and verify (line 6). If any step throws, it returnsfalse. - Line 15 makes an ECDSA key pair. Line 16 signs
"ship release 42"with the private key. - Line 17 prints
truewhen Ed25519 works. - Line 18 prints
true: the public key confirms the signature. - Line 19 prints
false: the message changed to"ship release 43".
With signatures, anyone can check package files, update lists, or approvals without knowing a secret. A signature does not hide the message. If the message is private, encrypt it as well.
CryptoKey objects and key formats
KEYSWeb Crypto does not usually give you a key's raw bytes. It gives you a CryptoKey object. A CryptoKey has these properties:
type:secretfor a shared key, orpublicorprivatefor one half of a key pair.extractable:trueif code may export (read out) the key's bytes.algorithm: which tool the key is for, such asAES-GCM.usages: what the key may do, such asencryptorverify.
These properties keep you safe. A key made only for verify cannot sign. A non-extractable key cannot be exported as 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();In plain words:
- Line 2 makes an AES key that can be exported.
- Line 3 prints
secret true encrypt,decrypt. - Line 4 exports the key as raw bytes and prints
32. - Line 5 exports it as a JSON Web Key and prints its kind,
oct, which means a shared key. - Line 7 makes an ECDSA key pair.
- Lines 8 and 9 export the public key and the private key. Both print
true, because both exports hold bytes.
Each row of the table is one way to hold or export a key: its name, what it holds, and when to use it.
| Format | What it holds | When to use it |
|---|---|---|
CryptoKey | An object in your running code. | Web Crypto gives you this. It has type, extractable, algorithm, and usages, and its bytes may be unreadable. |
raw | The plain bytes of a shared key. | Use it for AES or HMAC keys. Guard the bytes like a password. |
jwk | A JSON Web Key: the key written as JSON. | Use it to send keys in web protocols. It can hold a shared, public, or private key. |
spki | A public key in a standard binary form. | Use it to share a public key. It is often wrapped in text that starts with -----BEGIN PUBLIC KEY-----. |
pkcs8 | A private key in a standard binary form. | Use it only when you must move a private key. Keep it encrypted when stored or sent. |
A key that cannot be exported can still be saved. IndexedDB, the browser's database, can store the CryptoKey object itself. Your page code never sees the raw bytes. That is safer than putting key bytes in localStorage.
The code below is only a sketch, so it has no Run button. A real app needs its own IndexedDB setup.
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;}Line 4, false, makes the key non-extractable. Lines 8 and 9 open a database and save the key object. openIndexedDbSomewhere stands in for your own database code.
If you do export a key, pick the format by the kind of key:
rawfor the bytes of a shared key;jwkfor a JSON Web Key;spkifor a public key;pkcs8for a private key.
Exporting a private key should be rare. Encrypt it when you store it, and keep it out of normal page code when you can.
What not to do
- Do not write your own crypto. Use the tested tools and standard ways of combining them.
- Do not use ECB mode. ECB is an old AES mode that leaves patterns in the data visible. Web Crypto does not offer it; use AES-GCM.
- Do not reuse an AES-GCM IV with the same key. Make a new 12-byte IV for each message.
- Do not store keys in localStorage. In an XSS attack, an attacker's script runs in your page and can read it. Use non-extractable keys in IndexedDB, or keep keys on the server.
- Do not mix up encoding, hashing, encryption, and signing. Each one answers a different question.
Each row of the table is one common mistake, why it is wrong, and what to do instead.
| The mistake | Why it is wrong | Do this instead |
|---|---|---|
Using Math.random() for tokens. | An attacker can work out its next values. | Use crypto.getRandomValues or crypto.randomUUID. |
Using sample % max for a number in a range. | Some results come up a little more often, unless the range divides evenly. | Use rejection sampling: throw away draws at or above the limit. |
| Storing passwords as plain SHA-256. | SHA-256 is fast, so attackers can try billions of guesses. | Hash passwords on the server with Argon2id, bcrypt, or scrypt. If you must use PBKDF2, use 600,000 iterations or more. |
| Using the same AES-GCM IV twice with one key. | It can leak the messages and let attackers change them unseen. | Make a new random 12-byte IV for each message, and store it next to the ciphertext. |
Keeping keys in localStorage. | Any script in the page can read it, including an attacker's script after an XSS attack. | Use non-extractable CryptoKey objects in IndexedDB, or keep keys on the server. |
| Treating encoding as encryption. | Base64, UTF-8, and hex can be turned back without any key. | Name the job: encode, hash, check with HMAC, encrypt, or sign. |
- Write some key bytes as base64url text for a JSON Web Key.
- Make a SHA-256 digest of a script for a Subresource Integrity check.
- Check a webhook message with a shared secret.
- Hide a note with AES-GCM and a new IV.
- Prove that a release file came from the owner of a private key.
- Hash a PKCE verifier to make its challenge.
Sort each task by the tool that does the main security work. Some tasks use two tools; pick the one the task is mostly about.
Practice exercises
5 EXERCISESWhich digit must the third group of the UUID start with?
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");Every crypto.randomUUID() value has 4 at the start of its third group. So the second line of output is 4.
The fake random source always gives the same two draws. Predict the printed number.
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 throws away 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. It is a well-known SHA-256 test answer.
Does the check of the changed message 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 original message prints true. The message with ! added prints false.
One byte of the ciphertext or the tag changes. What error name does decrypt reject with?
When a byte of the ciphertext or the tag changes, decrypt rejects with OperationError.
Check your understanding
8 QUESTIONSQuestion 1 of 8Which tool should make login tokens or CSRF tokens in browser code?
Choose an answer to see the explanation.
Question 2 of 8Why does this random-number helper throw away some 32-bit draws?
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 give back, whatever the size of the input?
Choose an answer to see the explanation.
Question 4 of 8This code hashes
abc. What are the first eight characters it prints?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 8With the same AES-GCM key, what must be new for every message you encrypt?
Choose an answer to see the explanation.
Question 7 of 8This code encrypts and decrypts with AES-GCM. What happens if one byte of the ciphertext or tag is changed before decrypting?
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 format exports a public key?
Choose an answer to see the explanation.
Key takeaways
- Use
crypto.getRandomValuesandcrypto.randomUUIDfor anything an attacker must not guess. Never useMath.randomfor secrets. - For a fair random number in a range, throw away draws at or above the limit before you use
%. crypto.subtleworks on bytes and returns promises. Turn text into bytes before you hash, sign, or encrypt it.- SHA-256 always gives the same 32 bytes for the same data, and you cannot undo it. HMAC adds a secret key, so it also proves who sent the message.
- Never store plain SHA-256 password hashes. Hash passwords slowly on the server. The 1,000 PBKDF2 iterations here are only for quick demos.
- AES-GCM needs a new 12-byte IV for each message with the same key. If anything was changed, decrypt must fail.
- A
CryptoKeysays what it is for and whether its bytes can be read out. Pick the export format by the kind of key. - Keep keys out of storage that page scripts can read. Use non-extractable keys in IndexedDB, or keep keys on the server.
Final definition: Web Crypto gives JavaScript tested tools for random bytes, hashes, HMAC, encryption, signatures, key derivation, and keys. Your job is to pick the right tool, give it bytes, and follow the rules for keys and IVs.
Up next: Measuring performance. There you move from keeping users safe to finding out which code is slow.