Don't like ads? Go Ad-Free Today

Webhook Signatures Verify Payloads and Stop Spoofed Requests

Updated on

Learn how HMAC-SHA256 webhook signatures work, how to implement constant-time verification in Python, Node.js, and PHP, and the common mistakes that cause silent failures in production.

Webhook Signatures: Verify Payloads and Stop Spoofed Requests 1
ADVERTISEMENT · REMOVE?

Anyone can send a POST request to your webhook endpoint. Without signature verification, your server has no way to know whether that request actually came from Stripe, GitHub, or your own infrastructure — or from an attacker replaying a legitimate event.

Webhook signature verification solves this. It’s how payment processors, version control platforms, and e-commerce systems let you prove authenticity before acting on incoming data. This article walks through how HMAC-SHA256 signatures work, the step-by-step verification pattern, and the subtle mistakes that cause failures in production.

Why Unauthenticated Webhooks Are a Security Risk

A webhook endpoint is just an HTTP handler exposed to the internet. Without verification, any request that hits it gets processed. Two attacks stand out:

  • Spoofing — an attacker crafts a fake payload that looks like a legitimate event (a payment succeeded, a subscription renewed) and triggers action on your end without any real transaction occurring.
  • Replay attacks — a legitimate request is captured in transit and re-submitted later. If your endpoint is idempotent but unprotected, the same event fires multiple times.

Both attacks are prevented by a shared secret combined with a cryptographic signature. The sender signs the payload; you verify the signature before processing anything.

How HMAC-SHA256 Signatures Work

HMAC (Hash-based Message Authentication Code) takes two inputs: a secret key and a message. It runs them through a hash function — SHA-256 in most webhook implementations — and outputs a fixed-length signature.

The key property: the same secret + message always produce the same signature, and changing even a single byte in the message produces a completely different output. Anyone without the secret key cannot produce a valid signature, even if they can see the payload in full.

In practice, the flow looks like this:

  1. You register your webhook with a service (e.g. Stripe). The service gives you a signing secret.
  2. When the service sends an event, it computes HMAC-SHA256(secret, payload) and includes the result in a request header.
  3. Your server receives the request, computes the same HMAC using your stored secret and the raw request body, then compares the two signatures.
  4. If they match, the request is authentic. If they don’t, reject it.

The Verification Pattern, Step by Step

Here’s a Python implementation that mirrors what every major service expects:

import hmac
import hashlib

def verify_signature(payload: bytes, secret: str, received_sig: str) -> bool:
    expected = hmac.new(
        key=secret.encode("utf-8"),
        msg=payload,
        digestmod=hashlib.sha256
    ).hexdigest()
    return hmac.compare_digest(expected, received_sig)

Two things matter here beyond the HMAC call itself. First: payload is bytes, not a decoded string — you must pass the raw request body exactly as received from the network. Second: the comparison uses hmac.compare_digest instead of ==. This is deliberate.

Why Constant-Time Comparison Is Not Optional

String comparison in most languages short-circuits: it returns false the moment it finds a mismatched character. An attacker who can measure response time can exploit this — sending thousands of requests with varying signatures and using timing differences to guess the correct value one character at a time. This is a timing attack.

hmac.compare_digest in Python, hash_equals in PHP, and crypto.timingSafeEqual in Node.js all take the same amount of time regardless of where the strings differ. Use them every time you compare a signature — no exceptions.

Real-World Header Formats: Stripe, GitHub, Shopify

Each service has a slightly different header name and signature encoding. Here’s what to parse for the three most common integrations.

Stripe

Stripe sends a Stripe-Signature header with this format:

Stripe-Signature: t=1492774577,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a05bd539e6d5b9d2a2d2fbe

The t field is the Unix timestamp of when the event was sent. The v1 value is the HMAC-SHA256 of timestamp + "." + raw_body. When implementing manually:

  1. Parse the header to extract t and v1.
  2. Compute HMAC-SHA256(secret, t + "." + raw_body).
  3. Compare with v1 using constant-time equality.
  4. Reject if t is more than 300 seconds (5 minutes) from the current time.

GitHub

GitHub uses the X-Hub-Signature-256 header:

X-Hub-Signature-256: sha256=6ffbb59b2300aae63f272406069a9788598b770f698a48021f99b32f8de06bb3

Strip the sha256= prefix, then compare the remainder against your HMAC of the raw body. GitHub does not embed a timestamp in the signed content, so implement event ID deduplication separately if replay protection matters for your use case.

Shopify

Shopify uses X-Shopify-Hmac-Sha256 with the digest Base64-encoded rather than hex:

X-Shopify-Hmac-Sha256: b6LPiZidmXnJQf0Ff/p7MZQPIBPN9TqAqLMgAfn2YLQ=

Decode both your computed HMAC and the header value from Base64 to raw bytes, then compare with a constant-time function. Comparing the Base64 strings directly can introduce subtle encoding-normalization bugs.

The Most Common Mistake: Raw Body vs. Parsed JSON

This is where most webhook signature verification failures happen in production. When your framework auto-parses the request body into a dict or object before your handler runs, the raw bytes are gone. The HMAC was computed against those original bytes — not against whatever your JSON library produces when it re-encodes the parsed structure.

Even when the data is semantically identical, {"amount": 100} and {"amount":100} (no space after the colon) produce different HMAC values. Field ordering differences, Unicode normalization, and float precision can all silently break verification without any obvious error.

The fix: buffer the raw request body before any parsing happens and pass those bytes to your verification function. In Express.js, register the route with express.raw() middleware rather than express.json():

app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => {
  const rawBody = req.body; // Buffer — not a parsed object
  const sig = req.headers['stripe-signature'];
  // Verify rawBody against sig before JSON.parse()
});

In Django, use request.body (bytes). In Laravel, use $request->getContent(). The pattern is consistent: access the raw bytes directly from the request, never re-serialize a parsed representation.

Replay Protection with Timestamps

A valid signature only proves the payload wasn’t tampered with in transit. It doesn’t prevent an attacker from capturing a legitimate request and re-submitting it hours later — the signature will still verify correctly because nothing has changed.

Stripe addresses this by including a Unix timestamp in the signed payload and recommending a 5-minute tolerance window. After extracting t from the Stripe-Signature header, reject requests where the timestamp falls outside that window:

import time

def is_within_tolerance(timestamp: int, tolerance_seconds: int = 300) -> bool:
    return abs(time.time() - timestamp) <= tolerance_seconds

# In your handler:
if not is_within_tolerance(stripe_timestamp):
    return HttpResponse(status=403)  # Reject stale request

For services like GitHub and Shopify that don't embed timestamps in the signature scheme, implement your own replay protection by storing processed event IDs (available in the payload) and rejecting any ID you've already handled. A short-lived cache or Redis set with a TTL matching your processing window works well.

Verify Signatures Without Writing Code

When debugging a webhook integration — or auditing a payload you've already received — the Webhook Signature Validator lets you paste a payload, secret, and received signature to check verification instantly, without spinning up a local environment.

To generate HMAC signatures for testing your endpoint with crafted payloads, use the HMAC Generator. It produces hex and Base64 outputs for SHA-256, SHA-512, and several other digest algorithms — useful for building test cases that cover the formats used by each service.

Quick Reference

  • HMAC-SHA256 with a shared secret is the standard signature scheme across Stripe, GitHub, Shopify, and most other services.
  • Always use constant-time comparison (hmac.compare_digest, hash_equals, crypto.timingSafeEqual) — not ==.
  • Pass the raw request body bytes to your HMAC function, never a re-serialized JSON object.
  • Check the timestamp on services that include one; Stripe's tolerance window is 5 minutes.
  • Deduplicate by event ID for services without timestamp-based replay protection.
  • Header name and encoding (hex vs Base64) differ by service — always check the integration docs.
Want To enjoy an ad-free experience? Go Ad-Free Today

Install Our Extensions

Add IO tools to your favorite browser for instant access and faster searching

Add to Chrome Extension Add to Edge Extension Add to Firefox Extension Add to Opera Extension

Scoreboard Has Arrived!

Scoreboard is a fun way to keep track of your games, all data is stored in your browser. More features are coming soon!

ADVERTISEMENT · REMOVE?
ADVERTISEMENT · REMOVE?
ADVERTISEMENT · REMOVE?

News Corner w/ Tech Highlights

Get Involved

Help us continue providing valuable free tools

Buy me a coffee
ADVERTISEMENT · REMOVE?