The Day I Learned Why Webhooks Need HMAC

December 2025 · 8 min read

The task sounded almost too small to be a project. When a deal in HubSpot hits "Closed Won", copy one field into an ERP system. One webhook, one API call, maybe sixty lines of FastAPI. I had a working version the same afternoon.

The pipeline itself is the kind of glue work that makes businesses run and never makes conference talks. Sales lives in HubSpot. Finance lives in the ERP. When a deal closes, a project classification field needs to travel from one world to the other, and until this connector existed it traveled by someone remembering to retype it. The requirement was one line: within about a minute of a deal closing, the ERP should know. Event-driven, tiny payload, no batch jobs.

My first version authenticated the webhook the way I suspect most first versions do. I generated a long random string, put it in an environment variable, configured HubSpot to send it in a custom header, and compared the two:

if x_webhook_token != WEBHOOK_TOKEN:
    raise HTTPException(status_code=401)

A shared secret. It felt secure. The endpoint was HTTPS, the token was 64 characters of randomness, nobody was going to guess it. I shipped it and moved on. That version still exists in an older repository, and I've kept it deliberately, the way you keep an old photo with a bad haircut. It works. It even has tests. And it's a security design I'd now politely reject in someone else's code review.

Then, while setting up the HubSpot side properly (a private app with the minimal crm.objects.deals.read scope, because the connector reads deal properties and needs nothing else), I noticed the settings had a section I'd skipped: request signatures. HubSpot signs every webhook it sends with something called an X-HubSpot-Signature-V3 header. I went to the docs expecting a five-minute read. I came out an hour later having understood, for the first time, what my token approach was actually missing.

What the token doesn't protect

Here's the thing about a static token in a header: it proves the sender knows the secret. That's all it proves. It says nothing about the message.

If anyone ever captures one request, they now have everything. A misconfigured proxy that logs headers is enough. A debugging session where someone pastes a curl command into a ticket is enough. From that moment they can send my endpoint any payload they like, forever, and every request will look legitimate. They can also take a real request and replay it next week. The token never expires, never changes per request, and doesn't bind to the body at all.

Think about what that means for this specific endpoint. The payload carries a deal ID and a field value, and the endpoint writes that value into a finance system. With a captured token, an attacker doesn't need to understand anything about my code. They can update ERP records by POST request. The blast radius of "one field sync" is bigger than it looks the moment the destination is a system accountants trust.

HMAC fixes all three problems at once, which is why every serious webhook provider uses some version of it. The sender computes a signature over the actual request (method, URL, body, timestamp) using the shared secret as the key. The receiver recomputes it and compares. Change one byte of the payload and the signature breaks. Replay an old request and the timestamp gives it away. And the secret itself never travels over the wire.

Rebuilding it properly

HubSpot's v3 scheme concatenates four things in a fixed order, with no separators, which matters more than you'd think:

raw = method + url + body + timestamp
digest = hmac.new(secret.encode(), raw.encode(), hashlib.sha256).digest()
signature = base64.b64encode(digest).decode()

The verification side rejects a request in three ways before it ever touches business logic, and the whole flow is easier to see than to describe:

sequenceDiagram
    participant HS as HubSpot
    participant EP as FastAPI endpoint
    participant ERP as ERP system
    HS->>EP: POST /deal-closed-won<br/>headers: signature-v3, timestamp
    EP->>EP: headers present?
    Note over EP: missing → 401
    EP->>EP: timestamp within 5 min?
    Note over EP: too old → 401 (replay blocked)
    EP->>EP: recompute HMAC over<br/>method + url + body + timestamp
    EP->>EP: compare_digest(expected, received)
    Note over EP: mismatch → 401 (forgery blocked)
    EP->>ERP: update project field on deal
    ERP-->>EP: 200
    EP-->>HS: 200
  

Missing headers: 401. A timestamp older than five minutes: 401. That second one is the replay protection, a window small enough that a captured request goes stale before an attacker can do much with it. And finally the signature comparison itself:

if not hmac.compare_digest(expected, received):
    raise HTTPException(status_code=401, detail="Invalid signature")

That compare_digest instead of == was another small discovery. A normal string comparison returns early at the first mismatched character, and the timing difference is measurable. Given enough requests, an attacker can reconstruct a valid signature byte by byte. compare_digest takes the same time no matter where the strings differ. It costs nothing to use and closes an entire attack class I didn't know existed until I read why the function was in the standard library.

Reading a webhook signature spec is a compressed history of everything that has gone wrong with webhooks.

Only after all three gates pass does the boring part run: map the HubSpot property to the ERP field name, wrap it in the ERP's nested element format, POST it with the ERP's own token auth. Two different authentication worlds meet in sixty lines. I verify HubSpot cryptographically because its requests arrive unsolicited. The ERP accepts my token because I initiate that call over TLS to a known host. Same connector, two threat models, and the asymmetry is itself instructive.

The bugs that taught me the format

Nothing makes you understand a signature scheme like producing the wrong signature over and over. My first attempts failed for reasons that all came down to byte-exactness.

The URL had to match what HubSpot signed, which is the https:// URL, while my app behind a proxy saw http://. One scheme rewrite fixed a mystery that had me re-reading the docs three times. The body had to be the raw bytes as received, not the parsed-and-reserialized JSON, because json.dumps with default settings adds spaces after separators and then the signature is dead. And the timestamp is in milliseconds. I converted it to seconds exactly once, and after that never trusted myself again.

Each of those failures became permanent in the test suite. Four tests document the whole contract: a correctly signed request passes, a wrong signature is rejected, an expired timestamp is rejected, missing headers are rejected. The valid-signature test is my favorite because it has to be HubSpot. It builds the payload with separators=(",", ":") to get byte-identical JSON, generates a fresh millisecond timestamp, and signs it with the same function the server uses. Writing a legitimate forgery to prove forgery is impossible has a pleasing circularity to it. These are the most useful tests in the repo, because they encode everything I got wrong the first time.

Secrets, and where they live

The last piece of hygiene: the connector holds two secrets, the webhook signing secret and the ERP token, and neither appears in the repository. They arrive via environment variables, loaded from a .env file locally and injected by the platform in deployment. This sounds like table stakes, and it is. But the v1-versus-v2 comparison sharpened something for me. In the token-header design, the secret is the authentication, so leaking it is total compromise. In the HMAC design, the secret never crosses the network after setup, and even a leaked request log reveals only signatures, useless without the key and stale within five minutes. Good schemes degrade gracefully. Bad ones fail absolutely.

Was the token version ever attacked?

Almost certainly not. It ran quietly, syncing deal fields into the ERP within a minute of a deal closing, and no one ever tried to forge a request to an obscure endpoint that updates one field. That's not really the point though. The cost of doing it right was an afternoon and about forty lines of code. The cost of doing it wrong is unbounded and invisible. You don't get a notification when someone starts replaying your webhooks.

What stays with me is how much design is packed into a scheme this small. The timestamp window, the ordered concatenation, the constant-time compare. Each one exists because someone, somewhere, exploited its absence. Worth the hour.

References