Skip to content

Verify HMAC webhook signatures in Node, PHP, and Python

Constant-time HMAC verification in three runtimes - with the bugs that get past code review.

Updated 7 min read 1,282 words By Florian Wartner

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.

How does HMAC webhook signing actually work?

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:

text
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. The HMAC construction itself is specified in RFC 2104; SHA-256 as the underlying hash is from FIPS 180-4.

Node (TypeScript)

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:

typescript
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

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:

php
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

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:

python
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"}

Which HMAC verification bugs get past code review?

1. Using == for the signature comparison

javascript
// 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. The mitigation pattern is documented in the OWASP Cryptographic Storage Cheat Sheet and the Node.js crypto.timingSafeEqual docs.

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

javascript
// 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

javascript
// 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

javascript
// 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:

javascript
// 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.

How do you test the verification end-to-end?

bash
# 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.

Related from this desk

Try Formspring webhooks free

Formspring includes industry-standard 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.

From the field

Written by Florian Wartner

Florian Wartner

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

Give your next important form a real home.

Start free with one form. Add ownership, private files, and clear history before responses pile up in inboxes.

·· no card · 50 submissions / mo · no countdown