Webhook Signature Verification: The 2026 Fintech Security Standard
An unsigned webhook endpoint is a public API anyone can hit. Every fintech that's been breached in the last 3 years had at least one. Here's how to lock them down in an afternoon.
Why signed webhooks matter more in fintech
Your app exposes webhook endpoints — /webhooks/stripe, /webhooks/plaid, /webhooks/atlasforge. Each one accepts POST requests and mutates state (marking payments as complete, connecting bank accounts, triggering transfers).
If those endpoints don't verify the sender, anyone can forge one. An attacker who discovers /webhooks/stripe can POST a fake "payment succeeded" event and unlock premium features without paying. This has happened, publicly, to at least 4 well-known fintechs since 2022.
The fix takes one afternoon. Do it today.
The standard — HMAC-SHA256
Every serious webhook provider (Stripe, Plaid, GitHub, AtlasForge) uses HMAC-SHA256:
- You share a signing secret with the provider (given once at setup).
- When the provider sends a webhook, they compute
HMAC_SHA256(secret, payload_body)and put it in a header likeX-SignatureorStripe-Signature. - Your server receives the webhook, recomputes the HMAC against the raw body, and compares.
- If they match → verified. If they don't → drop the request.
Attackers can't forge the signature without the secret. The secret never leaves your infrastructure.
Python (FastAPI) implementation
import hmac, hashlib, os
from fastapi import FastAPI, Request, HTTPException
app = FastAPI()
WEBHOOK_SECRET = os.environ["ATLAS_WEBHOOK_SECRET"].encode()
def verify_signature(payload: bytes, signature_header: str) -> bool:
if not signature_header:
return False
expected = hmac.new(
WEBHOOK_SECRET, payload, hashlib.sha256
).hexdigest()
# constant-time compare
return hmac.compare_digest(expected, signature_header)
@app.post("/webhooks/atlasforge")
async def atlas_webhook(request: Request):
body = await request.body() # raw bytes — do not re-parse
sig = request.headers.get("x-atlas-signature", "")
if not verify_signature(body, sig):
raise HTTPException(status_code=401, detail="invalid signature")
# safe to parse now
event = await request.json()
...
Critical: compare against the raw body bytes, not a re-serialized JSON string. Every implementation bug we've seen at fintechs traces back to re-serializing the body before hashing.
Node.js (Express) implementation
const express = require("express");
const crypto = require("crypto");
const app = express();
const SECRET = process.env.ATLAS_WEBHOOK_SECRET;
// Use raw body parser for this endpoint
app.post(
"/webhooks/atlasforge",
express.raw({ type: "application/json" }),
(req, res) => {
const sig = req.headers["x-atlas-signature"] || "";
const expected = crypto
.createHmac("sha256", SECRET)
.update(req.body)
.digest("hex");
if (
!crypto.timingSafeEqual(
Buffer.from(expected, "hex"),
Buffer.from(sig, "hex")
)
) {
return res.status(401).send("invalid signature");
}
const event = JSON.parse(req.body.toString());
// handle event...
res.status(200).send("ok");
}
);
The 5 checks every webhook endpoint needs
- HMAC signature verification (above)
- Timestamp check — reject webhooks older than 5 minutes to prevent replay attacks
- Idempotency key — dedupe based on event ID; providers retry, and you'll get the same event twice
- Rate limiting — even signed webhooks should have per-provider rate limits
- Structured logging — log the event_id, signature verification result, and outcome for every webhook
The timestamp check
Attackers who capture a legitimate signed webhook (e.g., via a proxy) can replay it. Prevent this by including a timestamp in the signed payload and rejecting if too old:
import time
MAX_AGE_SECONDS = 300 # 5 minutes
@app.post("/webhooks/atlasforge")
async def atlas_webhook(request: Request):
body = await request.body()
sig = request.headers.get("x-atlas-signature", "")
ts_header = request.headers.get("x-atlas-timestamp", "")
# signature verifies both body and timestamp
signed_payload = f"{ts_header}.".encode() + body
expected = hmac.new(WEBHOOK_SECRET, signed_payload, hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, sig):
raise HTTPException(status_code=401)
# timestamp check
try:
ts = int(ts_header)
except ValueError:
raise HTTPException(status_code=400)
if abs(time.time() - ts) > MAX_AGE_SECONDS:
raise HTTPException(status_code=400, detail="webhook too old")
Secret rotation
Rotate signing secrets quarterly in production. Providers support multiple active secrets during rotation:
- Add secret v2 in the provider dashboard. Now both v1 and v2 are valid.
- Deploy code that accepts either. Verify both.
- After 24 hours, disable v1.
Never rotate without an overlap window. You will drop legitimate webhooks otherwise.
AtlasForge's implementation
Our webhook system uses HMAC-SHA256 with timestamp binding. See the Developer Docs for the full signing spec, sample code in 6 languages, and a Postman collection for testing.
Related reading:
Ready to build on AtlasForge?
Get sandbox API keys in 60 seconds — or install the Safe to Spend app.
