crypto.createHmac()
Creates an Hmac object that computes a keyed message authentication code over streamed data.
crypto.createHmac(algorithm, key[, options])- Added in Node.js v0.1.94
Parameters
| Parameter | Type | Description |
|---|---|---|
$algorithm | string | The name of the underlying digest algorithm, such as 'sha256' or 'sha512'. crypto.getHashes() lists candidates, though HMAC accepts fewer of them than hashing does. |
$key | string | ArrayBuffer | Buffer | TypedArray | DataView | KeyObject | The secret HMAC key. If it is a KeyObject its type must be 'secret'. A key from a cryptographic entropy source should not exceed the algorithm's block size (512 bits for SHA-256). |
$optionsoptional | Object | stream.Transform options, controlling behaviour when the Hmac is used as a stream. |
$options.encodingoptional | string | The string encoding to use when key is a string. |
Return value
HmacAn Hmac object, which is also a stream.Transform, with update() and digest() methods mirroring those of Hash.
Errors
Throws ERR_CRYPTO_UNKNOWN_DIGEST for an unsupported algorithm, or ERR_INVALID_ARG_TYPE if the key is not an accepted type.
Changelog
- PHP v15.0.0The key can also be an ArrayBuffer, the encoding option was added, and the key may not exceed 2**32 - 1 bytes.
- PHP v11.6.0The key argument can now be a KeyObject.
An HMAC answers a question a plain hash cannot: was this message produced by someone holding the secret key? Anyone can recompute a SHA-256 digest of tampered data, so a bare hash proves only that bytes are intact. Mixing in a key means a forged message cannot carry a valid tag.
When to reach for it
This is the workhorse of webhook verification. GitHub, Stripe, Shopify and Slack all sign their payloads with HMAC-SHA256 and a shared secret; verifying that signature is the only thing standing between your endpoint and anyone who guesses its URL. It is equally the right tool for signed URLs, signed cookies and API request signing.
What trips people up
Three recurring bugs. First, signing a parsed object instead of the raw body: JSON.parse then JSON.stringify reorders keys and drops whitespace, so the bytes no longer match what the sender signed. Capture the raw buffer before any body parser touches it.
Second, comparing signatures with ===. String comparison exits at the first mismatched character, and that timing difference lets an attacker recover a valid signature byte by byte. Use crypto.timingSafeEqual().
Third, like Hash, an Hmac is single-use — once digest() has run, the object is finalised and must be rebuilt for the next message.
Example
When to use it
- Verifying a GitHub or Stripe webhook signature before trusting the payload
- Signing a temporary download URL so it cannot be altered or extended
- Authenticating API requests with a shared client secret
- Producing a tamper-evident signed cookie without a full JWT library
Frequently asked questions
What is the difference between createHash and createHmac?
createHash takes only data and proves that bytes are unchanged, which anyone can recompute. createHmac also takes a secret key, so a valid tag proves the message came from someone holding that key — use it for webhooks, signed URLs and any authenticity check.
Why does my webhook signature verification always fail?
Almost always because you are signing a parsed body rather than the raw bytes — JSON.parse followed by JSON.stringify changes key order and whitespace. Capture the raw buffer with express.raw() or an equivalent before any body parser runs.
How should I compare two HMAC signatures?
With crypto.timingSafeEqual() on equal-length Buffers, never with === on strings. String comparison stops at the first differing byte, and that timing signal is enough for an attacker to reconstruct a valid signature one byte at a time.
More examples
Verifying a GitHub webhook signature
express.raw() is what makes this correct — express.json() would leave req.body as an object whose re-serialization no longer matches the signed bytes. Parsing happens only after verification, so malformed payloads from an unauthenticated sender never reach the JSON parser.
const crypto = require('node:crypto');
const express = require('express');
const app = express();
const SECRET = process.env.GITHUB_WEBHOOK_SECRET;
// Keep the raw bytes: the signature covers them, not the parsed object.
app.use('/webhook', express.raw({ type: 'application/json' }));
function isValid(rawBody, headerValue) {
if (!headerValue) return false;
const expected = 'sha256=' +
crypto.createHmac('sha256', SECRET).update(rawBody).digest('hex');
const a = Buffer.from(expected);
const b = Buffer.from(headerValue);
// Length check first — timingSafeEqual throws on mismatched lengths.
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
app.post('/webhook', (req, res) => {
if (!isValid(req.body, req.get('x-hub-signature-256'))) {
return res.status(401).send('bad signature');
}
const event = JSON.parse(req.body.toString('utf8')); // parse only after verifying
console.log('verified event:', event.action);
res.sendStatus(204);
});
console.log('handler registered for POST /webhook');Signed, expiring download URLs
Both attacks fail: swapping the path and pushing out the expiry each change the signed string, so the recomputed tag no longer matches. The expiry is checked only after the signature validates, so an attacker cannot learn anything by submitting garbage.
const crypto = require('node:crypto');
const SECRET = 'url-signing-key';
function sign(path, expiresAt) {
// Include every field that must not be tampered with, separated
// by a character that cannot appear in the path.
return crypto.createHmac('sha256', SECRET)
.update(`${path}\n${expiresAt}`)
.digest('base64url');
}
function verify(path, expiresAt, signature) {
const expected = Buffer.from(sign(path, expiresAt));
const given = Buffer.from(signature);
if (expected.length !== given.length) return 'invalid';
if (!crypto.timingSafeEqual(expected, given)) return 'invalid';
return Number(expiresAt) < 1_700_000_000_000 ? 'expired' : 'ok';
}
const path = '/files/report.pdf';
const exp = 1_900_000_000_000;
const sig = sign(path, exp);
console.log('signature chars:', sig.length);
console.log('valid :', verify(path, exp, sig));
console.log('tampered path :', verify('/files/secret.pdf', exp, sig));
console.log('extended expiry:', verify(path, 2_000_000_000_000, sig));HMAC compared with a plain hash
This is the distinction that decides which function to use: a hash detects accidental corruption, while an HMAC detects deliberate forgery. Without the key the attacker's tag is unrelated to the genuine one, so verification returns false.
const crypto = require('node:crypto');
const message = 'balance=100';
const tampered = 'balance=999';
// A plain hash proves integrity only if the digest itself is trusted.
// An attacker who changes the message can simply recompute the digest.
console.log('attacker recomputes hash:', crypto.hash('sha256', tampered).slice(0, 16), '...');
// An HMAC cannot be recomputed without the key.
const SECRET = crypto.randomBytes(32);
const tag = crypto.createHmac('sha256', SECRET).update(message).digest();
const forged = crypto.createHmac('sha256', Buffer.alloc(32)).update(tampered).digest();
console.log('genuine tag verifies:',
crypto.timingSafeEqual(tag, crypto.createHmac('sha256', SECRET).update(message).digest()));
console.log('forged tag verifies :', crypto.timingSafeEqual(tag, forged));
Discussion