DEV Community

SMSRoute Dev
SMSRoute Dev

Posted on

Your Inbound SMS Webhook Is a Public Endpoint: HMAC Verification, Replay, and Out-of-Order Delivery

The endpoint you forgot is on the internet

Inbound SMS and delivery-status callbacks arrive as HTTP POSTs to a URL you registered with your provider. That URL is reachable by anyone who guesses or discovers it. It is not protected by your session middleware, it usually sits outside your authenticated API surface, and in most codebases it is the one route with the least scrutiny.

A representative unprotected handler:

@app.post("/webhooks/sms")
def inbound():
    payload = request.json
    if payload["text"].strip().upper() == "STOP":
        suppress(payload["from"])
    return "", 200
Enter fullscreen mode Exit fullscreen mode

Anyone who can reach this can suppress any number in your database by POSTing a forged payload. No credentials required. The same pattern applied to a status callback lets an attacker mark messages delivered that never arrived, or trigger your retry path for messages that did.

Three defects need fixing, and they are independent: the payload is unauthenticated, a captured request can be replayed indefinitely, and callbacks do not arrive in the order the events happened.

Verifying the signature

Providers sign webhook bodies with a shared secret, typically HMAC-SHA256 over the raw request body, sometimes with the timestamp or URL prefixed. The header name varies; the mechanism rarely does.

Four things go wrong in implementations, and the first two are subtle enough to pass every test you are likely to write.

Comparing with ==. String equality short-circuits on the first differing byte, so comparison time leaks how many leading bytes were correct. That is a practical oracle for forging a signature byte by byte over many requests. Use a constant-time comparison.

Signing the parsed body instead of the raw bytes. If you verify against a re-serialized version of the parsed JSON, any difference in key order, whitespace, or unicode escaping produces a different digest. Worse, some frameworks let you re-serialize into a form that matches while the original bytes contained something else entirely. Capture the raw body before parsing.

import hmac, hashlib, time

MAX_SKEW = 300  # seconds

def verify(raw_body: bytes, header_sig: str, header_ts: str, secret: bytes) -> bool:
    # 1. reject stale or future-dated requests before doing any work
    try:
        ts = int(header_ts)
    except (TypeError, ValueError):
        return False
    if abs(time.time() - ts) > MAX_SKEW:
        return False

    # 2. sign exactly what the provider signed: timestamp + raw bytes
    signed = f"{ts}.".encode() + raw_body
    expected = hmac.new(secret, signed, hashlib.sha256).hexdigest()

    # 3. constant-time comparison
    return hmac.compare_digest(expected, header_sig or "")
Enter fullscreen mode Exit fullscreen mode

In Flask the raw body is request.get_data(); in Express you need express.raw({type: '*/*'}) on that route specifically, because express.json() consumes the stream and leaves you unable to recover the original bytes.

Verifying after parsing. If your JSON parser runs before verification, you have handed unauthenticated input to a parser. Verify first, parse second.

Falling open on a missing header. if sig and not verify(...) skips verification entirely when the header is absent, which is exactly the request an attacker sends. Treat a missing signature as a failed signature.

The timestamp window does not stop replay

A valid signed request stays valid for as long as your skew window allows. If an attacker captures one — from a log aggregator, an error report, a proxy, a misconfigured APM trace — they can resend it verbatim, and it will verify correctly every time within those five minutes.

For an inbound STOP that is a nuisance. For a status callback that advances a state machine or triggers billing, it is a real problem.

The fix is to make each delivery single-use. Providers send a unique event or message identifier; record the ones you have processed and reject repeats.

REPLAY_TTL = 600   # must exceed MAX_SKEW, with margin

def claim_once(event_id: str) -> bool:
    """True the first time this event_id is seen, False on every repeat."""
    # SET NX is atomic: no check-then-act race between concurrent deliveries
    return bool(redis.set(f"wh:{event_id}", "1", nx=True, ex=REPLAY_TTL))

@app.post("/webhooks/sms")
def inbound():
    raw = request.get_data()
    if not verify(raw, request.headers.get("X-Signature"),
                  request.headers.get("X-Timestamp"), SECRET):
        return "", 403
    event = json.loads(raw)
    if not claim_once(event["id"]):
        return "", 200          # already handled; acknowledge, do not reprocess
    process(event)
    return "", 200
Enter fullscreen mode Exit fullscreen mode

Two details matter. The TTL must be longer than the skew window, or a request can expire from the replay cache while its signature is still valid. And the claim must be atomic — a GET followed by a SET has a window where two concurrent deliveries of the same event both see "not present" and both process. SET NX collapses that to one winner.

Note that returning 200 for a duplicate is correct. The provider is telling you about an event; you have already recorded it. Returning an error would trigger their retry logic for something you handled successfully.

Callbacks arrive out of order, and the fix is not a queue

This is the failure that survives every security control above, because nothing is forged — the messages are genuine, they simply arrive in the wrong sequence.

A message's lifecycle produces several callbacks: accepted, sent, delivered. These are independent HTTP requests, frequently from different workers on the provider's side, traversing different network paths, subject to independent retries. There is no ordering guarantee. delivered overtaking sent is routine, not exotic.

The naive handler corrupts state on every reorder:

def on_status(event):
    db.execute("UPDATE messages SET status = ? WHERE id = ?",
               (event["status"], event["message_id"]))   # last writer wins
Enter fullscreen mode Exit fullscreen mode

If delivered arrives and then a delayed sent lands behind it, the message reverts to sent and stays there. Your dashboard under-reports delivery, and any logic keyed on the terminal state — billing, retries, user notification — misfires.

Ordering by timestamp helps but does not close it, because provider clocks are not synchronized across workers and events can share a timestamp. The robust approach is to encode the state machine's own ordering and refuse backward transitions:

RANK = {"queued": 0, "accepted": 1, "sent": 2, "delivered": 3,
        "failed": 3, "undelivered": 3}   # terminal states share the top rank

def on_status(event):
    new = event["status"]
    db.execute(
        """UPDATE messages
              SET status = ?, status_rank = ?, updated_at = ?
            WHERE id = ?
              AND status_rank < ?""",          # monotonic: never move backward
        (new, RANK[new], event["timestamp"], event["message_id"], RANK[new]))
Enter fullscreen mode Exit fullscreen mode

The WHERE status_rank < ? clause makes the update idempotent and order-independent. A late sent simply matches no rows and is discarded. The database enforces monotonicity, so concurrent handlers cannot interleave into a bad state.

Terminal states need care: delivered and failed share a rank, so whichever arrives first wins and the other is dropped. That is usually what you want, since a message cannot be both. If your provider can legitimately send delivered after a transient failed, give the terminal states distinct ranks reflecting the true precedence.

Responding correctly

Your response controls the provider's retry behaviour, and getting it wrong causes either duplicate processing or silent loss.

Acknowledge fast, then process. Most providers time out webhook calls in the low single-digit seconds and treat a timeout as failure, retrying the delivery. If your handler does the work inline and takes longer than that window, you receive the same event repeatedly, each attempt doing the work again. Write the event to a queue, return 200, and process asynchronously.

Return 2xx only when you have durably accepted the event — after the queue write commits, not before. Return 5xx when you want a retry, such as your datastore being unavailable. Return 4xx for a permanently unprocessable event, such as a failed signature, since retrying will not help.

The mapping to avoid is returning 200 on an internal error to "stop the noise". The provider treats that as success, does not retry, and the event is gone.

A checklist worth running against your handler

  • Signature verified against the raw body, before parsing.
  • Comparison uses hmac.compare_digest or the equivalent, never ==.
  • Missing signature header rejects, rather than skipping verification.
  • Timestamp skew window enforced, and the replay TTL exceeds it.
  • Replay claim is atomic (SET NX), not check-then-set.
  • Status updates are monotonic by rank, so reordered callbacks cannot regress state.
  • Handler acknowledges within the provider's timeout and processes asynchronously.
  • 2xx only after durable acceptance; 5xx to request retry; 4xx for unprocessable.

Provider signing schemes differ in what exactly is signed — body only, timestamp plus body, or URL plus timestamp plus body — so verify against your gateway's published scheme rather than assuming. If you are evaluating gateways, the webhook signing and idempotency documentation is worth reading before the integration rather than after; whichever SMS API you use should state its signing construction explicitly.

The handler is the smallest file in your codebase and the only one an unauthenticated stranger can reach. Give it the scrutiny that implies.

Top comments (0)