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
tis a Unix timestamp (seconds) of when the event was signed.v1is the lowercase hex HMAC-SHA256 of the string"<t>.<raw body>", keyed with your webhook secret.
To verify:
- Read the raw request body bytes exactly as received — before any JSON parsing or re-serialisation.
- Parse
tandv1from the header. - Reject if
|now − t|exceeds your tolerance (for example, five minutes). - Compute
HMAC_SHA256(secret, t + "." + raw_body)and hex-encode it. - Compare the computed value with
v1using a constant-time comparison. - 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:
- Deploy your receiver with both the current and the new secret accepted.
- Update the webhook secret in the OTP Route developer console.
- Use the console's test-send to confirm events verify with the new secret.
- 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-Forare 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
400with a generic message on failure. - Respond
200quickly 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