You are accepting webhooks from two hundred partners. Threat model the ingestion pipeline - what are you actually defending against, and what does the endpoint have to do before it trusts a payload?
The endpoint is an unauthenticated internet-facing entry point that writes to your core systems, so treat every field as attacker-controlled. Verify an HMAC over the raw body in constant time, reject stale timestamps, deduplicate on event id, bound the body size, and hand off to a queue rather than processing inline.
What the interviewer is scoring
- Whether the candidate verifies signatures over the raw bytes, before any parsing or deserialisation
- That constant-time comparison is used, and the candidate can say why a normal equality check leaks
- Does the answer combine a timestamp window with replay detection, rather than relying on one alone
- Whether idempotency is handled by event id, given that legitimate senders retry
- That the endpoint is treated as a denial-of-service surface, with size limits and asynchronous processing
- Whether per-partner key separation and rotation is considered, not one shared secret
- Does the answer distinguish authenticating the sender from authorising what the payload may change
Answer
Short answer
A webhook endpoint is an unauthenticated door on the public internet that writes into your core systems, and anyone who learns the URL can knock on it. Everything in the request is attacker-controlled until a signature proves otherwise. The ordered requirements are: verify an HMAC over the raw body in constant time, enforce a timestamp window, deduplicate on event id, cap the body size, and get off the request thread before doing real work.
Frame it as trust boundaries
The useful structure for a threat model here is to ask what crosses the boundary and what each crossing lets an attacker do.
Spoofing — anyone can POST to the URL. Without a signature, an attacker fabricates a payment.succeeded and you ship goods. This is the primary threat and it is why authentication comes before everything else.
Tampering — a legitimate event modified in transit or a partner altering amounts they should not control.
Replay — a genuine, correctly signed request captured and resent. The signature is valid, so signature checking alone does not stop it.
Denial of service — the endpoint must be publicly reachable and must do work, which makes it the cheapest place to attack your capacity.
Elevation — the payload names an account, a tenant, or a resource. If you act on those identifiers without checking the sender is entitled to them, one partner can operate on another's data.
Verify the signature over raw bytes
The standard scheme is an HMAC of the timestamp and body using a shared secret, sent in a header. Two details are where implementations break.
Sign the raw body, not the parsed object. Serialising a parsed object back to JSON will not reproduce the original bytes — key order, whitespace, number formatting and Unicode escaping all differ. The verification then fails on valid requests, and the usual "fix" is to canonicalise, which introduces a parser difference between you and the sender that an attacker can exploit. Capture the raw bytes before any middleware deserialises them, which in most frameworks requires explicit configuration.
Compare in constant time. A byte-by-byte comparison returns as soon as it finds a mismatch, so the time taken reveals how many leading bytes were correct. That is enough to forge a signature one byte at a time over many requests.
import hmac, hashlib, time
def verify(raw_body: bytes, header: str, secret: bytes, tolerance=300) -> bool:
ts, sig = parse(header)
if abs(time.time() - int(ts)) > tolerance: # replay window
return False
expected = hmac.new(secret, f"{ts}.".encode() + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, sig) # constant time - not ==
Note the timestamp is inside the signed payload. If it were only a header, an attacker could change it freely and the window would enforce nothing.
Use a separate secret per partner. One shared secret means a single partner compromise lets that partner impersonate all two hundred, and rotation becomes a coordinated outage. Rotation itself needs an overlap period where both the old and new secret are accepted, so partners can switch without downtime.
The timestamp window is necessary and insufficient
A five-minute tolerance bounds replay but does not prevent it — an attacker who captures a request can replay it freely within that window. So you also need to record event ids and reject ones already seen:
if seen(event_id): return 200 OK # already processed, acknowledge and stop
This is not only a security control. Every serious webhook provider retries on non-2xx and on timeout, so duplicate deliveries are normal traffic, not an attack. An ingestion pipeline that is not idempotent will double-process legitimate events long before anyone attacks it. Retention for seen ids should exceed the sender's maximum retry window.
Returning 200 for a duplicate rather than an error matters too: an error response makes the partner retry harder, amplifying the problem.
Treat it as a capacity surface
The endpoint must accept traffic from anyone, so the defences are structural.
Cap the body size at the transport layer, before parsing. A 500MB JSON body that gets deserialised into objects is a trivial memory exhaustion attack, and so is a deeply nested structure that blows the parser's stack.
Acknowledge fast, process asynchronously. Verify the signature, persist the raw event, return 200, and do the work from a queue. Processing inline couples your handler latency to the partner's timeout — if you take four seconds and they time out at three, they conclude failure and retry, so a slow downstream dependency turns into an ever-growing retry storm that you caused.
Rate limit per partner, not globally, so one partner's misbehaviour or compromise cannot starve the other 199. The fairness reasoning is the same as any shared API.
Authenticating the sender is not authorising the payload
The final control, and the one most often missing. A valid signature proves partner A sent this. It says nothing about whether partner A may act on the account named in the body.
If the payload contains account_id and you apply the change without verifying that account belongs to partner A, any partner with valid credentials can operate on any account — a properly authenticated privilege escalation. Every identifier in the body must be checked against what the authenticated sender is entitled to touch.
Related, and worth mentioning if the design includes callbacks: any URL in the payload that your system will fetch is a server-side request forgery vector into your internal network. If you must fetch partner-supplied URLs, resolve and validate the address against an allowlist and block private ranges — and do the check after DNS resolution, since a hostname can resolve to a private address.
© 2026 Preptima. Originally published at preptima.com.
Likely follow-ups
- Why must the signature be computed over the raw body rather than the parsed object?
- A partner replays a valid signed request from an hour ago. What stops it?
- Your handler takes four seconds and the partner times out at three. What goes wrong?
- The payload contains an account id. Do you trust it, and what do you check?
- How would you roll a partner's signing secret without downtime?
Related questions
- Walk me through applying STRIDE to the boundary where our order service calls the payments service over the network.mediumAlso on threat-modeling4 min
- A customer's bank shows the money left their account and you have no order. How does your checkout make that impossible, and how do you find the ones that already happened?hardAlso on webhooks6 min
- How do you know when a threat model has gone stale, and how would you catch it before an incident does?mediumAlso on threat-modeling4 min
- You are told the application is safe from injection because all user input is escaped as it comes in. What is wrong with that?mediumAlso on input-validation5 min