completefrontend
Open lab My progress
THE JAVASCRIPT FIELD GUIDE

The Web Crypto API

Learn Web Crypto for secure random values, SHA-256 hashing, HMAC, PBKDF2, AES-GCM encryption, signatures, and key formats.

By the end, you can
  • 01
    Make random values that nobody can guessUse crypto.getRandomValues, crypto.randomUUID, and rejection sampling instead of Math.random for secrets.
  • 02
    Hash, 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.
  • 03
    Encrypt, 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.
Plain definition

The Web Crypto API is the built-in crypto object. It has two parts:

  • crypto.getRandomValues and crypto.randomUUID make secure random values right away.
  • crypto.subtle hashes, derives keys, encrypts, signs, and imports and exports keys. Its methods return promises.
Real-life analogyA fingerprint belongs to one person

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
Real-life analogyA locked box needs the right key

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.

Five tools: encoding, hashing, HMAC, encryption, and signing
ToolWhat it doesWhat it does not do
EncodingWrites bytes in another form, such as hex, Base64, or UTF-8.It uses no key, so anyone can turn it back.
HashingTurns 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.
HMACMixes 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.
EncryptionTurns 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.
SigningA 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:

Secure random values

NO MATH.RANDOM

Random 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 as 3b241101-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.
Make a random ID or random bytesPop out in the code editor (opens in a new tab)JavaScript
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.

Turn random bytes into a hex tokenPop out in the code editor (opens in a new tab)JavaScript
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: bytesToHex writes each byte as two hex characters. Hex uses the digits 0 to 9 and the letters a to f.
  • Line 5: randomToken takes a length and a random source. The source is crypto unless you pass another one.
  • Line 6 makes an array of empty bytes. Line 7 fills it with random bytes.
  • Lines 11 to 16: fixedCrypto is a fake source. It always gives the bytes de, ad, be, ef, so tests always get the same output.
  • Line 18 uses the fake source and prints deadbeef.
  • Line 19 uses the real crypto and prints 32: 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.

Check that randomUUID gives a version 4 UUIDPop out in the code editor (opens in a new tab)JavaScript
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.

getRandomValues fills at most 65,536 bytesPop out in the code editor (opens in a new tab)JavaScript
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);}
Where these tools work

A secure context is a page loaded over HTTPS, or from localhost on your own computer. In browsers:

  • crypto.subtle and crypto.randomUUID work only in a secure context.
  • crypto.getRandomValues works on every page, even a plain http:// 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 THROUGH

Say 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.

A fair random number: throw away draws that are too big
Step 0 of 8Ready
Your turn: follow the blue line

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.

Running in
  1. script
Next: line 1
  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));
CallStoreChangeResultBlue line: runs next. Ran: already executed.
Recent returnsNothing yet. Start with the blue line.
A guided replay recorded from real JavaScript calls, not an engine debugger. Step follows executed statements; Back reviews a snapshot. Reset starts a fresh run.
The limit is 4,294,967,290. The first draw, 4,294,967,294, is at or above the limit, so the loop draws again. The second draw, 8, is below the limit, so 8 % 10 gives 8.limit = 4,294,967,2904,294,967,2941st draw≥ limitdraw again82nd draw< limit8 % 10 = 8
For 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.

Remainder only (sample % 10)

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.

With rejection sampling

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 ANSWER

A 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-256 always gives 32 bytes, for one letter or a huge file.

To hash text, take three steps:

  1. Turn the text into bytes with TextEncoder.
  2. Hash the bytes with crypto.subtle.digest, and await the result.
  3. Write the digest as hex, so you can read it.

You met hex and other ways to show bytes in Text encoding & Base64.

Hash text with SHA-256 and print it as hexPop out in the code editor (opens in a new tab)JavaScript
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.

Top row: the text abc goes through SHA-256 and comes out as the digest ba7816bf and so on, 32 bytes. Bottom row: the text abd goes through SHA-256 and comes out as a different digest, a52d159f and so on, also 32 bytes."abc"SHA-256ba7816bf…32 bytes"abd"SHA-256a52d159f…32 bytes
SHA-256 turns any text into a 32-byte digest. Change one letter and the whole digest changes.

Try it below. Change one letter in a message, and count how many hex characters of its digest change.

Change one letter, compare two hashes
The SHA-256 helper this playground runsPop out in the code editor (opens in a new tab)JavaScript
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("");}
Both digests58 hex characters differ
abcba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad
abda52d159f262b2c6ddb724a61840befc36eb30c88877a4030b65cbe86298449c9
Try it yourself

Both digests are 64 hex characters long. Right now 58 of those characters are different, even when the messages are almost the same.

The playground runs 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 SECRET

A 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.

Sign a message with HMAC, then check itPop out in the code editor (opens in a new tab)JavaScript
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 a CryptoKey, 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.
A signature, f7bc83f4 and so on, is checked with the secret key. Top row: message, verify, true. Bottom row: message plus an exclamation mark, verify, false.signature: f7bc83f4…checked with the secret "key"messageverifytruemessage + "!"verifyfalse
The same signature and secret are used both times. The original 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.

PBKDF2 with a demo iteration countPop out in the code editor (opens in a new tab)JavaScript
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.

Make an AES key from a password and a new saltPop out in the code editor (opens in a new tab)JavaScript
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.

Store passwords on the server

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 CHECK

AES-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.

AES-GCM: encrypt and decrypt with fixed demo bytes
Step 0 of 7Ready
Your turn: follow the blue line

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.

Running in
  1. script
Next: line 2
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();
CallStoreChangeResultBlue line: runs next. Ran: already executed.
Recent returnsNothing yet. Start with the blue line.
A guided replay recorded from real JavaScript calls, not an engine debugger. Step follows executed statements; Back reviews a snapshot. Reset starts a fresh run.

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.

Encrypt, change a byte, decrypt
Encrypt with a random IV, then decryptPop out in the code editor (opens in a new tab)JavaScript
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);}
Saved recordunchanged
IVnot generated yet
Encrypted bytes + tagnot generated yet
StatusClick Encrypt to make a random IV and the encrypted bytes.
Try it yourself

Click Encrypt to make a random IV and the encrypted bytes.

This demo keeps one AES-GCM key in memory and makes a new random 12-byte IV each time you click Encrypt. It never saves the key.
Both rows use the same key and IV. Top row: the 28 encrypted bytes, 688f3fa1 and so on, go through decrypt and give back meet at noon. Bottom row: the same bytes with the last byte flipped go through decrypt and fail with OperationError.same key, same IV688f3fa1…28 bytes: text + tagdecrypt"meet at noon"last byte flippeddecryptOperationError
Decrypt checks the tag before it gives anything back. One flipped byte, and it rejects with 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-OAEP can encrypt small secrets, such as a random AES key.
  • ECDH lets 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 SUPPORT

Encryption 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.

Check for Ed25519, then sign and verify with ECDSAPop out in the code editor (opens in a new tab)JavaScript
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: supportsEd25519 tries to make an Ed25519 key pair (line 3), sign (line 5), and verify (line 6). If any step throws, it returns false.
  • Line 15 makes an ECDSA key pair. Line 16 signs "ship release 42" with the private key.
  • Line 17 prints true when 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

KEYS

Web Crypto does not usually give you a key's raw bytes. It gives you a CryptoKey object. A CryptoKey has these properties:

  • type: secret for a shared key, or public or private for one half of a key pair.
  • extractable: true if code may export (read out) the key's bytes.
  • algorithm: which tool the key is for, such as AES-GCM.
  • usages: what the key may do, such as encrypt or verify.

These properties keep you safe. A key made only for verify cannot sign. A non-extractable key cannot be exported as bytes.

Look at a key, then export it in each formatPop out in the code editor (opens in a new tab)JavaScript
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.

Ways to hold and export a key
FormatWhat it holdsWhen to use it
CryptoKeyAn object in your running code.Web Crypto gives you this. It has type, extractable, algorithm, and usages, and its bytes may be unreadable.
rawThe plain bytes of a shared key.Use it for AES or HMAC keys. Guard the bytes like a password.
jwkA 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.
spkiA 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-----.
pkcs8A 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.

Save a non-extractable key in IndexedDBJavaScript
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:

  • raw for the bytes of a shared key;
  • jwk for a JSON Web Key;
  • spki for a public key;
  • pkcs8 for 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.

Common Web Crypto mistakes and what to do instead
The mistakeWhy it is wrongDo 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.
Hashing, encryption, signing, or encoding?
  • 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.
Try it yourself
0 of 6 correct

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.

Choose a category for every card. You can change an answer at any time; Reset clears them all.

Practice exercises

5 EXERCISES
Exercise 1 · Warm-upRead a version 4 UUID

Which digit must the third group of the UUID start with?

Starter codePop out in the code editor (opens in a new tab)JavaScript
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");

Answer, then press Check. Spacing and letter case don’t matter.

    Exercise 2 · PracticeThrow away the unfair draw

    The fake random source always gives the same two draws. Predict the printed number.

    Starter codePop out in the code editor (opens in a new tab)JavaScript
    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));

    Answer, then press Check. Spacing and letter case don’t matter.

      Exercise 3 · PracticeCheck a known SHA-256 answer

      Type the first eight hex characters of the digest.

      Starter codePop out in the code editor (opens in a new tab)JavaScript
      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);

      Answer, then press Check. Spacing and letter case don’t matter.

        Exercise 4 · PracticeSpot a changed message

        Does the check of the changed message print true or false?

        Starter codePop out in the code editor (opens in a new tab)JavaScript
        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));
        })();

        Answer, then press Check. Spacing and letter case don’t matter.

          Exercise 5 · ChallengeName the AES-GCM error

          One byte of the ciphertext or the tag changes. What error name does decrypt reject with?

          Answer, then press Check. Spacing and letter case don’t matter.

            Check your understanding

            8 QUESTIONS
            Web Crypto quiz · 8 questionsScore: first tries count
            1. Question 1 of 8Which tool should make login tokens or CSRF tokens in browser code?

              Choose an answer to see the explanation.

            2. Question 2 of 8Why does this random-number helper throw away some 32-bit draws?

              Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
              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));

              Choose an answer to see the explanation.

            3. Question 3 of 8What does SHA-256 give back, whatever the size of the input?

              Choose an answer to see the explanation.

            4. Question 4 of 8This code hashes abc. What are the first eight characters it prints?

              Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
              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);

              Choose an answer to see the explanation.

            5. Question 5 of 8What does HMAC add to a plain hash?

              Choose an answer to see the explanation.

            6. 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.

            7. 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 predictPop out in the code editor (opens in a new tab)JavaScript
              async 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.

            8. Question 8 of 8Which format exports a public key?

              Choose an answer to see the explanation.

            Key takeaways

            • Use crypto.getRandomValues and crypto.randomUUID for anything an attacker must not guess. Never use Math.random for secrets.
            • For a fair random number in a range, throw away draws at or above the limit before you use %.
            • crypto.subtle works 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 CryptoKey says 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.

            CompleteFrontend Clear concepts. Working examples.