Guide
Verify Webhook Signatures (HMAC Guide)
A webhook endpoint sits on the public internet, which means anyone who learns the URL can POST forged events to it. The defense is a signature: the sender computes an HMAC of the exact bytes it sent, keyed by a shared secret, and your handler recomputes it and compares before trusting a single field. This guide shows how that works, how to verify a Nylas webhook, how the major providers differ, and how to stop replay attacks.
Written by Caleb Geene Director, Site Reliability Engineering
Command references used in this guide: nylas webhook verify, nylas webhook server, and nylas webhook rotate-secret.
Why must you verify webhook signatures?
You must verify because a webhook endpoint is an unauthenticated, publicly reachable URL — once an attacker knows or guesses it, they can POST anything to it. Without a signature check, your handler can't tell a real message.created event from a forged one, so it might create records, send replies, or trigger workflows on attacker-controlled data. The OWASP input-validation guidance treats all external input as untrusted, and a webhook body is external input.
A signature closes that gap. The sender holds a secret shared only with you, computes a keyed hash of the exact payload, and sends it in a header. Because an attacker doesn't have the secret, they can't produce a matching hash, so a body that fails the check is rejected before any logic runs. This is the single most important control on a webhook receiver.
How does HMAC signature verification work?
HMAC (Hash-based Message Authentication Code) combines your secret key with the message through a hash function — SHA-256 in nearly every modern webhook scheme. The sender computes HMAC-SHA256(secret, raw_body) and puts the hex digest in a header; your handler recomputes the same value over the bytes it received and compares. A match proves both that the secret-holder sent it and that not one byte changed in transit.
Two details are easy to get wrong and both are security-critical. First, hash the raw request body, not a re-serialized object — JSON key reordering or whitespace changes break the digest even though the data is identical. Second, compare with a constant-time function (hmac.compare_digest, crypto.timingSafeEqual), never ==, so timing differences can't leak the expected signature byte by byte.
import hmac, hashlib
def verify(raw_body: bytes, signature: str, secret: str) -> bool:
# Hash the EXACT bytes received — never a re-encoded JSON object.
expected = hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
# Constant-time compare — never use ==, which leaks timing.
return hmac.compare_digest(expected, signature)
# In the handler: reject BEFORE parsing or acting on the payload.
# if not verify(request.body, request.headers["x-nylas-signature"], SECRET):
# return Response(status=401)How do you verify a Nylas webhook?
Nylas signs every webhook with an x-nylas-signature header — an HMAC-SHA256 of the raw body keyed by your webhook secret. The nylas webhook verify command runs that exact check for you: pass the raw payload, the secret, and the signature from the header, and it confirms or rejects the message. That lets you test a handler against a captured fixture without deploying anything.
For live development, nylas webhook server --tunnel --secret receives real events over a cloudflared tunnel and verifies each signature before printing it, so you see exactly what your production handler should accept. The documentation explicitly warns to pass the unmodified raw body — reformatting the JSON before verifying will break the digest, the most common false rejection.
# Verify a captured payload offline (exact raw bytes required)
nylas webhook verify \
--payload-file event.json \
--secret "$NYLAS_WEBHOOK_SECRET" \
--signature "$SIG_FROM_HEADER"
# Or receive and verify live events over a tunnel
nylas webhook server --tunnel --secret "$NYLAS_WEBHOOK_SECRET"How do webhook signatures compare across providers?
The scheme differs by provider, but the principle is identical: an HMAC (or signature) over the raw body in a named header. The table below maps four common schemes so you can port a verification routine. Stripe and GitHub document their verification in detail, and Stripe also embeds a timestamp in the header to defend against replay, which the next section covers.
| Provider | Header | Scheme |
|---|---|---|
| Nylas | x-nylas-signature | HMAC-SHA256 hex |
| Stripe | Stripe-Signature | HMAC-SHA256 with t= timestamp |
| GitHub | X-Hub-Signature-256 | HMAC-SHA256 hex, sha256= prefix |
| Twilio SendGrid | X-Twilio-Email-Event-Webhook-Signature | ECDSA public-key signature |
How do you stop replay attacks and rotate secrets?
A valid signature doesn't stop replay: an attacker who captures a real signed request can resend it. Defend by including a timestamp in the signed data — Stripe puts t= in the header — and rejecting any request whose timestamp is more than a few minutes old. A 5-minute tolerance window is typical; anything older is dropped even if the signature checks out.
Secrets also leak, so rotate them. nylas webhook rotate-secret issues a new signing secret for a webhook; deploy the new value to your verifier, confirm deliveries still pass, then retire the old one. Treat the webhook secret like any credential — never log it, never commit it, and rotate immediately if it appears in a stack trace or a shared environment. See the webhook events reference for the events you'll be verifying.
Next steps
- Webhook events reference — every trigger type and payload shape
- Test webhooks locally — tunnels, signatures, and fixtures
- Parse inbound email webhooks — handle verified payloads safely
- MCP email server security checklist — hardening an email integration
- Full command reference — every flag and subcommand documented