AES in the Browser: Web Crypto Demos vs Production Key Management
Use AES-GCM with Web Crypto for demos and learning, understand IV and auth tags, and know why inventing browser-only crypto protocols fails in production.
How HMAC signatures protect webhooks — canonical message construction, hex vs Base64 encodings, timing-safe comparison concepts, and building reliable test fixtures.
By codefunc
Webhook providers prove authenticity with a shared secret and an HMAC tag over the request body (and sometimes a timestamp). Implementing verification looks short — a few lines of crypto — yet production outages cluster around the same mistakes: pretty-printed JSON, wrong encoding, naive string compare, or clocks that reject every valid retry. This guide focuses on the engineering details that make signatures verify reliably.
Bottom line: HMAC authenticates a message with a shared secret. Sign the exact raw bytes the provider signed, encode the tag the way the docs specify (hex or Base64), reject stale timestamps, and compare tags in a timing-safe way on the server. Use browser HMAC tools only with non-production secrets for fixtures.
Backend and full-stack developers verifying Stripe-style, GitHub-style, or custom webhooks — and frontend engineers debugging why a local mock signature never matches. Familiarity with SHA-256 helps; see also the hashing guide for digest vs HMAC.
HMAC (Hash-based Message Authentication Code) combines a secret key with a hash function (commonly SHA-256):
tag = HMAC-SHA256(secret, message)
Anyone with the secret can produce or verify the tag. Anyone without the secret should not be able to forge a valid tag for a chosen message. HMAC does not encrypt the body — pair it with TLS in transit.
| Property | Bare SHA-256 of body | HMAC-SHA256 |
|---|---|---|
| Detects accidental corruption | Yes | Yes |
| Detects attacker who can rewrite body + checksum | No | Yes (needs secret) |
| Needs a shared secret | No | Yes |
Debug digests with a Hash Generator; debug keyed tags with an HMAC Generator.
Providers differ on what string/bytes get signed. Always read their docs. Common patterns:
| Pattern | Message input | Example header |
|---|---|---|
| Raw body only | Exact HTTP body bytes | X-Signature: sha256=… |
| Timestamp + body | t + "." + body |
Stripe-style t=…,v1=… |
| Method + path + body | Custom canonical string | Internal APIs |
| Body hash then outer scheme | Hash of body embedded in signed string | Some enterprise APIs |
// Stripe-inspired shape (illustrative)
const signedPayload = `${timestamp}.${rawBody}`;
const expected = hmacSha256Hex(secret, signedPayload);
Critical rule: do not re-serialize JSON before verifying. {"a":1,"b":2} and {"b":2,"a":1} are different byte sequences. Capture the raw body from the request framework (disable automatic parsing until after verify, or keep a raw copy).
The HMAC output is raw bytes. Providers encode them differently:
| Encoding | Looks like | Typical use |
|---|---|---|
| Hex | a3f2… (64 chars for SHA-256) |
GitHub X-Hub-Signature-256: sha256=… |
| Base64 | o/I… |
Many enterprise webhooks |
| Base64url | URL-safe variant | Token-like contexts |
Prefixes such as sha256= are not part of the HMAC input; strip them before compare. When a doc says Base64, decode carefully — use a Base64 tool to inspect fixtures, and confirm whether padding = is expected.
// GitHub-style header: "sha256=<hex>"
const received = header.replace(/^sha256=/, "");
A vulnerable compare:
// Vulnerable to timing attacks in theory
if (received === expected) accept();
On many platforms, short-circuiting string equality can leak information about how far two strings match. Use a constant-time compare for secret-derived tags:
import { timingSafeEqual } from "node:crypto";
function safeEqualHex(a, b) {
const ba = Buffer.from(a, "hex");
const bb = Buffer.from(b, "hex");
if (ba.length !== bb.length) return false;
return timingSafeEqual(ba, bb);
}
In browsers / Web Crypto verification flows, prefer verified libraries or compare digests of both tags with care — production webhook verification belongs on the server anyway. Length checks must not early-return in a way that skips a constant-time compare on equal-length buffers; handle unequal lengths without leaking the expected tag.
Also reject obviously wrong lengths before compare when the algorithm is fixed (SHA-256 hex = 64 chars), but still avoid verbose error messages that distinguish "bad length" from "bad mac" to anonymous clients if that aids attackers — many teams still log details server-side only.
Signing timestamp + body lets you reject replays:
t from the header.|now - t| exceeds tolerance (often 5 minutes).Without a timestamp, a captured valid request could be replayed until the secret rotates. Idempotency keys at the application layer remain useful even with HMAC.
Reliable fixtures beat ad-hoc curl:
whsec_test_…), never a live key.{ secret, body, timestamp, header } in the test suite.// Fixture sketch
const secret = "test_secret_do_not_use_live";
const body = '{"event":"order.created","id":"ord_1"}';
const timestamp = "1700000000";
const message = `${timestamp}.${body}`;
// expectedHex from HMAC-SHA256(secret, message)
When the provider publishes official test vectors, prefer those. When encoding is ambiguous, check intermediate values: hash of body (if any) in Hash Generator, Base64 of the tag in Base64, full HMAC in HMAC Generator.
timingSafeEqual (or equivalent)| Anti-pattern | Why it fails |
|---|---|
| Pretty-print then verify | Byte mismatch |
| Put the webhook secret in a SPA | Users could forge "verified" calls to your own APIs |
| Use bare SHA-256 of the body as auth | Attackers recompute it |
| Log full secrets or full signed payloads in plaintext | Leakage via log drains |
| Skip TLS because "we have HMAC" | Body confidentiality and path security still need HTTPS |
Treat webhook verification as strict byte protocol work: canonical message, documented encoding, timestamp window, timing-safe compare. Build fixtures with disposable secrets using the HMAC Generator, inspect encodings with Base64, and reserve the Hash Generator for non-keyed digests. Production secrets stay on the server; browser tools are for learning and offline fixture craft.
Use AES-GCM with Web Crypto for demos and learning, understand IV and auth tags, and know why inventing browser-only crypto protocols fails in production.
Learn SHA-1/256/384/512 in the browser, the difference between checksums and password hashing, how Web Crypto digests work, and when HMAC belongs in your frontend workflow.
How password entropy works, why character sets matter, when browser CSPRNG is enough, and why generated passwords are not a substitute for proper password storage hashing.
Find a developer tool