How to verify HMAC webhook signatures in Node, PHP, and Python
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:
- Pulls the timestamp
tand signaturev1out of the header. - Concatenates
t.rawBody(the literal request body, exactly as bytes-on-the-wire). - Computes
HMAC-SHA256(secret, t.rawBody). - Compares your result to
v1in constant time. - Optionally rejects requests where
tis 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.
Related posts
File uploads from HTML forms without S3 keys
The four ways to handle file uploads from a static-site form. Tradeoffs, code, and why most teams pick option 4.
Honeypot vs reCAPTCHA vs hCaptcha: form spam protection compared
Three approaches to stopping form spam, with honest tradeoffs on accuracy, accessibility, privacy, and user friction.
Webhook retry strategies: exponential backoff explained
How webhook senders should retry failed deliveries — exponential backoff math, jitter, idempotency keys, and the bugs that ruin good intentions.