A webhook endpoint is intentionally public. Stripe, GitHub, Slack, or Shopify must be able to send a POST request to it without a browser session. That makes a route such as /api/webhooks/stripe an attractive target: if your handler trusts any JSON it receives, an attacker can invent a successful payment, forge a repository event, trigger an internal workflow, or flood your queue with fake work.
A secret URL is not authentication. The endpoint is secure only when it verifies the provider's cryptographic signature over the exact request, rejects unverifiable traffic before side effects, and handles valid deliveries safely when they arrive more than once.
TL;DR
- Capture the raw, unmodified request body before a JSON or form parser consumes it.
- Build the exact signed message documented by the provider; there is no universal webhook-signature format.
- Use the endpoint's webhook signing secret, not an API key, OAuth token, or client secret unless the provider explicitly specifies that secret.
- Validate the header's syntax and byte length, then use a constant-time comparison or the provider's official SDK.
- Apply a freshness window only when the timestamp is part of the signed message, and always make processing idempotent.
- Parse JSON and perform business validation aftersignature verification succeeds.
Reconstruct and inspect an HMAC without exposing production data.
The CodeAva Webhook Signature Verifier & HMAC Playground helps you test the raw payload, secret, algorithm, signed-message framing, and output encoding used by Stripe, GitHub, Slack, or Shopify. Use a synthetic payload and a test secret when debugging.
Open the Webhook Signature VerifierWhat a valid webhook signature proves—and what it does not
HMAC combines a shared secret with a message to produce a digest. If only your application and the provider know the secret, a matching HMAC is strong evidence that the holder of that secret signed those exact bytes and that the signed bytes were not modified afterward.
That guarantee is deliberately narrow. A signature does not tell you whether the event is new, whether it has already been processed, or whether your application should grant access. A genuine payment_intent.succeeded event still needs business checks: verify the expected account, currency, amount, object state, and the relationship between the provider object and your own customer record.
| Control | Question it answers | Typical implementation |
|---|---|---|
| Signature verification | Did a party with the signing secret authenticate this exact payload? | Provider SDK or HMAC over the documented signed message |
| Freshness check | Was a timestamped request sent recently? | Reject old timestamps only when the timestamp is signed |
| Deduplication | Have we accepted this event or delivery before? | Unique database constraint on a provider event or delivery ID |
| Business authorization | Is this authentic event allowed to cause this action? | Account, amount, state, tenant, and ownership checks |
The fatal mistake: hashing parsed JSON instead of the raw body
The most common webhook bug is easy to miss. A framework parses the incoming JSON into an object. The handler then calls JSON.stringify(req.body) and computes an HMAC. The object still looks identical in a log, yet the signature never matches.
The provider did not sign a JavaScript object. It signed a sequence of bytes. Parsing and serializing can change insignificant JSON details that are cryptographically significant: spaces, line breaks, escape sequences, numeric representation, or the order in which properties appeared in the original document. The resulting JSON may mean the same thing, but it is not the same message.
Read the body once
request.json()and then request.text() does not recover the original payload; the stream has already been consumed. Capture bytes or text first, verify them, and only then parse that captured value.Next.js App Router: capture bytes before parsing
A Route Handler receives a Web Request. Use request.arrayBuffer() when your verification code accepts bytes, or request.text() when the provider SDK expects the original UTF-8 text. Do not call request.json() first.
export async function POST(request: Request) {
const rawBody = Buffer.from(await request.arrayBuffer());
const signature = request.headers.get("x-hub-signature-256");
if (!signature || !verifyGitHub(rawBody, signature)) {
return new Response("Invalid signature", { status: 401 });
}
const event = JSON.parse(rawBody.toString("utf8"));
// Deduplicate and enqueue only after verification.
return new Response(null, { status: 204 });
}Express: put the raw parser before the JSON parser
Middleware order is part of the security boundary. Register a route-specific raw parser before a global express.json()middleware. Choose the expected content type carefully; Slack may send form-encoded requests in addition to JSON depending on the product.
import express from "express";
const app = express();
app.post(
"/webhooks/stripe",
express.raw({ type: "application/json", limit: "1mb" }),
stripeWebhookHandler,
);
// Register the general JSON parser after the webhook route.
app.use(express.json({ limit: "1mb" }));If your application architecture requires the global parser first, use a supported parser hook to retain a raw-body copy, and test that the captured bytes are identical to what arrived at the edge. Avoid undocumented properties added by a framework plugin: upgrades can silently remove them.
Stripe, GitHub, Slack, and Shopify use different formats
All four platforms use HMAC-SHA256 in the flows covered here, but the signed message, header syntax, output encoding, and replay controls are not interchangeable.
| Provider | Signature header | Signed message | Encoding | Replay control |
|---|---|---|---|---|
| Stripe | Stripe-Signature | timestamp + "." + rawBody | Hex v1 | Signed timestamp tolerance plus event-ID idempotency |
| GitHub | X-Hub-Signature-256 | Raw body | sha256= plus hex | X-GitHub-Delivery deduplication |
| Slack | X-Slack-Signature | v0:timestamp:rawBody | v0= plus hex | Signed timestamp, normally five-minute window, plus idempotency |
| Shopify | X-Shopify-Hmac-SHA256 | Raw body | Base64 | X-Shopify-Webhook-Id deduplication |
Stripe signature verification
Stripe's Stripe-Signature header contains a Unix timestamp in t= and one or more signatures such as v1=. Stripe signs the timestamp, a period, and the raw payload. Multiple v1 values can appear during secret rotation, so hand-written code that keeps only the first key-value pair can reject a valid delivery.
Prefer Stripe's official SDK. It parses the compound header, computes the expected signature, compares eligible signatures, and applies its timestamp tolerance. Supply the endpoint-specific secret beginning with whsec_; a webhook secret shown by the Stripe CLI is not automatically the same as the secret for a Dashboard-managed endpoint.
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function POST(request: Request) {
const rawBody = await request.text();
const signature = request.headers.get("stripe-signature");
if (!signature) {
return new Response("Missing Stripe signature", { status: 400 });
}
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
rawBody,
signature,
process.env.STRIPE_WEBHOOK_SECRET!,
);
} catch {
return new Response("Invalid Stripe signature", { status: 400 });
}
// Insert event.id under a unique constraint and enqueue the work.
await acceptStripeEvent(event);
return new Response(null, { status: 204 });
}Stripe's default library tolerance is commonly 300 seconds. Do not set the tolerance to zero to disable freshness checks. Ensure your servers have synchronized clocks. Stripe generates a new signature and timestamp for each retry, so a timestamp check does not replace deduplication by event.id.
GitHub signature verification
GitHub computes HMAC-SHA256 over the raw payload and sends it in X-Hub-Signature-256 as sha256= followed by 64 hexadecimal characters. Configure a high-entropy secret for the webhook. Do not use the legacy SHA-1 X-Hub-Signature header when the SHA-256 header is available.
import { createHmac, timingSafeEqual } from "node:crypto";
function verifyGitHub(rawBody: Buffer, header: string): boolean {
const match = /^sha256=([0-9a-f]{64})$/i.exec(header);
if (!match) return false;
const expected = createHmac(
"sha256",
process.env.GITHUB_WEBHOOK_SECRET!,
).update(rawBody).digest();
const received = Buffer.from(match[1], "hex");
return (
received.length === expected.length &&
timingSafeEqual(received, expected)
);
}GitHub's standard SHA-256 signature does not include a signed timestamp. Record the globally unique X-GitHub-Deliveryvalue and reject or safely acknowledge duplicates. Also validate the expected X-GitHub-Event before routing the parsed payload to event-specific code.
Slack signature verification
Slack sends X-Slack-Request-Timestamp and X-Slack-Signature. For version 0, construct the base string from v0:, the timestamp, another colon, and the exact raw body. Reject stale timestamps before doing expensive work, then compare the expected v0= signature.
import { createHmac, timingSafeEqual } from "node:crypto";
function verifySlack(
rawBody: Buffer,
timestampHeader: string,
signatureHeader: string,
): boolean {
if (!/^\d+$/.test(timestampHeader)) return false;
const timestamp = Number(timestampHeader);
const now = Math.floor(Date.now() / 1000);
if (!Number.isSafeInteger(timestamp) || Math.abs(now - timestamp) > 300) {
return false;
}
const base = Buffer.concat([
Buffer.from("v0:" + timestampHeader + ":", "utf8"),
rawBody,
]);
const digest = createHmac(
"sha256",
process.env.SLACK_SIGNING_SECRET!,
).update(base).digest("hex");
const expected = Buffer.from("v0=" + digest, "utf8");
const received = Buffer.from(signatureHeader, "utf8");
return (
received.length === expected.length &&
timingSafeEqual(received, expected)
);
}Use the signing secret from the Slack app's Basic Information page, not the older verification token. Some Slack flows require a fast response or an immediate challenge response; verification must still happen first.
Shopify signature verification
Shopify sends a Base64-encoded HMAC-SHA256 digest in X-Shopify-Hmac-SHA256. The message is the raw request body, and the key is the app's client secret. Hex and Base64 representations of the same digest are different strings, so copying a GitHub-style hex comparison is a common failure.
import { createHmac, timingSafeEqual } from "node:crypto";
function verifyShopify(rawBody: Buffer, header: string): boolean {
const expectedBase64 = createHmac(
"sha256",
process.env.SHOPIFY_CLIENT_SECRET!,
).update(rawBody).digest("base64");
const expected = Buffer.from(expectedBase64, "utf8");
const received = Buffer.from(header, "utf8");
return (
received.length === expected.length &&
timingSafeEqual(received, expected)
);
}Shopify can deliver the same webhook more than once. Persist X-Shopify-Webhook-Id and make the database write idempotent. Verify the topic and shop-domain headers against the installation you expect, but do that in addition to—not instead of—the HMAC check.
Constant-time comparison without length bugs
A direct equality check such as computed === received is not designed for comparing secrets. Its execution can depend on where two values first differ. A constant-time primitive reduces that signal by examining the full equal-length input.
Node.js's crypto.timingSafeEqual requires byte arrays of identical length and throws if they differ. Because the header is attacker-controlled, validate its format first. Decode hex or Base64 deliberately, compare byte lengths, and only then call the primitive. The surrounding code, error paths, and response behavior can still introduce timing variation, so a timing-safe primitive is necessary but not magical.
Do not compare unrelated encodings
Replay protection and idempotency are separate requirements
An attacker who captures a valid request may resend it without changing a byte. The HMAC remains valid because it is a genuine signed message. Providers also retry legitimate deliveries when your endpoint times out or returns an error. The endpoint therefore needs two layers after signature verification.
- Freshness: when a provider includes a timestamp in the signed message, reject requests outside its documented tolerance. Never trust an unsigned timestamp header for security.
- Idempotency:insert the provider's stable event or delivery ID into a table with a unique constraint. If the insert conflicts, acknowledge the duplicate without repeating the side effect.
Make the deduplication record and the durable enqueue or state change atomic when possible. An in-memory set is insufficient across restarts, multiple instances, and regional failover. Also assume events can arrive out of order: fetch current provider state when ordering matters rather than treating arrival order as truth.
A production-safe webhook processing flow
Keep the synchronous edge path short. Verification must happen before acknowledgment, but slow business work usually belongs in a durable worker.
- Require
POST, enforce HTTPS, and apply a conservative body-size limit. - Capture the raw body and required headers without parsing or logging secrets.
- Verify the provider-specific signature and signed timestamp, if one exists.
- Parse the body with strict error handling and validate the expected event schema.
- Authorize the business action: tenant, account, object, amount, currency, state, and ownership must match your records.
- Deduplicate the provider event ID and persist or enqueue the accepted event transactionally.
- Return a successful status quickly, then perform retryable work in a background consumer.
Return a consistent client error for missing or invalid signatures and avoid revealing which portion matched. Monitor signature failures, parser failures, duplicate rates, queue latency, and handler outcomes as separate metrics. Never log signing secrets; consider payload sensitivity before logging raw bodies.
Common webhook verification failures
| Symptom | Likely cause | Fix |
|---|---|---|
| Every delivery fails | Wrong endpoint secret, parsed body, or incorrect signed-message framing | Recheck secret source, raw bytes, header name, delimiter, and encoding |
| Local test passes; production fails | Proxy, edge runtime, middleware, or platform adapter transforms or consumes the body | Capture a safe hash and byte length at ingress and handler, then compare the pipeline |
| Only old deliveries fail | Expected freshness-window rejection or clock drift | Synchronize clocks; keep a bounded tolerance; generate a fresh test delivery |
| Comparison throws | Unequal buffers or invalid header encoding | Validate syntax and length before timing-safe comparison |
| Valid events run twice | Provider retry or deliberate replay; no durable idempotency | Add a unique provider-event key and idempotent side effects |
Secrets, rotation, and network controls
Keep signing secrets in a managed secret store or protected deployment environment, with separate values for development, staging, production, providers, and endpoints. Restrict who can read and rotate them. A webhook secret is not a general API credential, and hardcoding it in a repository or test fixture makes every clone a potential compromise.
Rotation behavior differs by provider. Some send multiple signatures during an overlap; others require your verifier to accept an old and a new secret briefly. Design the verifier for an explicit, time-bounded overlap, record which key version verified the request, and remove the retired secret promptly. Do not retry every secret forever.
HTTPS remains mandatory. It prevents intermediaries from reading the payload or signature and protects response traffic; see the guide to TLS certificate chain errors if a provider cannot establish trust. IP allowlists can reduce noise when a provider publishes stable ranges, but cloud egress ranges change and proxies obscure source addresses. Treat network filtering as defense in depth.
Payment webhooks and PCI DSS: avoid the blanket claim
For US, UK, Canadian, and other organizations processing payment data, an unverified payment webhook is an obvious security and fraud risk. But it is inaccurate to say that omitting a webhook HMAC automatically violates one universal PCI DSS webhook rule. PCI DSS provides baseline technical and operational requirements for systems that store, process, transmit, or can affect the security of payment account data. Your scope, architecture, responsibilities, and compensating controls determine which requirements apply.
From an engineering perspective, authenticate every payment event, preserve its integrity, authorize the business action, restrict secret access, log security-relevant failures, and test the control. Ask your qualified security or compliance assessor to map that design to the applicable standard rather than using a blog post as a compliance determination.
Test the failure paths, not only the happy path
A useful automated suite starts with a known raw payload and test secret, computes a valid provider-format signature, and then changes one variable at a time:
- one payload byte changes;
- the signature is missing, truncated, malformed, or wrong length;
- hex case or Base64 padding is handled according to the provider;
- the timestamp is valid, stale, in the future, or non-numeric;
- multiple Stripe signatures appear in the header during rotation;
- the same event ID arrives concurrently at two application instances;
- the queue or database fails after verification but before response;
- the handler receives the provider's maximum expected body size.
Use provider CLIs or test-delivery features for end-to-end testing. For low-level diagnosis, paste a synthetic body and test secret into the CodeAva Webhook Signature Verifier & HMAC Playground to see whether the mismatch comes from framing, secret, algorithm, or output encoding. Never paste a live production secret or sensitive customer payload into any debugging tool.
Audit your webhook handler before production
The HMAC calculation is usually only a few lines; the surrounding framework code is where vulnerabilities appear. Paste a focused handler into the CodeAva Code Audit and review it for body parsing before verification, hardcoded secrets, missing length checks, direct string equality, side effects before authentication, and absent error handling. A code review supports—but does not replace—provider test deliveries and integration tests against your deployed route.
Zero-trust webhook checklist
- Capture the exact raw body before any JSON, form, compression, or character-encoding transformation.
- Read the signature and timestamp from the documented headers and reject missing or malformed values.
- Use the correct endpoint signing secret and provider-specific signed message, algorithm, version, and encoding.
- Prefer the official SDK; otherwise validate length and compare with a constant-time primitive.
- Enforce timestamp drift only when the timestamp is cryptographically covered. Five minutes is not a universal rule for every provider.
- Parse and schema-validate only after signature verification succeeds.
- Check account, tenant, amount, currency, object state, and ownership before business side effects.
- Deduplicate event or delivery IDs in durable storage and make handlers safe under concurrency and retries.
- Acknowledge accepted events quickly and move slow work to a durable queue.
- Store and rotate secrets safely; never log them or include them in client-side code.
- Monitor invalid signatures, stale requests, duplicates, processing failures, and queue delay separately.
Treat every webhook request as hostile until the signature is proven. Then treat it as authentic input—not automatically authorized, fresh, unique, or safe. That distinction is what turns a working webhook into a production-grade security boundary.



