How to Sign API Requests with HMAC
A practical guide to HMAC request signing — what it proves, how to construct a canonical request, sign it with a shared secret, and verify it on the server.
What HMAC signing proves
HMAC (Hash-based Message Authentication Code) lets a server verify that a request came from a party that holds the shared secret and that the request body has not been tampered with in transit. It does not encrypt the body — the contents are still readable. It is used by AWS Signature V4, Stripe webhooks, GitHub webhooks, and many other APIs.
The canonical request
Before signing, both sides must agree on exactly what bytes to sign. This is called the canonical request. A typical canonical string includes the HTTP method, path, sorted query parameters, selected headers (including a timestamp), and a hash of the body. The exact format is up to you — just ensure both the sender and receiver build it the same way.
POST
/api/orders
content-type:application/json
x-timestamp:1700000000
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855Signing with HMAC-SHA256 in Node.js
import crypto from 'node:crypto';
const SECRET = process.env.API_SECRET;
function signRequest(method, path, body, timestampSeconds) {
const bodyHash = crypto
.createHash('sha256')
.update(body)
.digest('hex');
const canonical = [
method.toUpperCase(),
path,
`x-timestamp:${timestampSeconds}`,
bodyHash,
].join('\n');
return crypto
.createHmac('sha256', SECRET)
.update(canonical)
.digest('hex');
}
// Add to request headers:
// X-Timestamp: <timestampSeconds>
// X-Signature: <signRequest(...)>Verifying on the server
Recompute the signature using the same canonical format and the shared secret. Use a timing-safe comparison — never use === on signature strings because a character-by-character comparison leaks timing information that can be used to forge signatures.
function verifyRequest(method, path, body, timestamp, receivedSig) {
// 1. Reject requests older than 5 minutes (replay protection)
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - Number(timestamp)) > 300) {
return false;
}
// 2. Recompute expected signature
const expected = signRequest(method, path, body, timestamp);
// 3. Timing-safe comparison
return crypto.timingSafeEqual(
Buffer.from(expected, 'hex'),
Buffer.from(receivedSig, 'hex'),
);
}Verifying Stripe and GitHub webhook signatures
Stripe and GitHub compute the HMAC for you and send it in a header. You only need to recompute it from the raw request body and compare.
// Stripe
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET);
stripe.webhooks.constructEvent(rawBody, req.headers['stripe-signature'], process.env.STRIPE_WEBHOOK_SECRET);
// GitHub (raw body must not be parsed before this)
const sig = req.headers['x-hub-signature-256'];
const expected = 'sha256=' + crypto.createHmac('sha256', SECRET).update(rawBody).digest('hex');
const ok = crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected));What is the difference between HMAC and a digital signature?
HMAC uses a shared symmetric secret — both sides need the same key. A digital signature (RSA, ECDSA) uses an asymmetric key pair — the sender signs with a private key and anyone with the public key can verify. Use HMAC when both parties can safely share a secret. Use asymmetric signatures when you need public verifiability.
Why must I use the raw request body for webhook verification?
Parsing the body through JSON.parse and then re-serialising it can produce a different byte sequence (different key ordering, whitespace) than the original. Always buffer the raw bytes before any parsing and use those for the HMAC.
Why use timing-safe comparison?
A normal string equality check short-circuits on the first differing byte. An attacker can measure response times to learn how many bytes of their forged signature match the real one. crypto.timingSafeEqual always compares all bytes in constant time, eliminating this side channel.
How to Parse a JWT
A practical guide to reading and decoding JSON Web Tokens — understand the three-part structure, decode the header and payload, and know what to check before trusting a token.
Read guide →How to Generate Cryptographic Hashes
A practical guide to hashing — understand what hash functions do, the difference between MD5, SHA-1, SHA-256, and SHA-512, and how to generate hashes in JavaScript, Python, and the terminal.
Read guide →How to Hash a Password Correctly
A practical guide to storing passwords securely — why plain hashing is wrong, which algorithms to use, how salting works, and what a safe implementation looks like.
Read guide →How OAuth 2.0 Works
A practical guide to OAuth 2.0 — the authorization code flow, PKCE, tokens, scopes, and when to use each grant type.
Read guide →