How to verify HMAC webhook signatures in Node, PHP, and Python

Florian Wartner2026-05-07 6 min read

A webhook receiver that doesn't verify signatures is a public POST endpoint. Anyone can send anything to it. The fix is HMAC verification - but the most common implementations are subtly broken in ways that get past code review.

This post shows how to do it correctly in Node, PHP, and Python, with the four bugs you should actively check for.

What HMAC signing actually does

When Formspring (or Stripe, or any well-designed sender) fires a webhook, it computes a hash over your payload + a timestamp + a secret only you and the sender know. It sends:

POST /your-webhook
Content-Type: application/json
X-Formspring-Signature: t=1715090123,v1=8e2c...sha256-hex

{"submission_id":"…","data":{…}}

Your receiver:

  1. Pulls the timestamp t and signature v1 out of the header.
  2. Concatenates t.rawBody (the literal request body, exactly as bytes-on-the-wire).
  3. Computes HMAC-SHA256(secret, t.rawBody).
  4. Compares your result to v1 in constant time.
  5. Optionally rejects requests where t is more than ~5 minutes old (replay protection).

If all five succeed, the request is from the legitimate sender, hasn't been tampered with, and isn't a replay.

Node (TypeScript)

import { createHmac, timingSafeEqual } from 'crypto';

interface VerifyOpts {
  rawBody: string;
  headerValue: string;
  secret: string;
  toleranceSeconds?: number;
}

export function verifyFormspringSignature(opts: VerifyOpts): boolean {
  const { rawBody, headerValue, secret, toleranceSeconds = 300 } = opts;

  const parts = Object.fromEntries(
    headerValue.split(',').map((p) => p.split('=', 2)),
  ) as Record<string, string>;

  const t = parseInt(parts.t ?? '', 10);
  const v1 = parts.v1 ?? '';
  if (!t || !v1) return false;

  // Replay protection
  const now = Math.floor(Date.now() / 1000);
  if (Math.abs(now - t) > toleranceSeconds) return false;

  const expected = createHmac('sha256', secret)
    .update(`${t}.${rawBody}`)
    .digest('hex');

  // Constant-time compare; timingSafeEqual throws on length mismatch
  if (expected.length !== v1.length) return false;
  return timingSafeEqual(Buffer.from(expected), Buffer.from(v1));
}

In an Express handler:

import express from 'express';

const app = express();

// CRITICAL: capture rawBody BEFORE any JSON parser runs
app.use((req, res, next) => {
  let data = '';
  req.setEncoding('utf8');
  req.on('data', (c) => (data += c));
  req.on('end', () => {
    (req as any).rawBody = data;
    next();
  });
});

app.post('/webhook', (req, res) => {
  const ok = verifyFormspringSignature({
    rawBody: (req as any).rawBody,
    headerValue: req.header('X-Formspring-Signature') ?? '',
    secret: process.env.FORMSPRING_WEBHOOK_SECRET ?? '',
  });
  if (!ok) return res.status(401).end();

  const payload = JSON.parse((req as any).rawBody);
  // process…
  res.status(200).end();
});

PHP

function verifyFormspringSignature(string $rawBody, string $headerValue, string $secret, int $toleranceSeconds = 300): bool {
    $params = [];
    foreach (explode(',', $headerValue) as $part) {
        if (! str_contains($part, '=')) continue;
        [$k, $v] = explode('=', $part, 2);
        $params[$k] = $v;
    }

    $t = isset($params['t']) ? (int) $params['t'] : 0;
    $v1 = $params['v1'] ?? '';
    if (! $t || ! $v1) return false;

    if (abs(time() - $t) > $toleranceSeconds) return false;

    $signedPayload = "{$t}.{$rawBody}";
    $expected = hash_hmac('sha256', $signedPayload, $secret);

    return hash_equals($expected, $v1);
}

In a Laravel route:

Route::post('/webhook', function (Request $request) {
    $ok = verifyFormspringSignature(
        $request->getContent(),
        $request->header('X-Formspring-Signature') ?? '',
        config('services.formspring.secret'),
    );
    abort_unless($ok, 401);

    $payload = $request->json()->all();
    // process…
    return response('', 200);
});

Python

import hashlib
import hmac
import time
from typing import Optional


def verify_formspring_signature(
    raw_body: bytes,
    header_value: str,
    secret: str,
    tolerance_seconds: int = 300,
) -> bool:
    parts = dict(p.split("=", 1) for p in header_value.split(",") if "=" in p)

    try:
        t = int(parts.get("t", ""))
    except ValueError:
        return False
    v1 = parts.get("v1", "")
    if not t or not v1:
        return False

    if abs(int(time.time()) - t) > tolerance_seconds:
        return False

    signed_payload = f"{t}.{raw_body.decode('utf-8')}".encode("utf-8")
    expected = hmac.new(secret.encode(), signed_payload, hashlib.sha256).hexdigest()

    return hmac.compare_digest(expected, v1)

In a FastAPI handler:

from fastapi import FastAPI, Header, Request, HTTPException
import os

app = FastAPI()


@app.post("/webhook")
async def webhook(request: Request, x_formspring_signature: str = Header(...)):
    raw_body = await request.body()
    ok = verify_formspring_signature(
        raw_body,
        x_formspring_signature,
        os.environ["FORMSPRING_WEBHOOK_SECRET"],
    )
    if not ok:
        raise HTTPException(status_code=401)

    payload = await request.json()
    # process…
    return {"status": "ok"}

The four bugs that get past code review

1. Using == for the signature comparison

// WRONG
return expected === v1;

This is timing-attack-vulnerable. A === comparison short-circuits at the first mismatched character, leaking timing information that lets attackers brute-force the signature one byte at a time.

Use timingSafeEqual (Node), hash_equals (PHP), hmac.compare_digest (Python), or your language's constant-time-compare equivalent.

2. Parsing the request body before computing the signature

// WRONG
app.use(express.json());
app.post('/webhook', (req, res) => {
  const expected = createHmac('sha256', secret).update(JSON.stringify(req.body)).digest('hex');
  // …
});

The signature was computed over the raw bytes the sender sent. Once you JSON.parse and JSON.stringify, you've reordered keys, normalized whitespace, and re-encoded numbers - your hash will never match.

Always capture the raw body before JSON parsing. In Express, express.raw({ type: 'application/json' }) works. In Laravel, $request->getContent() returns the raw body. In FastAPI, await request.body() returns bytes.

3. Skipping the timestamp check

// WEAK: works for tampering, fails for replay attacks
if (expected !== v1) return false;

If you don't check the timestamp, an attacker who captures one valid webhook can replay it indefinitely. The t parameter exists specifically to prevent this. A 5-minute tolerance is conventional.

4. Trusting the Content-Type to compute the body

// WRONG
const expected = createHmac('sha256', secret).update(req.body.toString('utf8')).digest('hex');

Buffer.toString() defaults to UTF-8 in Node, but if the sender used a different encoding, your hash diverges. Use the raw bytes directly:

// RIGHT
const expected = createHmac('sha256', secret).update(req.rawBody).digest('hex');

In PHP, $request->getContent() returns a string of bytes - fine to pass directly. In Python, pass the raw bytes to hmac.new.

Test it

# Generate a test signature
node -e "
const { createHmac } = require('crypto');
const t = Math.floor(Date.now() / 1000);
const body = '{\"test\":true}';
const sig = createHmac('sha256', 'YOUR_SECRET').update(\`\${t}.\${body}\`).digest('hex');
console.log(\`X-Formspring-Signature: t=\${t},v1=\${sig}\`);
console.log(body);
"

# Curl your endpoint
curl -X POST https://your-app.example/webhook \
  -H 'Content-Type: application/json' \
  -H 'X-Formspring-Signature: t=…,v1=…' \
  -d '{"test":true}'

You should get a 200. Now flip a byte in the body and resend - you should get a 401.

Try Formspring webhooks free

Formspring includes Stripe-pattern HMAC-signed webhooks on every paid plan. Sign up free, create a form, add a webhook URL, and start receiving signed deliveries. Your secret is in the dashboard.

Florian Wartner

Founder of Formspring and Pixel & Process. Senior Laravel and Vue engineer based in Lübeck, Germany. Building developer-first SaaS with EU data residency and honest pricing.

Ship your form in two minutes.

No credit card. 50 free submissions a month, every month.