DevToolsForYou
Auth & Security

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.

2 min readUpdated Apr 11, 2026

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.

text
POST
/api/orders

content-type:application/json
x-timestamp:1700000000

e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855

Signing with HMAC-SHA256 in Node.js

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

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

javascript
// 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));
Frequently asked questions

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.

Related cheatsheetsAll cheatsheets →
Related guidesAll guides →