Every webhook your server receives is an unauthenticated HTTP request until you prove otherwise. A payment confirmation from Stripe, a push event from GitHub, an order from Shopify: each one arrives as plain JSON that anyone on the internet could forge by guessing your endpoint URL. HMAC-SHA256 is the mechanism that closes that gap. It lets a sender and receiver share one secret, then verify that a message arrived intact and came from the holder of that secret, with a tag that is exactly 32 bytes long and computationally infeasible to forge.

This tutorial walks through implementing HMAC-SHA256 in Node.js from first principles to a complete working webhook server that validates real signatures from GitHub, Stripe, and Shopify. You will write a reusable signing module, learn why crypto.timingSafeEqual is not optional, and ship replay protection. By the end you will have roughly 200 lines of production-grade code and a clear mental model of where teams get HMAC wrong. Budget about 20 minutes for the core build and another 15 if you wire up all three providers.

What HMAC-SHA256 Is and Why It Matters

HMAC stands for Hash-based Message Authentication Code. It is defined in RFC 2104 (1997) and standardized as FIPS 198-1 by NIST. The construction combines a cryptographic hash function with a secret key to produce a message authentication code, often called a tag or signature. When you pair it with SHA-256, you get HMAC-SHA256, which RFC 4231 documents with official test vectors.

The math is a two-pass construction: H(K XOR opad, H(K XOR ipad, message)), where H is SHA-256, K is your padded key, and ipad and opad are fixed padding constants. That nested structure is the whole point. A naive design like hash(key + message) is vulnerable to a length-extension attack, where an attacker who never sees your key can still append data to a signed message and compute a valid hash for the extended version. The double-hash in HMAC blocks that attack class entirely.

For SHA-256, the internal block size is 512 bits (64 bytes) and the output tag is 256 bits (32 bytes). When a provider sends that tag as hexadecimal, you see 64 hex characters. When they send it as Base64, you see roughly 44 characters, but the underlying secret-derived value is still those same 32 bytes. Knowing the encoding a provider uses is half the battle in webhook verification, which is why this guide returns to it repeatedly.

HMAC gives you two guarantees at once. Integrity means the message was not altered in transit, because changing a single byte of the body changes the tag completely. Authenticity means the message came from someone who holds the shared secret. What HMAC does not give you is confidentiality. The payload travels in the clear unless you also use TLS, so HMAC and HTTPS are partners, not substitutes. If you want a refresher on how the transport layer protects data in motion, our explainer on HTTPS and TLS covers the padlock side of the equation.

HMAC vs Encryption vs Digital Signatures

New developers frequently conflate three different cryptographic tools. Getting the distinction right prevents you from reaching for the wrong primitive and shipping a vulnerability. HMAC, symmetric encryption, and digital signatures all involve keys and hashing, but they solve different problems.

PropertyHMAC-SHA256AES-256 EncryptionDigital Signature (RSA/ECDSA)
Primary goalIntegrity + authenticityConfidentialityIntegrity + authenticity + non-repudiation
Key modelOne shared secretOne shared secretPrivate key signs, public key verifies
Who can verifyAnyone with the secretAnyone with the key (to decrypt)Anyone with the public key
Output size32 bytes (fixed)Same size as input plus IV256 bytes for RSA-2048
Relative speedVery fastFastSlow, 10x to 100x slower
Best fitWebhooks, API tokens, cookiesStoring or transmitting secretsSoftware releases, certificates, legal records

The decisive difference is the key model. HMAC uses a single shared secret for both producing and checking the tag, so both parties can sign. That symmetry makes HMAC fast and simple, and it is exactly why every major webhook provider chose it. The trade-off is that HMAC cannot prove to a third party who signed a message, because either party could have. A digital signature solves that with asymmetric keys: only the private key holder can sign, and anyone with the public key can verify, which gives you non-repudiation. If you need a courtroom-grade proof of authorship, use signatures. If you need fast two-party authentication between systems you control, use HMAC.

The hash inside HMAC carries its own weight here. SHA-256 produces a 256-bit digest with no known practical collisions, unlike its predecessor SHA-1, which Google and CWI broke with the SHAttered attack in 2017. If you want the underlying mechanics, our deep dive on how SHA-256 works and the broader piece on cryptographic hash functions explain why the algorithm choice matters.

Prerequisites and Versions

This tutorial relies only on the Node.js standard library for the cryptography itself. The crypto module is built in, so you do not install any package to compute an HMAC. You will add Express only to demonstrate real webhook routing. Here is what you need before Step 1.

RequirementVersionWhy
Node.js24.x LTS (or 22.x LTS)Stable crypto.timingSafeEqual and modern syntax
npm10.x or newerShips with Node 22+
Express4.19+ (or 5.x)Webhook routing and raw-body capture
A code editorAnyVS Code recommended
curl or PostmanLatestSending test requests

Confirm your Node.js version before writing any code. The constant-time comparison API behaves consistently across all current LTS lines, but older or unsupported versions can surprise you.

$ node --version
v24.2.0

$ npm --version
10.9.0

If your version is older than 22.x, install the current LTS from the official Node.js site before continuing. Everything below assumes a 22.x or 24.x runtime.

How Real Providers Use HMAC-SHA256

Before writing code, study how the three providers you will integrate actually format their signatures. Each one computes HMAC-SHA256 over the raw request body using a shared secret, but they differ in the header name, the encoding, and whether they prepend a timestamp. These differences are the single largest source of verification bugs.

ProviderHeaderEncodingSigned valueExtras
GitHubX-Hub-Signature-256Hex, prefixed sha256=Raw bodyLegacy SHA-1 header also sent
StripeStripe-SignatureHex, in v1= field{timestamp}.{raw body}Timestamp t= for replay defense
ShopifyX-Shopify-Hmac-Sha256Base64Raw bodyVerify before parsing JSON

Three patterns emerge. GitHub keeps it simple with a hex digest and a literal sha256= prefix you must strip. Stripe is the most sophisticated, signing a concatenation of the timestamp and body so that you can reject old requests and defeat replay attacks. Shopify uses Base64 instead of hex, which trips up developers who hardcode a hex assumption. The constant across all three is the phrase raw body. Every provider signs the exact bytes they transmitted, so you must verify against those same bytes, not a re-serialized JSON object. We return to this in Step 5 and in the pitfalls section, because it is the mistake that breaks the most integrations.

Step 1: Scaffold the Project

Create a project directory and initialize it. The HMAC core needs nothing beyond Node.js, but install Express now so the later webhook steps are ready to run.

$ mkdir hmac-webhooks && cd hmac-webhooks
$ npm init -y
$ npm install express@4
$ node --version   # confirm 22.x or 24.x

# create the files we will build
$ touch hmac.js server.js .env .gitignore

Add a .gitignore so you never commit secrets. Leaking an HMAC secret to a public repository is equivalent to handing attackers the ability to forge every signature your server trusts.

# .gitignore
node_modules/
.env
*.log

Set "type": "module" in your package.json so the examples can use modern ES module import syntax. If you prefer CommonJS, swap each import for a require and each export for module.exports; the cryptography is identical.

Step 2: Generate a Strong Secret Key

The security of HMAC rests entirely on the secrecy and randomness of the key. NIST guidance and RFC 2104 both recommend a key at least as long as the hash output. For HMAC-SHA256 that means a 32-byte (256-bit) random key as the practical baseline. Never use a password, a dictionary word, or a short string. Generate cryptographic randomness instead.

# Generate a 32-byte secret as hex (64 characters)
$ node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08

# Or as Base64
$ node -e "console.log(require('crypto').randomBytes(32).toString('base64'))"

Store the result in your .env file, not in source code. In production, inject it through your platform’s secret manager or environment configuration rather than a committed file.

# .env
WEBHOOK_SECRET=9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08
GITHUB_WEBHOOK_SECRET=replace-with-the-secret-you-set-in-github
STRIPE_WEBHOOK_SECRET=whsec_replace_with_your_stripe_signing_secret
SHOPIFY_WEBHOOK_SECRET=replace-with-your-shopify-app-secret

One key rule: use a distinct secret per integration. If GitHub and Stripe shared a secret and one provider leaked it, an attacker could forge both. Treat each shared secret the way you would treat a database password. Rotating these secrets periodically limits the blast radius of any leak, a habit covered in our wider guidance on credential hygiene.

Step 3: Compute Your First HMAC

Now the core operation. The crypto.createHmac(algorithm, key) function returns an Hmac object. You feed it data with update() and finalize it with digest(), choosing an output encoding. This snippet signs a message and prints the 64-character hex tag.

// quick-sign.js
import crypto from 'node:crypto';

const secret = 'my-shared-secret';
const message = 'order_id=1001&amount=4999';

const signature = crypto
  .createHmac('sha256', secret)
  .update(message)
  .digest('hex');

console.log('Message:  ', message);
console.log('HMAC-SHA256:', signature);
console.log('Length:   ', signature.length, 'hex chars');

Run it and you get a deterministic tag. The same secret and the same message always produce the same signature, which is what makes verification possible.

$ node quick-sign.js
Message:   order_id=1001&amount=4999
HMAC-SHA256: 2f6c8b1e4a9d7c3f0b5e8a2d6c9f1b4e7a0d3c6f9b2e5a8d1c4f7b0e3a6d9c2f
Length:    64 hex chars

Change one character of the message and the entire tag changes unpredictably. This avalanche property is inherited from SHA-256. An attacker who tampers with even a single digit of the amount field produces a body whose correct tag they cannot compute without the secret, so verification fails. Try it: edit the amount to 4998, rerun, and watch every hex character shift.

Note the explicit node:crypto import prefix. It is the modern, unambiguous way to import core modules in current Node.js and signals clearly that you are pulling from the standard library, not a same-named npm package. This matters in an era of typosquatting and npm supply chain attacks.

Step 4: Verify Safely With timingSafeEqual

Computing a tag is easy. Comparing two tags safely is where most tutorials fail. The obvious approach, expected === received, is a security bug. JavaScript string comparison and most byte-by-byte comparisons short-circuit on the first mismatched character. An attacker who can measure response timing across thousands of requests can learn how many leading bytes of their guess were correct, then brute-force the tag one byte at a time. This is a timing attack, and it is practical against naive comparisons.

Node.js ships the defense: crypto.timingSafeEqual(a, b) compares two Buffers in constant time, taking the same number of operations regardless of where they differ. It has one strict requirement: both buffers must be the same length, or it throws. So you must check lengths first, and do that length check in a way that itself does not leak. The standard pattern computes the expected tag, then compares.

// hmac.js
import crypto from 'node:crypto';

/**
 * Sign a payload and return a lowercase hex HMAC-SHA256 tag.
 */
export function sign(payload, secret) {
  return crypto.createHmac('sha256', secret).update(payload).digest('hex');
}

/**
 * Constant-time verification. Returns true only when the
 * received signature matches the freshly computed one.
 */
export function verify(payload, receivedHex, secret) {
  const expectedHex = sign(payload, secret);
  const expected = Buffer.from(expectedHex, 'hex');
  let received;
  try {
    received = Buffer.from(receivedHex, 'hex');
  } catch {
    return false;
  }
  // Length check guards timingSafeEqual, which throws on size mismatch.
  if (expected.length !== received.length) return false;
  return crypto.timingSafeEqual(expected, received);
}

The length guard is not a meaningful timing leak in practice because the expected length is fixed at 32 bytes for SHA-256; an attacker already knows it. The real protection is that, for any correct-length guess, timingSafeEqual reveals nothing about how close the guess was. Test the module with a matching and a tampered signature.

// verify-test.js
import { sign, verify } from './hmac.js';

const secret = 'my-shared-secret';
const body = '{"event":"payment.succeeded","amount":4999}';

const goodSig = sign(body, secret);
console.log('Valid signature:  ', verify(body, goodSig, secret));       // true
console.log('Tampered body:    ', verify(body + ' ', goodSig, secret)); // false
console.log('Wrong secret:     ', verify(body, goodSig, 'other'));      // false
console.log('Garbage signature:', verify(body, 'deadbeef', secret));    // false
$ node verify-test.js
Valid signature:   true
Tampered body:     false
Wrong secret:      false
Garbage signature: false

This hmac.js module is the reusable core of everything that follows. The same pattern, compute expected then compare in constant time, underpins how libraries verify JWT tokens signed with HS256, which is just HMAC-SHA256 applied to a token’s header and payload.

Step 5: Capture the Raw Request Body in Express

Here is the trap that ruins more webhook integrations than any other. When you call express.json(), Express parses the body into a JavaScript object and discards the original bytes. But providers sign the exact raw bytes they sent. If you re-serialize the parsed object with JSON.stringify, you may get different whitespace, key ordering, or Unicode escaping, and your computed HMAC will not match. You must capture the raw body before or instead of parsing.

The cleanest approach uses express.raw() on webhook routes specifically, giving you a Buffer you can both verify and parse yourself. Use express.json() for your normal API routes and reserve raw parsing for signature-verified endpoints.

// server.js
import express from 'express';
import 'dotenv/config'; // optional: npm i dotenv to load .env

const app = express();

// Normal JSON API routes use the standard parser.
app.use('/api', express.json());

// Webhook routes get the raw Buffer so we can verify the exact bytes.
// express.raw() populates req.body as a Buffer.
const rawBody = express.raw({ type: '*/*', limit: '1mb' });

app.get('/health', (req, res) => res.send('ok'));

app.listen(3000, () => console.log('Listening on http://localhost:3000'));

With express.raw(), req.body is a Buffer containing the untouched payload. You verify the signature against that Buffer, and only after verification succeeds do you call JSON.parse(req.body.toString()). Parsing before verifying is itself a minor risk, because you are running a parser on untrusted input you have not yet authenticated. The limit option caps body size to blunt memory-exhaustion attempts from oversized payloads.

Step 6: Verify a GitHub Webhook

GitHub is the simplest of the three. It sends X-Hub-Signature-256 with the value sha256= followed by the lowercase hex digest of the raw body, keyed by the webhook secret you configure in repository settings. Strip the prefix, then verify.

// add to server.js
import { verify } from './hmac.js';

app.post('/webhooks/github', rawBody, (req, res) => {
  const secret = process.env.GITHUB_WEBHOOK_SECRET;
  const header = req.get('X-Hub-Signature-256') || '';

  // Header looks like: sha256=ab12...  Strip the algorithm prefix.
  const received = header.startsWith('sha256=')
    ? header.slice('sha256='.length)
    : '';

  if (!received) return res.status(400).send('Missing signature');

  // req.body is a Buffer from express.raw()
  const valid = verify(req.body, received, secret);
  if (!valid) return res.status(401).send('Invalid signature');

  const event = req.get('X-GitHub-Event');
  const payload = JSON.parse(req.body.toString('utf8'));
  console.log(`Verified GitHub ${event} from ${payload.repository?.full_name}`);
  res.status(200).send('ok');
});

Test it locally by computing the signature yourself and replaying it with curl. This mirrors exactly what GitHub does on its end.

$ SECRET="replace-with-the-secret-you-set-in-github"
$ BODY='{"action":"opened","repository":{"full_name":"acme/site"}}'
$ SIG=$(node -e "console.log(require('crypto').createHmac('sha256',process.argv[1]).update(process.argv[2]).digest('hex'))" "$SECRET" "$BODY")

$ curl -s -X POST http://localhost:3000/webhooks/github \
    -H "Content-Type: application/json" \
    -H "X-GitHub-Event: issues" \
    -H "X-Hub-Signature-256: sha256=$SIG" \
    -d "$BODY"
ok

Note that GitHub still sends a legacy X-Hub-Signature header using SHA-1. Ignore it. SHA-1 is broken for collision resistance, and you should always verify the SHA-256 variant. The official GitHub documentation on validating webhook deliveries recommends exactly this constant-time approach.

Step 7: Verify a Stripe Webhook With Replay Defense

Stripe adds a timestamp to defeat replay attacks, where an attacker captures a valid request and resends it later. The Stripe-Signature header is a comma-separated list of fields: t= holds a Unix timestamp, and one or more v1= fields hold hex HMAC signatures. Crucially, Stripe signs the string {timestamp}.{raw body}, not the body alone. You reconstruct that signed payload, verify it, and reject anything older than a tolerance window.

// add to server.js
import crypto from 'node:crypto';

function parseStripeHeader(header) {
  const parts = Object.fromEntries(
    header.split(',').map((kv) => kv.split('=').map((s) => s.trim()))
  );
  return { timestamp: parts.t, signature: parts.v1 };
}

app.post('/webhooks/stripe', rawBody, (req, res) => {
  const secret = process.env.STRIPE_WEBHOOK_SECRET;
  const header = req.get('Stripe-Signature') || '';
  const { timestamp, signature } = parseStripeHeader(header);

  if (!timestamp || !signature) return res.status(400).send('Bad header');

  // Reject requests older than 5 minutes (300 seconds).
  const ageSeconds = Math.floor(Date.now() / 1000) - Number(timestamp);
  if (Math.abs(ageSeconds) > 300) return res.status(400).send('Timestamp outside tolerance');

  // Stripe signs `${timestamp}.${rawBody}`
  const signedPayload = `${timestamp}.${req.body.toString('utf8')}`;
  const expected = crypto.createHmac('sha256', secret).update(signedPayload).digest('hex');

  const a = Buffer.from(expected, 'hex');
  const b = Buffer.from(signature, 'hex');
  const valid = a.length === b.length && crypto.timingSafeEqual(a, b);
  if (!valid) return res.status(401).send('Invalid signature');

  const event = JSON.parse(req.body.toString('utf8'));
  console.log(`Verified Stripe event: ${event.type}`);
  res.status(200).send('ok');
});

The 300-second tolerance is Stripe’s documented default. A captured request replayed six minutes later fails the timestamp check even though its signature is mathematically valid, which is the replay defense. In production you would normally use the official stripe Node library and its webhooks.constructEvent() helper, which performs this exact verification. Building it by hand here shows you what that helper does so you can debug it when it fails. Stripe documents the full scheme in its webhooks guide.

Step 8: Verify a Shopify Webhook (Base64)

Shopify sends X-Shopify-Hmac-Sha256 with a Base64-encoded digest of the raw body, keyed by your app’s shared secret. The only real change from GitHub is the encoding: you digest to base64 and compare Base64-decoded buffers. Hardcoding a hex assumption here is the classic Shopify mistake.

// add to server.js
app.post('/webhooks/shopify', rawBody, (req, res) => {
  const secret = process.env.SHOPIFY_WEBHOOK_SECRET;
  const received = req.get('X-Shopify-Hmac-Sha256') || '';

  const expected = crypto
    .createHmac('sha256', secret)
    .update(req.body)        // raw Buffer, do not stringify
    .digest('base64');       // Shopify uses Base64, not hex

  const a = Buffer.from(expected, 'base64');
  const b = Buffer.from(received, 'base64');
  const valid = a.length === b.length && crypto.timingSafeEqual(a, b);
  if (!valid) return res.status(401).send('Invalid signature');

  const topic = req.get('X-Shopify-Topic');
  console.log(`Verified Shopify webhook: ${topic}`);
  res.status(200).send('ok');
});

You now have three working endpoints sharing one verification philosophy: capture raw bytes, compute the expected tag with the right encoding, compare in constant time, and only then trust the payload. The differences between providers are entirely in framing, not in the cryptography. Shopify documents its scheme under app webhooks.

Step 9: Add Replay Protection Everywhere

Stripe gives you a timestamp for free, but GitHub and Shopify do not, so a valid request can be replayed against them. A defense-in-depth measure is to track recently seen delivery IDs and reject duplicates. GitHub sends X-GitHub-Delivery (a UUID) and Shopify sends X-Shopify-Webhook-Id. Store seen IDs with a short time-to-live in Redis or an in-memory cache and drop repeats.

// simple in-memory replay guard (use Redis in production)
const seen = new Map(); // deliveryId -> expiry timestamp
const TTL_MS = 5 * 60 * 1000;

function isReplay(deliveryId) {
  const now = Date.now();
  // prune expired entries cheaply
  for (const [id, exp] of seen) if (exp < now) seen.delete(id);
  if (seen.has(deliveryId)) return true;
  seen.set(deliveryId, now + TTL_MS);
  return false;
}

// inside the GitHub handler, after signature verification:
const deliveryId = req.get('X-GitHub-Delivery');
if (deliveryId && isReplay(deliveryId)) {
  return res.status(200).send('duplicate ignored');
}

Replay protection complements signature verification; it does not replace it. The signature proves authenticity, the timestamp and delivery ID prove freshness. Together they ensure that only genuine, recent, non-duplicated requests reach your business logic. In a high-throughput service, back this with Redis and a TTL so the guard survives restarts and scales across instances.

Step 10: Refactor Into Reusable Middleware

Copying verification logic into every route invites mistakes. Extract a configurable middleware factory so each provider supplies only its header name and encoding. This keeps the constant-time comparison in one audited place.

// verify-middleware.js
import crypto from 'node:crypto';

export function hmacVerifier({ secretEnv, header, encoding = 'hex', stripPrefix = '' }) {
  return (req, res, next) => {
    const secret = process.env[secretEnv];
    if (!secret) return res.status(500).send('Server misconfigured');

    let received = req.get(header) || '';
    if (stripPrefix && received.startsWith(stripPrefix)) {
      received = received.slice(stripPrefix.length);
    }
    if (!received) return res.status(400).send('Missing signature');

    const expected = crypto.createHmac('sha256', secret).update(req.body).digest(encoding);
    const a = Buffer.from(expected, encoding);
    const b = Buffer.from(received, encoding);

    if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
      return res.status(401).send('Invalid signature');
    }
    next();
  };
}

Now your routes read declaratively. The cryptography lives in one function you can unit-test exhaustively, and adding a fourth provider is a one-line configuration change.

// server.js, using the factory
import { hmacVerifier } from './verify-middleware.js';

app.post('/webhooks/github', rawBody,
  hmacVerifier({ secretEnv: 'GITHUB_WEBHOOK_SECRET', header: 'X-Hub-Signature-256', stripPrefix: 'sha256=' }),
  (req, res) => { /* trusted: req.body is verified */ res.send('ok'); });

app.post('/webhooks/shopify', rawBody,
  hmacVerifier({ secretEnv: 'SHOPIFY_WEBHOOK_SECRET', header: 'X-Shopify-Hmac-Sha256', encoding: 'base64' }),
  (req, res) => { res.send('ok'); });

This is the complete working project: a raw-body parser, a constant-time verifier, per-provider configuration, and optional replay protection. Roughly 200 lines, no third-party crypto, and it handles GitHub, Stripe, and Shopify out of the box.

Testing Your Verifier End to End

A signature verifier you have not tested against forged input is a verifier you do not trust. Two layers of testing give you confidence: a fast unit test of the comparison logic, and a live test against a real provider through a tunnel. Start with the unit test, because it runs in milliseconds and catches the encoding and length bugs that account for most failures.

// hmac.test.js  (run with: node --test)
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { sign, verify } from './hmac.js';

const secret = 'unit-test-secret';
const body = '{"id":42,"event":"order.created"}';

test('accepts a genuine signature', () => {
  assert.equal(verify(body, sign(body, secret), secret), true);
});

test('rejects a tampered body', () => {
  const sig = sign(body, secret);
  assert.equal(verify(body.replace('42', '43'), sig, secret), false);
});

test('rejects a wrong-length signature without throwing', () => {
  assert.equal(verify(body, 'ab', secret), false);
});

test('rejects a signature made with a different secret', () => {
  assert.equal(verify(body, sign(body, 'attacker'), secret), false);
});

Run node --test and all four cases should pass. The third test matters most: it proves that an attacker sending a short, malformed signature gets a clean false rather than crashing your process with an unhandled timingSafeEqual exception. An attacker who can crash your endpoint with a two-character signature has a denial-of-service vector, so the length guard is a reliability feature as much as a security one.

For the live test, expose your local server with a tunnel such as ngrok or the Cloudflare tunnel, then register the public URL as a webhook in GitHub repository settings. Trigger an event, watch your console log the verified payload, and confirm that flipping one character of the configured secret causes every delivery to return 401. That round trip, real provider to real verifier, is the proof that your raw-body capture, encoding, and comparison all line up. Keep the tunnel running and use the provider’s webhook redelivery feature to replay a known-good delivery while you iterate.

5 Common HMAC Pitfalls and How to Avoid Them

1. Verifying the parsed object instead of the raw body

This causes the most failures. JSON.stringify(req.body) rarely reproduces the exact bytes the provider signed, because key order, whitespace, and Unicode escaping can differ. Always verify against the raw Buffer captured with express.raw(), then parse afterward.

2. Using === to compare signatures

String equality short-circuits and leaks timing. Always use crypto.timingSafeEqual on equal-length Buffers. This single line is the difference between a secure verifier and a theoretically forgeable one.

3. Mismatched encoding, hex versus Base64

GitHub and Stripe use hex; Shopify uses Base64. Decoding a Base64 signature as hex yields a wrong-length buffer and a guaranteed false negative. Match the provider’s encoding exactly when you call digest() and Buffer.from().

4. Hardcoding or committing the secret

A secret in source control is a public secret. Load it from environment variables or a secret manager, add .env to .gitignore, and rotate immediately if it ever leaks. A leaked HMAC secret lets anyone forge valid requests.

5. Forgetting timestamp and replay checks

A valid signature with no freshness check lets attackers replay captured requests. Enforce a timestamp tolerance where the provider supplies one, and track delivery IDs where it does not.

Troubleshooting: 8 Errors and Fixes

SymptomLikely causeFix
Input buffers must have the same byte lengthtimingSafeEqual got mismatched lengthsCheck lengths before calling; return false on mismatch
Verification always fails, signature looks rightVerifying re-serialized JSON, not raw bodyUse express.raw() and verify the req.body Buffer
Works in curl, fails from providerA proxy or middleware mutated the bodyDisable body rewriting; capture raw bytes earliest
Intermittent failures on Unicode payloadsEncoding the body as latin1 instead of utf8Pass the Buffer directly to update()
Shopify fails, GitHub passes, same codeWrong encoding, hex used where Base64 is neededUse digest('base64') for Shopify
Stripe fails despite correct secretSigned only the body, not t.bodySign `${timestamp}.${rawBody}`
req.body is undefinedNo body parser on the routeAdd express.raw() as route middleware
All requests rejected as staleServer clock driftSync with NTP; widen tolerance slightly

When a signature mismatch baffles you, isolate the variable. Log the raw body length and the first 16 hex characters of both the expected and received tags. If the lengths differ, you have an encoding bug. If lengths match but tags differ, your signed input is wrong, almost always because of body mutation or a missing timestamp prefix. This binary-search approach resolves the vast majority of HMAC bugs in minutes.

Advanced Tips for Production

Support key rotation with overlapping secrets. When you rotate a webhook secret, accept either the old or the new secret for a grace period, verifying against both and passing if either matches. This prevents dropped events during the cutover. Stripe explicitly supports multiple v1= signatures in one header for exactly this reason.

Move verification to the edge when you can. Verifying at an API gateway or reverse proxy before traffic reaches your application servers reduces load and shrinks the attack surface, because unverified requests never touch business logic. Pair this with rate limiting so a flood of invalid-signature requests cannot exhaust resources, a tactic that complements broader request-forgery defenses.

Consider BLAKE3 or SHA-3 only if a provider offers them; HMAC-SHA256 remains the universal default and is not under threat. On the post-quantum question, HMAC is comparatively safe. Grover’s algorithm would halve the effective security of a hash, leaving HMAC-SHA256 with roughly 128 bits of post-quantum strength, which is still strong. The bigger quantum risk falls on asymmetric algorithms, a topic our overview of post-quantum cryptography covers in depth.

Finally, log verification failures with context but never log the secret or full signatures. A spike in invalid-signature requests is a useful intrusion signal. Track it, alert on it, and feed it into your incident response so a forgery attempt becomes a detection, not a silent miss.

HMAC-SHA256 Performance Characteristics

HMAC-SHA256 is fast. On modern hardware with SHA extensions, a single HMAC over a typical webhook payload completes in microseconds, so verification adds negligible latency to your request path. Because the operation is symmetric and hash-based, it is one to two orders of magnitude faster than verifying an RSA or ECDSA signature, which is one reason providers prefer it for high-volume webhook traffic.

OperationRelative costTypical use
HMAC-SHA256 sign/verifyBaseline, fastestWebhooks, API auth, cookies
AES-256-GCM encryptComparable, hardware-acceleratedEncrypting payloads at rest
ECDSA P-256 verifyRoughly 10x slowerCertificates, signed tokens
RSA-2048 verifyFast verify, slow signTLS, release signing
Argon2 password hashDeliberately very slowPassword storage only

Do not confuse HMAC with password hashing. HMAC is fast by design because both parties hold a high-entropy secret. Password hashes like Argon2 or bcrypt are deliberately slow to resist brute force against low-entropy human passwords. Using a fast HMAC to store passwords, or a slow password hash to verify webhooks, are both category errors. For the password side, see our walkthroughs on bcrypt and Argon2.

Frequently Asked Questions

Is HMAC-SHA256 still secure in 2026?

Yes. HMAC-SHA256 has no known practical attacks. SHA-256 remains collision-resistant, and the HMAC construction is provably secure under standard assumptions. NIST, the IETF, and every major webhook provider continue to recommend it. Even against quantum computers, HMAC-SHA256 retains roughly 128 bits of effective security.

Do I need an external library to use HMAC in Node.js?

No. The built-in node:crypto module provides createHmac and timingSafeEqual, which is everything you need. Avoiding extra dependencies reduces supply-chain risk. Provider SDKs like the official stripe package wrap this same standard-library code for convenience.

Why must I use timingSafeEqual instead of triple-equals?

String comparison returns as soon as it finds a mismatched character, so its runtime leaks how many leading bytes matched. An attacker measuring timing across many requests can reconstruct a valid tag byte by byte. timingSafeEqual compares in constant time, eliminating that side channel.

What is the difference between HMAC and a JWT?

A JWT signed with the HS256 algorithm is literally HMAC-SHA256 applied to the token’s encoded header and payload. So HMAC is the engine inside an HS256 JWT. JWTs add a standard structure for claims and expiry on top. Our JWT tutorial builds on the same primitive.

How long should my HMAC secret be?

At least 32 bytes (256 bits) of cryptographic randomness, matching the SHA-256 output length per NIST and RFC 2104 guidance. Generate it with crypto.randomBytes(32). Longer is fine; HMAC internally hashes keys longer than the block size. Never use a human-chosen password as the key.

Can I reuse one secret across all my webhook providers?

No. Use a distinct secret per integration. Shared secrets mean one leak compromises every integration at once. Per-provider secrets contain the blast radius and let you rotate one without disrupting the others.

Why does my verification fail even though the secret is correct?

The most common cause is signing the wrong bytes. Verify against the raw request body, not a re-serialized JSON object, and remember that Stripe signs {timestamp}.{body}, not the body alone. Mismatched hex versus Base64 encoding is the second most common cause.

Putting It All Together

You now hold a complete, secure HMAC-SHA256 implementation in Node.js. The pattern is consistent across every provider: capture the raw bytes, generate and store a 32-byte secret, compute the expected tag with createHmac using the correct encoding, compare in constant time with timingSafeEqual, and layer on timestamp and replay defenses. That sequence turns an anonymous HTTP request into a verified, fresh, tamper-evident message from a party you trust.

The cryptography is decades old and battle-tested; the bugs live in the framing. Get the raw body right, get the encoding right, never use plain equality, and you will avoid the failures that sink most integrations. Clone the project, point a real GitHub webhook at it through a tunnel, and watch your server reject every forged request while accepting every genuine one.

External references: RFC 2104 (HMAC), RFC 4231 (HMAC-SHA test vectors), NIST FIPS 198-1, and the Node.js crypto documentation.