Home / Blog / Engineering
Engineering · 7 min read · Published 2026-10-21 · OTP Route Engineering

Securing OTP Webhooks with HMAC Signatures and Replay Protection

Threat model for OTP webhook endpoints with HMAC-SHA256 verification, constant-time compare, timestamp tolerance and secret rotation, in Python, Node and Go.

TL;DR — Secure OTP webhooks by verifying the HMAC-SHA256 signature over the timestamp and raw body, comparing in constant time, rejecting stale timestamps, deduplicating events and rotating secrets with an overlap window.

An OTP webhook endpoint receives one-time codes, so anyone who can forge or replay requests to it can inject fake verification results into your system. The defence is to verify every request's HMAC signature against a shared secret before trusting the payload: recompute HMAC-SHA256 over the signed timestamp and the raw request body, compare it to the received value in constant time, and reject requests whose timestamp falls outside a short tolerance window. OTP Route signs every webhook with an X-OTPRoute-Signature header designed for exactly this check.

This guide walks through the threat model, the verification algorithm, production-ready code in Python, Node.js and Go, and the operational practices — rotation, deduplication, network controls — that complete the picture.

Threat model for an OTP webhook endpoint

Start by listing what an attacker could do to a publicly reachable URL that accepts activation.sms_received, activation.expired, activation.cancelled and number.sms_received events.

Threat Example Impact Primary control
Forgery Attacker POSTs a fabricated activation.sms_received with a chosen code Your system acts on a fake code or marks a flow verified HMAC signature verification
Replay Attacker captures a genuine request and resends it later Duplicate processing, stale codes reused Timestamp tolerance plus event deduplication
Tampering Body modified in transit by a misconfigured proxy Wrong code or wrong activation ID processed Signature over the exact raw body
Timing side channel Attacker measures response time of naive string comparison Gradual recovery of a valid signature Constant-time comparison
Secret leakage Secret committed to a repo or printed in logs Attacker can sign arbitrary requests Secret management and rotation
Resource exhaustion Flood of large or malformed requests Receiver degraded, real events delayed Body size limits, rate limits, fast rejection
Information disclosure Verbose error responses Attacker learns validation details Uniform, minimal error responses

Notice that TLS alone addresses none of the first two rows. HTTPS protects the channel, but it does not prove who sent the request. That proof comes from the signature.

How the OTP Route signature works

Every webhook request carries a header in this format:

X-OTPRoute-Signature: t=1792300000,v1=5f2c...e9a1
  • t is a Unix timestamp (seconds) of when the event was signed.
  • v1 is the lowercase hex HMAC-SHA256 of the string "<t>.<raw body>", keyed with your webhook secret.

To verify:

  1. Read the raw request body bytes exactly as received — before any JSON parsing or re-serialisation.
  2. Parse t and v1 from the header.
  3. Reject if |now − t| exceeds your tolerance (for example, five minutes).
  4. Compute HMAC_SHA256(secret, t + "." + raw_body) and hex-encode it.
  5. Compare the computed value with v1 using a constant-time comparison.
  6. Only then parse the JSON and process the event.

Including the timestamp inside the signed string is what makes replay protection work: an attacker cannot change t to a fresh value without invalidating the signature.

Why the raw body matters

The most common verification bug is computing the HMAC over a re-serialised object. JSON serialisers reorder keys, change whitespace and escape characters differently, so the bytes you hash no longer match the bytes that were signed. Every framework has a way to access the raw body; use it.

Verification in Python

import hmac, hashlib, time

TOLERANCE_S = 300

def verify_otproute_signature(raw_body: bytes, header: str, secret: str) -> bool:
    try:
        parts = dict(item.split("=", 1) for item in header.split(","))
        t, v1 = parts["t"], parts["v1"]
        ts = int(t)
    except (ValueError, KeyError):
        return False

    if abs(time.time() - ts) > TOLERANCE_S:
        return False

    signed = t.encode() + b"." + raw_body
    expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, v1)

In Flask, use request.get_data() for the raw bytes; in FastAPI, await request.body(). Return a plain 400 on failure, without explaining which check failed.

Verification in Node.js

import crypto from "node:crypto";
import express from "express";

const TOLERANCE_S = 300;
const SECRET = process.env.OTPROUTE_WEBHOOK_SECRET;

function verify(rawBody, header, secret) {
  const parts = Object.fromEntries(
    (header || "").split(",").map((p) => p.split("=", 2))
  );
  const t = parts.t, v1 = parts.v1;
  if (!t || !v1 || !/^\d+$/.test(t)) return false;
  if (Math.abs(Date.now() / 1000 - Number(t)) > TOLERANCE_S) return false;

  const expected = crypto
    .createHmac("sha256", secret)
    .update(`${t}.`)
    .update(rawBody)
    .digest("hex");

  const a = Buffer.from(expected, "utf8");
  const b = Buffer.from(v1, "utf8");
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

const app = express();
app.post(
  "/hooks/otproute",
  express.raw({ type: "application/json", limit: "64kb" }),
  (req, res) => {
    if (!verify(req.body, req.get("X-OTPRoute-Signature"), SECRET)) {
      return res.sendStatus(400);
    }
    const event = JSON.parse(req.body.toString("utf8"));
    enqueue(event); // process asynchronously, respond fast
    res.sendStatus(200);
  }
);

crypto.timingSafeEqual throws if buffer lengths differ, so check length first. Using express.raw instead of express.json on this route preserves the exact bytes.

Verification in Go

package webhooks

import (
    "crypto/hmac"
    "crypto/sha256"
    "encoding/hex"
    "io"
    "math"
    "net/http"
    "strconv"
    "strings"
    "time"
)

const toleranceSeconds = 300

func Verify(rawBody []byte, header, secret string) bool {
    var t, v1 string
    for _, part := range strings.Split(header, ",") {
        kv := strings.SplitN(part, "=", 2)
        if len(kv) != 2 {
            continue
        }
        switch kv[0] {
        case "t":
            t = kv[1]
        case "v1":
            v1 = kv[1]
        }
    }
    ts, err := strconv.ParseInt(t, 10, 64)
    if err != nil || v1 == "" {
        return false
    }
    if math.Abs(float64(time.Now().Unix()-ts)) > toleranceSeconds {
        return false
    }
    mac := hmac.New(sha256.New, []byte(secret))
    mac.Write([]byte(t + "."))
    mac.Write(rawBody)
    expected := mac.Sum(nil)

    got, err := hex.DecodeString(v1)
    if err != nil {
        return false
    }
    return hmac.Equal(expected, got)
}

func Handler(secret string) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        body, err := io.ReadAll(io.LimitReader(r.Body, 64<<10))
        if err != nil || !Verify(body, r.Header.Get("X-OTPRoute-Signature"), secret) {
            http.Error(w, "bad request", http.StatusBadRequest)
            return
        }
        // parse JSON and enqueue
        w.WriteHeader(http.StatusOK)
    }
}

hmac.Equal performs a constant-time comparison of the decoded bytes.

Constant-time comparison, explained

A naive == on strings typically returns as soon as it finds the first differing character. In principle, an attacker who can send many requests and measure response times precisely can learn how many leading characters of their guess are correct, and build a valid signature one character at a time. Network jitter makes this hard in practice, but the fix costs nothing: always use the language's constant-time primitive (hmac.compare_digest, crypto.timingSafeEqual, hmac.Equal).

Timestamp tolerance and replay protection

The tolerance window is a trade-off:

  • Too tight and legitimate requests fail when your servers' clocks drift or a retry arrives late.
  • Too loose and a captured request stays replayable for longer.

A few minutes is a common choice. Keep your servers synchronised with NTP so clock skew does not eat into the window.

Tolerance alone does not stop a replay inside the window. Add idempotent processing: store a unique key for each processed event — for example, the activation ID combined with the event type — and ignore duplicates. This also protects you from legitimate redelivery, which any webhook sender may perform after a timeout. The webhook-based OTP capture guide shows an idempotent consumer.

Secret management and rotation

Treat the webhook secret like any credential:

  • Store it in your secret manager or CI secret store, never in source control.
  • Never log the secret, the full signature header, or OTP codes at info level.
  • Use separate secrets per environment, so a staging leak cannot forge production events.
  • Keep webhook secrets distinct from API keys. Your or_live_ API keys authenticate your calls to OTP Route; the webhook secret authenticates OTP Route's calls to you.

Zero-downtime rotation

Rotate on a schedule and immediately after any suspected exposure. To avoid dropping events during the switch, make your verifier accept a list of secrets:

def verify_any(raw_body, header, secrets):
    return any(verify_otproute_signature(raw_body, header, s) for s in secrets)

The sequence is:

  1. Deploy your receiver with both the current and the new secret accepted.
  2. Update the webhook secret in the OTP Route developer console.
  3. Use the console's test-send to confirm events verify with the new secret.
  4. After a safe interval, remove the old secret from your receiver.

Consult the API docs for the console's current webhook configuration options.

IP allowlisting: useful, but not a substitute

It is tempting to restrict the webhook route to known source IPs. Treat that as defence in depth, never as the primary control:

  • Sender egress addresses can change with infrastructure updates, and a stale allowlist silently drops real events.
  • Requests often pass through load balancers, CDNs or proxies, so the source IP your application sees may not be the original sender's.
  • Header-based client IPs such as X-Forwarded-For are trivially spoofable unless set by infrastructure you control.

Signature verification works regardless of network topology. If you add network controls, verify signatures anyway.

Operational hardening checklist

  • Serve the endpoint over HTTPS only.
  • Enforce a small request body limit and reject non-JSON content types.
  • Verify before parsing; respond 400 with a generic message on failure.
  • Respond 200 quickly and process asynchronously.
  • Deduplicate events by a stable key.
  • Alert on spikes of signature failures — they indicate misconfiguration or probing.
  • Use a path that is unguessable but not secret-dependent; the signature is the real gate.

Responsible use

Webhook security protects both your users and the integrity of verification flows. OTP Route is intended for QA, privacy-preserving sign-ups, app verification and operational lines; use it within the terms of the platforms you integrate with and never for fraud or abuse.

For the wider integration, see the OTP API integration guide and the developer hub.

Frequently asked questions

What does the X-OTPRoute-Signature header contain?

It contains a Unix timestamp and a signature in the form t=<unix>,v1=<hex>. The v1 value is the hex-encoded HMAC-SHA256 of the timestamp, a period and the raw request body, keyed with your webhook secret.

Why does my signature verification fail even with the right secret?

The most common cause is hashing a parsed and re-serialised JSON body instead of the raw bytes. Make sure your framework gives you the unmodified request body, and that you include the timestamp and the period in the signed string.

Is HTTPS enough to secure an OTP webhook?

No. HTTPS encrypts the connection but does not prove that the request came from OTP Route. Signature verification authenticates the sender and detects tampering, and timestamp checks plus deduplication limit replays.

How do I rotate a webhook secret without losing events?

Configure your receiver to accept both the old and new secrets, update the secret in the developer console, confirm with a test-send, and remove the old secret after a safe interval.

Should I rely on IP allowlisting for webhooks?

Use it only as an additional layer. Source addresses can change and proxies can obscure the real origin, so HMAC signature verification should remain the primary authentication control.

Route your first OTP in minutes

Pay as you go · no monthly fee · automatic refunds on unused activations.

Topics: webhook HMAC verification · secure webhooks · OTP webhook security · HMAC SHA256 signature · webhook replay attack · constant-time compare · webhook secret rotation