HMAC Signature: Definition and How to Verify One

An HMAC signature is a keyed hash that proves a message came from someone with the secret and was not changed. Tested signing and verification code.

By Simon Chiu · Updated

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_id and expires, with your rs_secret_ key. The SDKs do it for you; in Node it is client.generateUrl(options, expiresAt). The signed URLs reference has tested code for six languages.
  • Webhook signatures. Each delivery carries X-Webhook-Signature: sha256=<hex> over timestamp + "." + raw_body, keyed with your whsec_ 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. Use timingSafeEqual (Node), hmac.compare_digest (Python), hash_equals (PHP) or Rack::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.
  • 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.

Frequently asked questions

Is an HMAC signature the same as a digital signature?
No. HMAC uses one shared secret for signing and checking, so anyone who can verify can also sign. A digital signature such as Ed25519 or RSA uses a private key to sign and a public key to verify, so verifiers cannot forge.
Why not just hash the secret and the message together with SHA-256?
sha256(secret + message) is open to length-extension attacks: an attacker who sees one valid hash can compute a valid hash for the message with extra data appended. HMAC's two-pass construction prevents that.
Should the signature be hex or base64?
Either, as long as both sides agree. HMAC-SHA256 gives 32 bytes: 64 characters in hex or 44 in base64. RenderScreenshot signed URLs and webhook signatures use lowercase hex.

Related in this topic

Try RenderScreenshot free

150 free credits every month, no credit card required.