Skip to content
Chapter 16Lesson 1

Web Crypto: random IDs and HMAC signatures

The platform's built-in Web Crypto API, the global you reach for to mint random IDs and tokens and to sign and verify webhook payloads.

A POST lands on your /api/webhooks/stripe route. The body says a customer just paid you $49, and the only thing standing between that claim and a row in your database is one header: x-signature. The endpoint is public by definition, so anybody who guesses the URL can send that request. Before you write a single cent to your books, you have to answer one question: was this body actually produced by Stripe, using the secret only you and Stripe share, or did someone forge it?

That question runs through the whole lesson, and answering it correctly is harder than it looks. The platform gives you a clean primitive to prove the signature, but many developers then verify it with a one-line comparison that an attacker can exploit to forge a valid signature. So there are two things to get right: how the platform lets you check the signature, and why the check itself is the part people get wrong. Keep both in mind as we work toward the answer.

The proof is built on one object: the crypto global. That same object does two simpler, far more common jobs long before you ever sign anything: it gives you a unique ID with no trip to the server, and a random token of whatever length you need. We’ll take those first, because they’re easy, and because they use the same building blocks the harder signing work reuses.

By the end you’ll have done four concrete things with this one global: minted a UUID for a primary key, filled a byte buffer and rendered it as a URL-safe string, hashed a payload to a stable hex fingerprint, and signed and verified a webhook payload the way the production code later in the course does it. That’s one global and three surfaces, climbing from a one-liner up to a signature, and we’ll start with the easiest of them.

Start with one fact, so you never have to wonder which package to import crypto from: it’s a global. It’s available in every runtime this course touches, namely the browser, Node 24+, the Edge runtime, the body of a Server Component, and the inside of a Route Handler. You never import it. Every code block in this lesson omits an import for crypto on purpose, because the name is simply there, the way fetch and console are.

That one object exposes three surfaces, and the entire lesson is a climb up these three rungs:

crypto the global
crypto.randomUUID() sync
a unique ID
crypto.getRandomValues() sync
random bytes
crypto.subtle async
algorithms · sign, verify, digest — taught at depth

One global, three surfaces. The first two are synchronous conveniences. crypto.subtle is the asynchronous algorithm surface where signing lives, and the only one this lesson takes to depth.

  • crypto.randomUUID() returns a v4 UUID string. Synchronous, zero arguments.
  • crypto.getRandomValues(typedArray) fills a typed array with random bytes. Synchronous.
  • crypto.subtle is the asynchronous algorithm surface: sign, verify, digest, encrypt, derive, and import and export keys. Every method on it returns a Promise.

Before we touch any of them, here are two habits worth fixing early, because the wrong reflex on either one ships a security bug.

First, ignore the legacy synchronous methods that hang directly off crypto, the old helpers from a decade ago that predate crypto.subtle. They’re not what 2026 code reaches for, and you’ll never need them. The three surfaces above are the whole map.

Second: never reach for Math.random() for anything that isn’t visual jitter. Shuffling a deck for a non-security animation or nudging a particle a few pixels is fine. But the moment a random value is a token, an ID someone could guess, a nonce, or anything an attacker would benefit from predicting, Math.random() is a bug. It is not a CSPRNG (a cryptographically secure pseudo-random number generator). Its output is statistically random but predictable: an attacker who watches enough values can reconstruct its internal state and forecast the next one. Every random byte in this lesson comes from crypto, which is the platform default for exactly this reason.

This is the easiest, highest-frequency thing the crypto global does, so we lead with it. When you need a unique string identifier and you don’t want a round-trip to the server to guarantee uniqueness, you reach for crypto.randomUUID().

It returns a version-4 UUID, a 36-character string like '1f0b8c2e-3d4a-4f6b-9c1e-7a2d5e8b0c11', carrying 122 bits of entropy. That’s so much randomness that for any volume you’ll ever generate, collisions are not something you plan for. You don’t coordinate with a database, and you don’t ask a server “is this one taken?” You just mint it on the spot and trust it’s unique.

const id = crypto.randomUUID();
// '1f0b8c2e-3d4a-4f6b-9c1e-7a2d5e8b0c11'

Where does this show up? Everywhere you need a unique string and don’t want to wait for one:

  • a primary key for a row you’re about to insert,
  • an idempotency key you attach to a POST so a retry doesn’t double-charge (you’ll meet this pattern properly in the chapter on idempotent requests),
  • a request or correlation ID you thread through your logs so one user action is traceable end to end (the kind of thing the observability work later in the course leans on),
  • a React list key for a row the client invented optimistically, before the server has assigned it anything,
  • an object key for a file you’re about to upload.

This isn’t a toy version of the real thing, it is the default. Drizzle UUID primary keys and Better Auth session IDs both consume v4 UUIDs downstream, so the ID you mint here is the same shape the data layer and the auth layer expect later.

There is exactly one constraint, and it’s the first appearance of a thread that runs through this whole chapter. In the browser, crypto.randomUUID, like nearly all of Web Crypto, only exists in a secure context : a page served over HTTPS, or over localhost. On a plain http:// page, such as a raw LAN IP or a dev host that isn’t localhost, the property is undefined and the call throws. This is the same constraint the mkcert setup earlier in the course unblocks for local development, so treat HTTPS-or-localhost as the prerequisite and don’t re-litigate TLS here. On the server, meaning Node or the Edge runtime, there is no secure-context restriction at all, and the call always works. That split decides where the call can fail: the same randomUUID that’s fine in your Server Component will throw in a component that runs in the browser over plain HTTP.

getRandomValues: when you need bytes, not a UUID

Section titled “getRandomValues: when you need bytes, not a UUID”

A UUID has a fixed shape. Sometimes that shape doesn’t fit: you want a share token of a specific length, an opaque slug, a nonce, or a one-time invite code, something where you decide how many bytes and what the output looks like. That’s the trigger to step past randomUUID, because here you control the length and the alphabet of the result.

crypto.getRandomValues(typedArray) fills a typed array in place with CSPRNG bytes and hands the same array back. You allocate the buffer at the size you want, and the function writes the randomness into it. A 256-bit token is new Uint8Array(32), thirty-two bytes, filled in one call. (Uint8Array is the fixed-length byte array from the streaming chapter; here it’s just a 32-slot buffer of numbers from 0 to 255.)

Raw bytes, though, aren’t something you can drop into a URL or a header. A byte buffer is numbers, and a token is a string. So the real work of this section isn’t the randomness, which is one call. It’s the encoding step that turns those bytes into a string safe to put anywhere. That encoding is base64url , and it’s worth learning as a named, reusable pipeline, because the digest and the signature later in this lesson render their output the same way.

The portable path has two moves. First, convert bytes to base64 with the platform’s btoa. Then make that base64 URL-safe: base64’s alphabet includes + and /, which mean other things inside a URL, and it pads the end with =. So you swap +-, /_, and strip the trailing =. That last swap is what the “url” in base64url means.

const generateToken = () => {
const bytes = new Uint8Array(32);
crypto.getRandomValues(bytes);
return btoa(String.fromCharCode(...bytes))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '');
};

Allocate the 32-byte buffer. The length is entirely your call: 32 bytes is a 256-bit token, plenty for a share link or an invite code. The array starts full of zeros.

const generateToken = () => {
const bytes = new Uint8Array(32);
crypto.getRandomValues(bytes);
return btoa(String.fromCharCode(...bytes))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '');
};

getRandomValues fills the buffer in place with CSPRNG bytes and returns the same array. After this line the thirty-two slots hold unpredictable values. This is the only randomness step.

const generateToken = () => {
const bytes = new Uint8Array(32);
crypto.getRandomValues(bytes);
return btoa(String.fromCharCode(...bytes))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '');
};

Render to a URL-safe string. btoa(String.fromCharCode(...bytes)) turns the bytes into base64, and the three replace calls then make it URL-safe: +-, /_, and dropping the = padding. That trio is the whole of base64url.

1 / 1

getRandomValues carries one quirk and one hard limit worth knowing. The quirk is that it is the single Web Crypto member that also works in an insecure (http://) context; randomUUID and crypto.subtle don’t. Don’t build on that, since the rule is still HTTPS-or-localhost everywhere via mkcert. The reason to know it is only that a blanket “Web Crypto needs a secure context” would be slightly wrong, and this is the one exception. The hard limit is that getRandomValues throws QuotaExceededError once the array’s byte length passes 65,536, so this is a short-token tool. For bulk randomness, like generating thousands of keys or a large random file, reach for a server-side helper instead.

The first two surfaces were synchronous conveniences. crypto.subtle works differently: it’s the asynchronous algorithm surface, and every method on it returns a Promise. That single fact causes the most common silent bug in this whole area. Forget the await and you don’t get a hash or a signature, you get a Promise<ArrayBuffer>. The next line treats that Promise as if it were the bytes, and everything computed from it is wrong. Every subtle call in this lesson is awaited, so read a missing await as a defect, not a style choice.

subtle is a big surface, but a SaaS engineer in 2026 only reaches for five families of methods, and this course takes only two of them to depth. Here’s the whole map, each tagged with how far we go:

  • digest: a one-shot hash (SHA-256 over a buffer). Taught at depth in the next section.
  • sign / verify: signatures, both HMAC and asymmetric. HMAC taught at depth through the rest of this lesson.
  • encrypt / decrypt: AES-GCM is the default algorithm. Recognition only. Key handling for encryption is server-side work, and no SaaS UI ships client-side AES in 2026.
  • importKey / exportKey: move key material in and out of the platform’s opaque key type. You’ll use importKey for HMAC; exportKey is recognition only.
  • deriveKey / deriveBits: HKDF, PBKDF2, and friends. Recognition only. Password hashing, the thing people reach here for, belongs on the server with argon2id, never in the browser.

One idea ties the whole surface together, so meet it now: subtle never operates on raw secret bytes directly. You hand it your secret’s bytes a single time through importKey, and it gives you back a CryptoKey , an opaque handle that the algorithm methods consume. You pass that handle to sign and verify, and you don’t read its bytes back out (unless you explicitly marked it extractable, which for our purposes you never will). Think of CryptoKey as a sealed envelope the platform holds for you: you can use it, but you can’t peek inside.

Before we go deep on the two families that matter, commit the scope boundary to memory by sorting it yourself.

Sort each `crypto.subtle` method by how far this lesson takes it. Drag each item into the bucket it belongs to, then press Check.

Taught here Used at depth in this lesson
Recognition / server-side Named for awareness, not written here
digest
sign
verify
importKey
encrypt
decrypt
deriveKey
exportKey

digest: hashing a payload to a fixed string

Section titled “digest: hashing a payload to a fixed string”

digest is the gentlest way onto the subtle surface, and it’s the perfect warm-up for HMAC, because it exercises everything HMAC needs (the async call, the string-to-bytes encoder, the bytes-to-hex render) with no key and no verify decision to think about yet. Get this one fluent and HMAC is a small step.

crypto.subtle.digest(algorithm, buffer) takes an algorithm string and a buffer of bytes, and returns a Promise that resolves to an ArrayBuffer of the raw digest. You pass 'SHA-256' (the 2026 default) and a Uint8Array of your input, produced by new TextEncoder().encode(input), the one string-to-bytes bridge this course uses. For SHA-256 the result is 32 bytes.

The defining property of a hash is that the same input produces the same digest, every time, on any machine. That makes it the tool for content-addressable keys, request-body fingerprints, and deduplication: any time you want a stable identifier for a blob of data computed locally, with no server in the loop. (This exact encode → digest → hex pipeline comes back in the object-storage work later in the course, for content addressing.)

The catch is the same one as the token: a digest is raw bytes, and you usually want a string, conventionally a 64-character lowercase hex string. Hex rendering has one detail you cannot skip, and it’s the trap most worth your attention here. You take each byte and call .toString(16) to get its hex. But a byte like 0x0a renders as 'a', a single character, and 0x00 renders as '0'. Without padding, those short renders silently concatenate into a string that’s shorter than it should be and no longer maps back to the original bytes. So every byte gets .padStart(2, '0') to force two characters. Skip it and your fingerprint is wrong, but only for inputs that happen to contain a low byte. It passes every test where the bytes are all ≥ 16, then corrupts the moment real data flows through: invisible in development, live in production.

const sha256Hex = async (input: string) => {
const bytes = new TextEncoder().encode(input);
const digest = await crypto.subtle.digest('SHA-256', bytes);
return [...new Uint8Array(digest)]
.map((b) => b.toString(16).padStart(2, '0'))
.join('');
};

Turn the input string into UTF-8 bytes. TextEncoder().encode is the only string-to-bytes bridge the course uses; digest needs bytes, not a string.

const sha256Hex = async (input: string) => {
const bytes = new TextEncoder().encode(input);
const digest = await crypto.subtle.digest('SHA-256', bytes);
return [...new Uint8Array(digest)]
.map((b) => b.toString(16).padStart(2, '0'))
.join('');
};

The async one-shot hash. 'SHA-256' is case-sensitive, so 'sha-256' throws. The await is mandatory: drop it and digest holds a Promise, not bytes. It resolves to an ArrayBuffer of 32 raw bytes.

const sha256Hex = async (input: string) => {
const bytes = new TextEncoder().encode(input);
const digest = await crypto.subtle.digest('SHA-256', bytes);
return [...new Uint8Array(digest)]
.map((b) => b.toString(16).padStart(2, '0'))
.join('');
};

Render to hex. ArrayBuffer is the raw container, and wrapping it in new Uint8Array(...) gives you a view you can iterate. Each byte becomes two hex chars, and padStart(2, '0') is the guard that stops a low byte like 0x0a from collapsing to one character.

1 / 1

One detail is worth holding on to, because it returns the moment you sign: the ArrayBuffer versus Uint8Array distinction from the streaming chapter is doing real work here. digest hands back an ArrayBuffer, a raw byte container you can’t index or iterate directly, and you wrap it in a Uint8Array to get a view you can spread and map over. Every subtle method that returns bytes returns them this way, so this wrap becomes muscle memory from here on.

Now prove to yourself that the padStart really matters. The following program runs a hex renderer that forgot the pad over a tiny byte array. Predict exactly what it prints before you check.

Predict what this program prints, then press Check.

const bytesToHex = (bytes) =>
[...bytes].map((b) => b.toString(16)).join('');
console.log(bytesToHex([10, 255, 0]));

HMAC: signing a payload with a shared secret

Section titled “HMAC: signing a payload with a shared secret”

This is where the secret enters. HMAC (Hash-based Message Authentication Code) is, in one line, a keyed hash: you feed it the payload bytes and a secret key, and you get back a signature that only a holder of that same secret could have produced. A plain digest says “here’s a fingerprint of this data.” An HMAC says “here’s a fingerprint of this data, made by someone who knows the secret,” and that second clause is the entire point of a webhook signature.

The property that decides when HMAC is the right tool is its symmetry: the same secret both signs and verifies. That’s exactly the shape of a webhook. Stripe holds the secret, you hold the same secret, and you both agreed on it out of band when you set up the endpoint. Stripe signs with it, you verify with it: one secret, two ends. That symmetry is also HMAC’s boundary. It is the wrong primitive when the signer and the verifier are different actors who don’t share a secret. For that case you need asymmetric crypto, where a public key anyone can verify with pairs with a private key only one party can sign with. That’s out of scope here, though it’s what Better Auth reaches for under the hood for JWTs. The rule, then, is that a shared secret between two trusting ends means HMAC, and different actors with no shared secret means something else.

Inside HMAC, SHA-256 is again the 2026 default hash. The flow to produce a signature is three steps, and two of them you already know:

  1. Bytes. TextEncoder().encode(...) turns the secret and the payload into Uint8Array, the same encoder from the digest section.
  2. Import the key. Hand the secret’s bytes to importKey once and get back a CryptoKey.
  3. Sign. Pass that key and the payload bytes to sign, get an ArrayBuffer signature, and render it to hex with the pipeline you already wrote.

So the genuinely new surface is just step 2, and within it, one argument that trips people more than any other.

const signPayload = async (secret: string, payload: string) => {
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(payload),
);
return [...new Uint8Array(signature)]
.map((b) => b.toString(16).padStart(2, '0'))
.join('');
};

One TextEncoder, reused for both the secret and the payload. No need for two; it’s stateless.

const signPayload = async (secret: string, payload: string) => {
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(payload),
);
return [...new Uint8Array(signature)]
.map((b) => b.toString(16).padStart(2, '0'))
.join('');
};

importKey is where the new surface lives. 'raw' says the secret is plain bytes rather than a structured key format; the object names the algorithm and inner hash; false means not extractable, since you never need the bytes back out. The last argument is the usages array, and it’s the trap, covered just below.

const signPayload = async (secret: string, payload: string) => {
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(payload),
);
return [...new Uint8Array(signature)]
.map((b) => b.toString(16).padStart(2, '0'))
.join('');
};

sign produces the signature ArrayBuffer over the payload bytes, using the imported key. await is mandatory here too.

const signPayload = async (secret: string, payload: string) => {
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(payload),
);
return [...new Uint8Array(signature)]
.map((b) => b.toString(16).padStart(2, '0'))
.join('');
};

Render the signature to hex with the now-familiar pipeline, ready to drop into an x-signature header. Same three lines as the digest, so by now this is muscle memory.

1 / 1

The trap is that fifth argument to importKey: the usages array. It declares, up front, what the key is allowed to do. Import a key with ['sign'] and then call verify with it, and the platform throws InvalidAccessError, because the key simply isn’t permitted to verify. Since in practice you almost always need both ends (you sign to produce a test signature, and you verify to check an incoming one), you pass ['sign', 'verify']. Use that array in every HMAC key you import and the error never finds you.

Two smaller points round this out. HMAC keys may be any length: a key shorter than the 32-byte hash output is just hashed first internally, which is fine, only slightly wasteful. And the algorithm strings 'HMAC' and 'SHA-256' are both case-sensitive, exactly as before.

This is the takeaway the whole lesson has been climbing toward. The handler has the incoming x-signature header, the raw request body, and the shared secret. It owes you one boolean: was this body signed with this secret? There are two ways to get that boolean, and the difference between them is the difference between a secure endpoint and one an attacker reads character by character.

The platform path, the default. crypto.subtle.verify('HMAC', key, signatureBytes, payloadBytes) returns a Promise of a boolean. You decode the incoming header from hex (or base64url) back into a Uint8Array for signatureBytes, pass the raw body bytes as payloadBytes, and the platform tells you true or false. This is the default because subtle.verify runs in time independent of where the inputs diverge: it compares the full thing regardless. That property has a name and a reason, and you need to understand the failure it prevents, because the alternative looks completely innocent.

The DIY path, and the footgun. Suppose instead you re-sign the payload yourself and compare your hex string against the incoming one with ===. That single line, a === b, is a timing-attack vulnerability. String equality short-circuits: it returns the instant it hits the first character that differs. So a forged signature that matches the first character of the real one takes a hair longer to reject than one that fails at character zero. Match the first two, and it takes longer still. The rejection time leaks how many leading characters were right, and over enough timed requests, an attacker walks the signature one character at a time until they’ve reconstructed the whole thing and can forge a valid request. This is not theoretical; it’s how real signature bypasses happen. Scrub through the next diagram to see the leak, and watch the top row’s timing creep up while the bottom row stays flat.

a === b naive compare
x···········
1 of 12 checked · fast
subtle.verify constant time
xf2c7a1e4b08
12 of 12 checked · constant
matched
first mismatch
never examined

The naive === rejects on the first wrong character, so it returns fast. The constant-time compare scans the whole string anyway.

a === b naive compare
9f2cx·······
5 of 12 checked · slower
subtle.verify constant time
9f2cxa1e4b08
12 of 12 checked · constant
matched
first mismatch
never examined

The attacker fixes character 1 and the naive compare now runs measurably longer before rejecting. That extra time is the leak: it just confirmed character 1 was right.

a === b naive compare
9f2c7a1e4x··
10 of 12 checked · slowest
subtle.verify constant time
9f2c7a1e4x08
12 of 12 checked · constant
matched
first mismatch
never examined

Each correct character costs more time on top, none on the bottom. Repeat the probe and the attacker reconstructs the entire signature. The flat bottom row is why you compare every byte.

The constant-time fix. Sometimes you genuinely must compare two buffers yourself, for instance when a provider hands you a raw digest instead of letting you use subtle.verify, or with a legacy hex format or mixed signature schemes. Then the fix is to compare every byte, regardless of mismatches, so the timing reveals nothing about where they differ. Check that the lengths match, then XOR each pair of bytes into an accumulator and assert the accumulator is zero at the end. Because the loop always touches every byte, it takes the same time whether the buffers differ at byte 0 or byte 31.

const verify = async (secret, payload, incomingSig) => {
const expectedSig = await signPayload(secret, payload);
return incomingSig === expectedSig;
};

This leaks the signature one character at a time. === returns sooner the earlier the mismatch, so rejection time tells an attacker how many leading characters they got right. Repeat the probe and they recover the whole signature.

One runtime note so you recognize it in server code: Node exposes crypto.timingSafeEqual(a, b) on its node:crypto namespace for exactly this job. You don’t need to drill it, since the cross-runtime answer this course standardizes on is subtle.verify (preferred) or the hand-written XOR compare above, but when you see timingSafeEqual in someone’s handler, now you know it’s the same constant-time compare wearing Node’s name.

The rule has no exceptions: prefer subtle.verify, never compare a signature with ===, and make any hand-rolled compare constant-time.

An attacker fires the same forged request thousands of times and watches how long the handler takes to reject each one. Against incomingSig === expectedSig, which two facts let those timings hand them a valid signature? Select all that apply.

A guess whose first few characters happen to match the real signature gets rejected a hair later than one that’s wrong from the start.
Because each extra matched character costs measurably more time, the attacker can confirm the signature one position at a time until the whole thing is recovered.
=== refuses to compare two strings of different lengths, so it throws before the timing even matters.
A hex string isn’t a safe type to feed to ===, so the comparison result itself is unreliable.
=== runs slower than crypto.subtle.verify, so the handler is the bottleneck under load.

You will not hand-write most of these flows again. Later lessons hand you helpers, like a verifySignature or a session check, that wrap exactly what you just built. The payoff of building the primitive once is that you read those helpers with comprehension instead of faith. Here’s where each piece resurfaces:

Stripe & Resend webhooks

The exact HMAC-verify pattern from this lesson. The production rule to carry forward: verify on the raw body before you parse it, and dedupe replays with a processed_events ledger.

Idempotency keys

randomUUID minted on the client and sent as an Idempotency-Key header so a retried POST doesn’t run twice.

Session-cookie integrity

HMAC under the hood, handled for you by Better Auth. You trust the seam because you’ve seen what’s inside it.

Content-addressable keys

The digest → hex pipeline, reused to fingerprint and deduplicate uploaded files in the object-storage work.

Of those, one detail is worth fixing in your memory now, because it’s the single most common way webhook verification ships broken: you verify the signature on the raw, unparsed body. The moment you call JSON.parse and re-serialize, the bytes can shift through key order, whitespace, or number formatting, and the signature no longer matches even though nothing was tampered with. Verify first, on the exact bytes that arrived, then parse. Carry that one forward and you’ll get webhooks right the first time.

The platform documentation is the source of truth here, and the MDN pages are unusually good: each method page ships a runnable example you can paste into a console.