Source: https://cli.nylas.com/guides/webhook-signature-verification

# 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](https://cli.nylas.com/authors/caleb-geene) Director, Site Reliability Engineering

Updated June 8, 2026

> **TL;DR:** Compute HMAC-SHA256 over the *raw* request body keyed by your webhook secret, then compare it to the signature header with a constant-time function. If they differ, reject with a `401` before reading any field. Use the raw bytes (not re-serialized JSON), reject stale timestamps to block replays, and rotate the secret if it leaks. The CLI verifies a payload for you with `nylas webhook verify`.

Command references used in this guide: [`nylas webhook verify`](https://cli.nylas.com/docs/commands/webhook-verify), [`nylas webhook server`](https://cli.nylas.com/docs/commands/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](https://cheatsheetseries.owasp.org/cheatsheets/Input_Validation_Cheat_Sheet.html) 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.

```python
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.

```bash
# 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](https://docs.stripe.com/webhooks/signatures) and [GitHub](https://docs.github.com/en/webhooks/using-webhooks/validating-webhook-deliveries) 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](https://cli.nylas.com/guides/email-webhook-events-reference) for the events you'll be verifying.

## Next steps

- [Webhook events reference](https://cli.nylas.com/guides/email-webhook-events-reference) — every trigger type and payload shape
- [Test webhooks locally](https://cli.nylas.com/guides/test-email-webhooks-locally) — tunnels, signatures, and fixtures
- [Parse inbound email webhooks](https://cli.nylas.com/guides/parse-inbound-email-webhooks) — handle verified payloads safely
- [MCP email server security checklist](https://cli.nylas.com/guides/mcp-email-server-security-checklist) — hardening an email integration
- [Full command reference](https://cli.nylas.com/docs/commands) — every flag and subcommand documented
