All posts
Developers·· 7 min read

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.

By AtlasForge Engineering

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:

  1. You share a signing secret with the provider (given once at setup).
  2. When the provider sends a webhook, they compute HMAC_SHA256(secret, payload_body) and put it in a header like X-Signature or Stripe-Signature.
  3. Your server receives the webhook, recomputes the HMAC against the raw body, and compares.
  4. 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

  1. HMAC signature verification (above)
  2. Timestamp check — reject webhooks older than 5 minutes to prevent replay attacks
  3. Idempotency key — dedupe based on event ID; providers retry, and you'll get the same event twice
  4. Rate limiting — even signed webhooks should have per-provider rate limits
  5. 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:

  1. Add secret v2 in the provider dashboard. Now both v1 and v2 are valid.
  2. Deploy code that accepts either. Verify both.
  3. 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.