Launch-Free 3 months Builder plan-
Pixel art lobster connecting webhook endpoints — HMAC webhook signature verification email events

HMAC webhook signature verification for email events

How to verify HMAC signatures on email webhook events. Step-by-step implementation in Node.js and Python with timing-safe comparison.

9 min read
Ian Bussières
Ian BussièresCTO & Co-founder

Your agent just received an email.received webhook. Or did it? Without HMAC signature verification, there's no way to distinguish a legitimate email event from a spoofed payload hitting the same endpoint. Every unverified webhook is a door you forgot to lock.

This matters more for email events than most other webhook types. A fake email.bounced event can trick your agent into blacklisting a valid contact. A spoofed email.received event can inject content your agent trusts and acts on without question. If your agent processes email programmatically, HMAC verification is the minimum viable security boundary.

LobsterMail signs every webhook payload with HMAC-SHA256 and returns the secret exactly once at creation time. , paste the setup instructions, and your agent will have HMAC-verified email events running in minutes.

How to verify an HMAC webhook signature for email events#

  1. Capture the raw, unparsed request body before any JSON parsing middleware runs.
  2. Extract the provider-supplied signature from its designated HTTP header (e.g., X-Webhook-Signature).
  3. Recompute a fresh HMAC-SHA256 digest using your stored secret key and the raw body.
  4. Hex-encode both the computed digest and the extracted signature.
  5. Compare them using a constant-time (timing-safe) equality function.
  6. Return HTTP 200 on match, or HTTP 401 and discard the event on mismatch.

Every email event type follows the same verification flow. Whether the payload carries a bounce notification, a new inbound message, or a quarantine alert, the signature covers the full body. Verify once at the boundary, then trust the data downstream.

What HMAC actually does#

HMAC (Hash-based Message Authentication Code) combines a cryptographic hash function with a secret key to produce a fixed-length signature. The webhook provider computes this signature over the raw request body before sending. Your server recomputes it on arrival using the same secret. If both signatures match, you know two things: the payload hasn't been modified in transit, and it was sent by someone who holds the secret key.

For email events, the threat model is concrete. Your agent's webhook URL is a public HTTP endpoint. Anyone who discovers it through logs, network inspection, or brute force can POST arbitrary JSON to it. Without HMAC verification, your agent has no way to distinguish these fake payloads from real ones. With it, every spoofed request fails signature validation and gets rejected before your agent ever touches the data.

Implementing verification in Node.js#

Here's a complete Express middleware for verifying HMAC signatures on incoming email webhooks:

const crypto = require('crypto');

function verifyWebhookSignature(secret) {
  return (req, res, next) => {
    const signature = req.headers['x-webhook-signature'];
    if (!signature) {
      return res.status(401).json({ error: 'Missing signature header' });
    }

    const computed = crypto
      .createHmac('sha256', secret)
      .update(req.rawBody)
      .digest('hex');

    const trusted = Buffer.from(computed, 'utf8');
    const untrusted = Buffer.from(signature, 'utf8');

    if (trusted.length !== untrusted.length ||
        !crypto.timingSafeEqual(trusted, untrusted)) {
      return res.status(401).json({ error: 'Invalid signature' });
    }

    next();
  };
}

One thing most tutorials skip: you need the raw, unparsed request body. Configure Express to preserve it during JSON parsing:

app.use(express.json({
  verify: (req, _res, buf) => {
    req.rawBody = buf;
  }
}));

If Express has already parsed and re-serialized the JSON, the byte-level representation may differ from what the provider signed. Whitespace or key ordering can change silently. The HMAC won't match, and you'll spend hours debugging a "broken" webhook that's actually a parsing order problem.

Implementing verification in Python#

import hmac
import hashlib

def verify_signature(payload_body: bytes, secret: str, signature_header: str) -> bool:
    computed = hmac.new(
        secret.encode('utf-8'),
        payload_body,
        hashlib.sha256
    ).hexdigest()

    return hmac.compare_digest(computed, signature_header)

Python's hmac.compare_digest handles constant-time comparison for you. Pass the raw request body as bytes, not a decoded string. In Flask, that means request.get_data(). In FastAPI, use await request.body() before any Pydantic model parsing runs.

Why timing-safe comparison matters#

A regular === or == comparison short-circuits on the first mismatched character. An attacker can measure response time differences across thousands of requests to reconstruct a valid signature one byte at a time. This is called a timing attack, and it isn't theoretical.

crypto.timingSafeEqual in Node.js and hmac.compare_digest in Python compare every byte regardless of where a mismatch occurs. The response time stays constant whether the first character is wrong or the last one is.

Warning

Both buffers passed to crypto.timingSafeEqual must have the same length. If they differ, Node.js throws an error instead of returning false. Always check trusted.length !== untrusted.length before calling the function.

Handling verification failures#

When HMAC verification fails, return HTTP 401 and stop processing immediately. Don't return 200 and silently ignore the payload. Some providers interpret 200 as successful delivery and won't retry, which means you'll miss legitimate events during a temporary key mismatch. Returning 500 is also wrong: it signals a server error and the provider may keep retrying a potentially malicious payload.

Log the failure with enough context to investigate: timestamp, source IP, the event type claimed in the payload. But don't log the raw body. A spoofed payload might contain data designed to pollute your logs or trigger downstream processing if someone reviews them later.

For agents processing high volumes of email events, track verification failure rates over time. A sudden spike usually means either your secret rotated without updating the endpoint, or someone is actively probing your webhook URL. Both require different responses. If you're thinking about the broader security picture, we wrote about whether your agent's email setup is actually secure.

Replay protection beyond HMAC#

HMAC proves authenticity and integrity. It does not prevent replay attacks. An attacker who intercepts a valid signed payload can resend it an hour later, and the signature will still verify. To defend against replays, check the timestamp in the payload and reject anything older than a few minutes:

const eventTime = new Date(payload.timestamp);
const now = new Date();
const fiveMinutes = 5 * 60 * 1000;

if (Math.abs(now - eventTime) > fiveMinutes) {
  return res.status(401).json({ error: 'Stale event' });
}

Combine timestamp checks with idempotency keys. If your agent has already processed event eml_abc123, don't process it again even when the signature is valid. Duplicate delivery notifications happen often with email webhooks, and without deduplication your agent might send the same reply twice. We covered related architectural tradeoffs in our post on webhooks vs polling for agent email.

How email providers differ#

Not every email provider signs webhooks the same way. Header names, encoding formats, and what gets signed all vary across the industry:

ProviderSignature headerAlgorithmEncodingSigned content
LobsterMailX-Webhook-SignatureSHA-256HexRaw body
SendGridX-Twilio-Email-Event-Webhook-SignatureECDSABase64Timestamp + body
MailgunX-Mailgun-SignatureSHA-256HexTimestamp + token
PostmarkX-Postmark-SignatureSHA-256Base64Raw body

If your agent receives events from multiple providers, abstract the verification pattern (extract header, compute digest, compare) and swap in provider-specific details. LobsterMail's approach of SHA-256 with hex encoding over the raw body follows the most common convention and is the simplest to implement.

When you create a LobsterMail webhook, the secret is returned exactly once:

const webhook = await lm.createWebhook({
  url: 'https://your-agent.example.com/hooks/email',
  events: ['email.received', 'email.bounced', 'email.quarantined'],
});

// Store this immediately — it won't be shown again
const secret = webhook.secret; // whsec_...

Store that whsec_ value in your environment variables or a secrets manager the moment you receive it. If you lose it, you'll need to delete the webhook and create a new one.

Tip

Subscribe only to the event types your agent actually processes. Receiving email.sent confirmations when your agent never acts on them just adds verification overhead for no benefit.

After verification passes#

Once the signature checks out, parse the JSON, route by event type, and let your agent act on the data. The verification middleware sits at the boundary so your business logic never needs to question whether the payload is authentic.

For agents that manage their own inbox, this boundary is the single most important line of defense. Your webhook URL is a public surface that anyone can hit. HMAC verification is what turns it from an open target into a trusted channel.

Start with SHA-256, timing-safe comparison, and timestamp validation. Add idempotency tracking once your agent handles enough volume for duplicate events to become a real problem. That combination covers the threat model for nearly every email webhook integration you'll build.

Frequently asked questions

What is HMAC and why is it the standard mechanism for webhook signature verification?

HMAC (Hash-based Message Authentication Code) combines a hash function with a secret key to produce a signature that proves both payload integrity and sender authenticity. It's the webhook standard because it's fast to compute, supported in every programming language, and doesn't require public key infrastructure.

How does HMAC webhook verification protect against man-in-the-middle tampering on email delivery events?

The provider signs the entire payload body with your shared secret before sending. Your server recomputes the signature and compares. If anyone modifies the payload in transit (changing a bounce status, altering a sender address, injecting fake content), the signatures won't match and your server rejects the event.

Why must I pass the raw, unparsed request body to the HMAC function?

The HMAC was computed over the exact bytes the provider sent. If your framework parses the JSON and re-serializes it, whitespace, key order, or Unicode escaping may change. Those byte differences produce a different hash, causing verification to fail even on legitimate events.

Which hash algorithm should I choose for email webhook HMAC: SHA-256 or SHA-512?

SHA-256 is the standard choice for webhook signatures. It's universally supported, fast enough for high-throughput event processing, and provides more than enough security bits for this use case. SHA-512 offers no practical security advantage here, and some providers don't support it.

What is a timing-safe comparison and why is using a simple equality operator dangerous?

A timing-safe comparison checks every byte of both strings regardless of where a mismatch occurs, taking constant time for any input. Regular string comparison (=== or ==) short-circuits on the first wrong character, leaking information about how many leading bytes are correct. Use crypto.timingSafeEqual in Node.js or hmac.compare_digest in Python.

How do I securely store my webhook secret key in a production environment?

Store it in environment variables or a dedicated secrets manager like AWS Secrets Manager, HashiCorp Vault, or Doppler. Never commit it to source code, log it, or expose it in client-side code. LobsterMail returns the secret only once at webhook creation, so capture and store it immediately.

Which HTTP request header carries the HMAC signature from an email events provider?

It depends on the provider. LobsterMail uses X-Webhook-Signature, SendGrid uses X-Twilio-Email-Event-Webhook-Signature, Mailgun uses X-Mailgun-Signature, and Postmark uses X-Postmark-Signature. Always check your provider's documentation for the exact header name.

What HTTP status code should my endpoint return when HMAC verification fails?

Return HTTP 401 (Unauthorized). Returning 200 may cause the provider to mark the event as delivered and never retry it. Returning 500 signals a server error and may trigger repeated retries of a potentially malicious payload.

Does HMAC verification alone prevent webhook replay attacks, or do I need a timestamp check too?

HMAC alone does not prevent replays. A captured valid payload can be resent later and the signature will still verify. Add timestamp validation (reject events older than 5 minutes) and idempotency tracking to defend against replay attacks.

How do SendGrid, Mailgun, and Postmark differ in how they sign and deliver email webhook payloads?

SendGrid uses ECDSA (not HMAC) with Base64 encoding over a timestamp-plus-body combination. Mailgun uses HMAC-SHA256 but signs a timestamp-plus-token string rather than the raw body. Postmark uses HMAC-SHA256 over the raw body with Base64 encoding. Each requires provider-specific verification logic.

How can I test HMAC webhook verification locally without connecting to a live email provider?

Generate a test secret, compute an HMAC-SHA256 signature over a sample JSON payload using openssl dgst -sha256 -hmac "your-secret", then send a POST request with curl including the computed signature in the appropriate header. This validates your verification logic without any external dependency.

Can a single endpoint verify HMAC signatures for multiple email event types such as open, click, and bounce?

Yes. The HMAC signature covers the entire request body regardless of event type. Your verification middleware runs the same check for every event. Route by event type (e.g., email.received, email.bounced, email.quarantined) only after verification passes.

Should I log or silently discard email webhook events that fail HMAC verification?

Log metadata like timestamp, source IP, and claimed event type, but do not log the raw payload body. Spoofed payloads may contain data designed to exploit log viewers or downstream systems. Track failure rates to detect secret rotation issues or active endpoint probing.

Related posts