Webhook Signature Verifier With Replay Protection

How I verify Stripe-style webhook signatures and stop someone from re-POSTing yesterday's `invoice.paid`. Stdlib HMAC, a tolerance window, and an idempotency cache that lives in any Redis-shaped store.

Python
Compiler
3 snippets
webhooks
security
authentication
hashing
averyperry

By @averyperry

November 28, 2025

·

Updated May 20, 2026

594 views

10

4.2 (10)

from __future__ import annotations
import hashlib
import hmac
import time

SECRET = b'whsec_replace_me'


def verify_signature(raw_body: bytes, header: str, secret: bytes = SECRET, tolerance_sec: int = 300, now: float = None) -> dict:
    # Header format we mimic: 't=<unix>,v1=<hex>'
    parts = dict(p.split('=', 1) for p in header.split(',') if '=' in p)
    ts_str = parts.get('t')
    sig_hex = parts.get('v1')
    if not ts_str or not sig_hex:
        return {'ok': False, 'reason': 'malformed_header'}
    try:
        ts = int(ts_str)
    except ValueError:
        return {'ok': False, 'reason': 'malformed_timestamp'}
    current = time.time() if now is None else now
    if abs(current - ts) > tolerance_sec:
        return {'ok': False, 'reason': 'outside_tolerance'}
    signed = f'{ts}.'.encode('ascii') + raw_body
    expected = hmac.new(secret, signed, hashlib.sha256).hexdigest()
    if not hmac.compare_digest(expected, sig_hex):
        return {'ok': False, 'reason': 'bad_signature'}
    return {'ok': True, 'timestamp': ts}


if __name__ == '__main__':
    body = b'{"id":"evt_42","type":"invoice.paid"}'
    ts = int(time.time())
    sig = hmac.new(SECRET, f'{ts}.'.encode('ascii') + body, hashlib.sha256).hexdigest()
    header = f't={ts},v1={sig}'
    print('valid:', verify_signature(body, header))

    # Tampered body.
    print('tampered:', verify_signature(body + b' ', header))

    # Stale timestamp (10 minutes old).
    old_ts = ts - 600
    old_sig = hmac.new(SECRET, f'{old_ts}.'.encode('ascii') + body, hashlib.sha256).hexdigest()
    print('stale:', verify_signature(body, f't={old_ts},v1={old_sig}'))

The signed string is <timestamp>.<raw_body>, not the parsed JSON, because re-serializing JSON is the classic way to break a verifier (key order, whitespace, float precision). A 5-minute tolerance is the value Stripe defaults to and is what I keep in my own services because anything tighter starts catching legitimate clock drift between cloud regions. The tolerance check happens before the HMAC check on purpose: an attacker who can spam your endpoint forces you to do real crypto work for free without the timestamp gate. hmac.compare_digest is again the only correct way to compare the two hex strings.