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:
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. The HMAC construction itself is specified in RFC 2104; SHA-256 as the underlying hash is from FIPS 180-4.
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"}
Which HMAC verification bugs 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. 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
// 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.
How do you test the verification end-to-end?
# 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
- Webhook idempotency: handling duplicate deliveries safely - what to do after the signature passes, so duplicate retries don't double-fire.
- Webhook retry strategies: exponential backoff explained - sender-side retry math, with the four bugs that turn a good schedule into a thundering herd.
- Why webhook deliveries fail and how to replay them safely - the five failure modes and the replay flow that doesn't make things worse.
- Form submission automations: routing, enrichment, follow-up - what to wire up on the consumer once delivery is reliable.
- Webhook configuration reference: /docs/webhooks/overview and the form backend overview.
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.
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.
Elsewhere