If you wire Stripe, Shopify, GitHub, or any modern SaaS into an automation tool, you are sending signed events over a public wire. The signature is the only thing standing between your workflow and a malicious actor who knows where your endpoint lives. Most small business teams ignore that fact, then discover it the hard way.
HMAC signature verification is the standard fix. Every major automation platform supports it, the implementation is small, and the payoff is real: forged events get rejected, replay attacks fail, and secret rotation lets you reset trust without rebuilding the pipeline. This guide walks through how HMAC works, what Zapier, Make, and n8n each offer, and how to ship verification on a working afternoon.
What the signature actually protects
A webhook is just an HTTP POST. Anyone who knows the URL can send one. Without a signature, your automation has no way to tell whether the event came from the SaaS you wired up or from someone who found the URL in a server log, a leaked screenshot, or a brute-force scan of common paths.
HMAC, short for Hash-based Message Authentication Code, fixes this by adding a second piece of information: a cryptographic stamp that only the legitimate sender and your endpoint can produce. The sender and you share a secret. The sender hashes the request body with that secret and puts the hash in a header. You do the same on your side and compare. If they match, the request is authentic.
Three things follow from this design that matter for automation work:
- Tamper detection. If an attacker modifies the body in transit, the hashes will not match. The request is rejected.
- Replay protection. If you also include a timestamp in the signed payload and reject requests older than a few minutes, an attacker who captures a valid request cannot replay it later.
- No shared secrets on the wire. The secret never leaves either endpoint. It is used to compute the hash but is not transmitted.
The signature is not the same thing as authentication. A signature proves the message came from someone who knows the secret. It does not prove that someone is who you think they are. Keep your secret in environment variables, never in code, and rotate it on a schedule.
Why Zapier's built-in webhook auth is not enough
Zapier offers two options for inbound webhook authentication in a "Webhooks by Zapier" trigger: no auth and custom auth. Custom auth lets you require a header match, often a shared API key sent as a query parameter or header. That stops casual drive-by noise, but it does not stop a determined attacker who captures one valid request.
Here is the specific failure mode. Suppose your Zapier webhook accepts an event like this:
POST /webhook/abc123
Content-Type: application/json
X-Api-Key: sk_live_4e8a...
{"event": "invoice.paid", "customer_id": "cus_xyz", "amount": 4999}
Anyone who obtains sk_live_4e8a... can send arbitrary events forever. There is no way to tell whether the request is fresh, whether the body was modified, or whether it actually came from the SaaS you think it did.
With HMAC, the same workflow looks like this:
POST /webhook/abc123
Content-Type: application/json
X-Signature: t=1752230400,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
{"event": "invoice.paid", "customer_id": "cus_xyz", "amount": 4999}
The header includes a timestamp t and a signature v1. The signature is HMAC_SHA256(secret, "${t}.${raw_body}"). Your verification code recomputes the hash using the same secret, compares, and rejects the request if anything is off. The shared secret never appears on the wire.
How Make handles inbound verification
Make.com (formerly Integromat) exposes a "Custom Webhook" trigger with an optional data structure and no built-in HMAC verification. The platform expects you to validate the signature inside the scenario using built-in functions. The pattern is similar to Zapier but lives in a different place.
The Make scenario receives the raw body and headers, then the first two modules do the verification:
<figure> <img src="/blog/img/webhook-security-hmac-signatures-zapier-make-n8n-2.webp" alt="Vintage tin-toy robot reviewing webhook verification checklist at developer workstation" /> </figure>// Make scenario: webhook trigger -> HMAC verify -> route
// Module 1: Set variables from headers and body
const t = {{1.headers["x-signature-timestamp"]}};
const sig = {{1.headers["x-signature-v1"]}};
const body = {{1.body}}; // raw text
const secret = process.env.WEBHOOK_SECRET;
// Module 2: Compute expected signature
const expected = crypto.createHmac("sha256", secret)
.update(`${t}.${body}`)
.digest("hex");
// Module 3: Router - if sig !== expected, drop the event
{{2.expected}} === {{1.headers["x-signature-v1"]}}
? continue : filter("rejected", "signature mismatch");
The crypto call needs Node.js, which Make supports through a Code module or via an external webhook proxy. A cleaner pattern for non-developers is to put the verification logic in a small Cloudflare Worker or AWS Lambda and have Make call that as a step before processing the event.
Do not try to verify HMAC inside a Make scenario using only built-in functions. The
cryptomodule is not available in the Make expression language. Use a Code module or call out to a small verification endpoint.
n8n: the open-source path with HMAC baked in
n8n is the only one of the three with first-class HMAC support in the webhook node. Open the Webhook node, set Authentication to "Header Auth", and pick "HMAC" from the dropdown. You give it the secret and the header name. n8n verifies the signature before the workflow even starts. If the signature is invalid, the request returns 401 and your workflow never runs.
This is the right default for any team that can run a self-hosted n8n instance. The verification happens at the platform boundary, not inside a Code module you might forget to update. It also means non-developers can ship verified webhooks without touching crypto code.
For teams using n8n Cloud, the same option exists, but the secret is stored in n8n's credential system rather than in an environment variable on your own infrastructure. That is fine for most use cases. If you have compliance requirements (SOC 2, HIPAA, PCI), self-host n8n and keep the secret in your own vault.
A small reference implementation
Here is a self-contained HMAC verification function you can drop into a Cloudflare Worker, AWS Lambda, or any Node.js service. It handles the timestamp window, the constant-time comparison, and the secret rotation pattern in one block.
import crypto from "node:crypto";
const TOLERANCE_SECONDS = 300; // 5 minutes
async function verifyWebhook(req, rawBody) {
const sigHeader = req.headers.get("x-signature") ?? "";
const m = sigHeader.match(/t=(\d+),v1=([a-f0-9]{64})/);
if (!m) return { ok: false, reason: "malformed header" };
const t = parseInt(m[1], 10);
const provided = m[2];
// Reject anything outside the tolerance window (replay protection)
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - t) > TOLERANCE_SECONDS) {
return { ok: false, reason: "timestamp out of window" };
}
// Constant-time compare against any of the active secrets
// (rotation support: pass an array, not a single string)
const secrets = process.env.WEBHOOK_SECRETS?.split(",") ?? [];
const signedPayload = `${t}.${rawBody}`;
const expected = secrets.map((s) =>
crypto.createHmac("sha256", s).update(signedPayload).digest("hex")
);
const match = expected.some((e) =>
crypto.timingSafeEqual(Buffer.from(e), Buffer.from(provided))
);
return match
? { ok: true }
: { ok: false, reason: "signature mismatch" };
}
Three things worth calling out:
- The tolerance window. Five minutes is the standard. Smaller windows are stricter but cause false rejections during legitimate clock drift between sender and receiver.
timingSafeEqual. Do not use===to compare hashes. The===operator short-circuits on the first byte difference, which leaks timing information.crypto.timingSafeEqualruns in constant time.- Array of secrets. The
WEBHOOK_SECRETSenv var is a comma-separated list. The first entry is the current secret, the rest are still-valid older secrets that you have not yet rotated out. This lets you rotate without coordinating a synchronized update on the sender side.
Secret rotation without downtime
The clean rotation pattern is to keep two valid secrets for an overlap window. The sender signs with the new secret. Your endpoint accepts either secret. After the overlap window, the old secret is removed and the sender stops signing with it.
# Day 0: deploy with two secrets (current + new)
WEBHOOK_SECRETS="sk_current_aaa,sk_new_bbb"
# Day 1: sender is now signing with sk_new_bbb, both still accepted
# Day 2 (after overlap window): remove the old secret
WEBHOOK_SECRETS="sk_new_bbb"
The duration of the overlap window depends on how often your sender sends events. For SaaS APIs that fire frequently (Stripe, Shopify), 24 hours is plenty. For slow-event APIs (annual subscriptions, quarterly reports), a longer overlap of a week or more is safer.
The mistake to avoid is rotating the secret in your endpoint at the same instant the sender starts using the new one. Anything in flight during that window will fail verification. The overlap accepts both, so the transition is invisible to in-flight events.
Where most teams get this wrong
Three patterns show up over and over in webhook incidents and bug reports.
Verifying only part of the request. Some teams check the signature against the parsed JSON object instead of the raw body. JSON parsers re-serialize, which changes whitespace and key order. The signature will not match. Verify against the raw bytes the sender signed, not the parsed object.
Skipping the timestamp check. If your verification function only checks the signature and not the t= value, you are vulnerable to replay. An attacker who captures one valid request can send it again tomorrow and it will still pass. Always check the timestamp window.
Logging the signature header. Some teams log request headers for debugging. The signature header is not sensitive in itself, but combined with a captured raw body, it lets an attacker forge new requests for the duration of the secret's validity. Redact the signature header before it hits your log aggregator.
A short checklist before you ship
Before turning on HMAC verification in production, walk through this list. Each item is small and most teams miss at least one.
- The secret is in an environment variable, not in code or in a config file committed to git.
- The verification function runs against the raw body bytes, not the parsed JSON.
- The timestamp window is enforced and is at most 5-10 minutes.
- The comparison uses
timingSafeEqual(or equivalent), not===. - The signature header is redacted from logs.
- A second secret is in place for rotation before the first one expires.
- The rejection path is exercised by a test case that posts a forged payload and confirms the workflow does not fire.
If all seven are true, your webhook is verified. The remaining work is monitoring. Track the rejection rate over time. A sudden spike in signature mismatch errors means either a legitimate sender rotated their secret without telling you, or someone is actively trying to forge events. Both are worth knowing about.
Verification is not the same as authorization. A valid signature tells you the message came from a party that knows the secret. You still need to decide what that party is allowed to do. The signature is the door lock, not the security guard.
Webhook signing is one of those few security practices where the implementation is small and the upside is large. An afternoon of work closes a class of attack that most small business automations leave wide open. Pick the platform you already use, ship the verification function, and rotate your first secret this month. The blast radius of the alternative is much larger than the time investment.