An HMAC signature is a short code computed from a message and a shared secret key with a hash function, such as HMAC-SHA256, that lets the receiver confirm the message came from a holder of the key and was not changed on the way.
HMAC stands for hash-based message authentication code. It is behind signed URLs, webhook signatures, many API request-signing schemes and cookie signing. This entry explains the primitive itself; the signed URL entry covers one use of it in depth. Both are part of the screenshot and rendering glossary.
A plain explanation
A plain hash, such as SHA-256, proves a message was not changed, but anyone can compute it. An attacker who changes the message just computes a new hash. HMAC mixes in a secret key, so only someone who knows the key can produce the right code. The receiver, who also knows the key, computes the code again and compares.
sender receiver message ──┐ message ──┐ ├─ HMAC-SHA256(key, message) ─▶ code ├─ HMAC-SHA256(key, message) ─▶ code' key ──────┘ key ──────┘ send message + code ─────────────▶ accept only if code == code' (constant time)
A standard test value lets you check any implementation. With key key and message The quick brown fox jumps over the lazy dog, HMAC-SHA256 in hex is:
f7bc83f430538424b13298e6aa6fb143ef4d59a14946175997479dbc2d1a3cd8
Our Node run on 2026-10-15 produced exactly this value.
How it works technically
HMAC (RFC 2104) hashes twice: H((K ⊕ opad) ‖ H((K ⊕ ipad) ‖ message)), where the key is padded to the hash's block size and combined with two fixed constants. The outer hash is what makes HMAC safe where sha256(secret + message) is not: SHA-256 alone allows length extension, so a valid hash for one message leaks a valid hash for a longer one.
What an HMAC signature does and does not give you:
| Property | HMAC gives it? | Notes |
|---|---|---|
| Integrity: message not changed | Yes | any changed byte gives a different code |
| Authenticity: from a key holder | Yes | but every verifier is also a key holder |
| Non-repudiation | No | use a public-key signature |
| Freshness, no replay | No | sign a timestamp or expiry and check it |
| Secrecy of the message | No | the message travels in plain text |
The last two rows are why real schemes add something. A signed URL includes expires in the signed string. A webhook signature signs timestamp + "." + body, and the receiver rejects old timestamps.
Signing and verifying around a headless browser
A common screenshot task is capturing an unpublished page, such as a draft post, that your app protects with an HMAC-signed preview link. The app signs id.expiry; the preview route recomputes the code and compares in constant time with crypto.timingSafeEqual.
Puppeteer signs a 5-minute link and captures it:
import { createHmac } from 'node:crypto'; import puppeteer from 'puppeteer'; // Sign a short-lived link to an unpublished preview page, then capture it. const secret = process.env.PREVIEW_SECRET; // shared with the app that serves /preview const id = 'post-42'; const exp = Math.floor(Date.now() / 1000) + 300; // valid for 5 minutes const sig = createHmac('sha256', secret).update(`${id}.${exp}`).digest('hex'); const url = `http://localhost:4630/preview?id=${id}&exp=${exp}&sig=${sig}`; const browser = await puppeteer.launch(); try { const page = await browser.newPage(); await page.setViewport({ width: 640, height: 120 }); const res = await page.goto(url, { waitUntil: 'load' }); if (!res.ok()) throw new Error(`preview returned ${res.status()}`); await page.screenshot({ path: 'out/hmac-preview.png' }); console.log('captured', res.status(), 'sig', sig.slice(0, 12) + '...'); } finally { await browser.close(); }
Playwright shows what tampering does: the same signature with a different id is refused.
import { createHmac } from 'node:crypto'; import { chromium } from 'playwright'; const secret = process.env.PREVIEW_SECRET; const sign = (id, exp) => createHmac('sha256', secret).update(`${id}.${exp}`).digest('hex'); const exp = Math.floor(Date.now() / 1000) + 300; const browser = await chromium.launch(); try { const page = await browser.newPage(); // A valid link, then the same link with the id changed but the old signature kept. for (const id of ['post-42', 'post-43']) { const res = await page.goto( `http://localhost:4630/preview?id=${id}&exp=${exp}&sig=${sign('post-42', exp)}`); console.log(id, res.status()); } } finally { await browser.close(); }
Output: post-42 200, then post-43 403.
The verifying side, from our test server, is the part people get wrong most often:
// Fragment: the preview route's check (Node). const expected = createHmac('sha256', PREVIEW_SECRET).update(`${id}.${exp}`).digest(); const given = Buffer.from(sig, 'hex'); const valid = Number(exp) > Date.now() / 1000 && given.length === expected.length && timingSafeEqual(given, expected);
HMAC in RenderScreenshot
RenderScreenshot uses HMAC-SHA256 in two places, both with lowercase hex output:
- Signed URLs. You sign the sorted, form-encoded query string, including
key_idandexpires, with yourrs_secret_key. The SDKs do it for you; in Node it isclient.generateUrl(options, expiresAt). The signed URLs reference has tested code for six languages. - Webhook signatures. Each delivery carries
X-Webhook-Signature: sha256=<hex>overtimestamp + "." + raw_body, keyed with yourwhsec_secret. The SDK helper checks it and rejects timestamps more than 5 minutes old.
import { verifyWebhook } from 'renderscreenshot'; // Fragment, inside an Express route. payload must be the raw body string, not re-serialized JSON. const ok = verifyWebhook(payload, req.get('X-Webhook-Signature'), req.get('X-Webhook-Timestamp'), process.env.WEBHOOK_SECRET); if (!ok) return res.status(401).send('invalid signature');
The webhook verification guide explains why the raw body matters.
Tested on 2026-10-15 with Node 22.18, Puppeteer 24.43.1 (headless shell, Chrome 148) and Playwright 1.64.1 (Chromium 156) against a local preview server. The RenderScreenshot snippets follow the SDK docs and were not run for this page.
Common mistakes
- Comparing with
===. String comparison stops at the first different character, which leaks timing. UsetimingSafeEqual(Node),hmac.compare_digest(Python),hash_equals(PHP) orRack::Utils.secure_compare(Ruby). - Signing something other than what you send. Re-serialized JSON, re-ordered query parameters or a different URL encoding give a different code.
- No expiry in the signed data. Without one, a leaked link or request works forever.
- Hex on one side, base64 on the other. Both are valid. They never match each other.
Related terms
- Signed URL: a link that carries an HMAC of its own parameters.
- Presigned URL: the storage-bucket version of the same idea.
- Webhook: the callbacks whose bodies HMAC protects.
To sign screenshot URLs for og:image tags, get a free API key.