Skip to main content

Verifying SendGrid Event Webhook Signatures (ECDSA)

Verify SendGrid Event Webhook ECDSA signatures in Node.js against the raw request body and timestamp to block forged delivery, bounce, and complaint events.

Your SendGrid webhook endpoint accepts any POST that hits it, which means anyone who learns the URL can forge bounce and spamreport events and poison your suppression list and deliverability analytics. This guide shows the exact ECDSA verification that closes that hole, in Node.js, against the raw request body.

The Problem and Its Scope

SendGrid's Event Webhook POSTs a JSON array of events — delivered, open, click, bounce, dropped, spamreport — to a URL you register. If that endpoint trusts the payload without authentication, the attack is trivial: a single forged {"event":"bounce","email":"vip@customer.com"} POST suppresses a real customer, and a flood of fake open events corrupts the metrics you feed back into your sending decisions. This applies to every SendGrid Event Webhook integration regardless of language or framework; the examples here are Node.js with Express, with a raw-crypto fallback.

Root Cause: An Unauthenticated Endpoint

An open webhook endpoint is an unauthenticated write API to your most sensitive email state. Because the URL travels over the network, is logged in proxies, and is visible in the SendGrid dashboard, treating it as secret is not security. The correct control is cryptographic: SendGrid signs every Event Webhook request with an ECDSA key pair, publishes the public verification key in the dashboard, and includes the signature plus a timestamp in headers. You verify the signature against the request before acting on it. Without verification, there is no way to distinguish a real SendGrid event from a forged one.

Consequences of an unverified endpoint Without signature verification anyone can post forged events, suppressing arbitrary addresses, faking delivery state or flooding the event store. A Public URL That Writes to Your Database forged suppression post a bounce for any address The target stops receiving your password resets entirely, and nothing looks broken. falsified state mark failures as delivered Support and audit trails now disagree with what actually reached the recipient. event-store flooding unbounded writes Storage and queue depth grow until real events are delayed behind the noise.
None of these need a credential — only the endpoint URL, which appears in provider dashboards and logs.

SendGrid signs over the concatenation of the timestamp and the raw, unmodified request body, using ECDSA with the curve behind its signed-webhook feature (an ed25519-style public key, distributed as base64 you convert to a usable ECDSA key). Two headers carry the proof:

  • X-Twilio-Email-Event-Webhook-Signature — the base64 ECDSA signature.
  • X-Twilio-Email-Event-Webhook-Timestamp — the Unix timestamp that was prepended to the body before signing.

If you verify against anything other than the exact bytes SendGrid signed, the check fails. This is why re-serialized JSON breaks verification.

The Exact Fix

The sequence is: enable the signed webhook, fetch the public key, then on every request verify the signature over timestamp + raw body before parsing JSON, reject on mismatch, and guard against replay using the timestamp.

SendGrid signature verification sequence SendGrid sends the signed event; the receiver captures the raw body, reconstructs timestamp plus body, verifies with the public key, and rejects on mismatch. ECDSA Verification Sequence SendGrid signs + POSTs Capture raw body + 2 headers Verify ECDSA ts + raw body Enqueue if valid Reject 403 on mismatch Verify before JSON.parse — the parsed object cannot reproduce the signed bytes.
Verification happens on the raw bytes before parsing; a mismatch returns 403 and the event is never enqueued.

First enable it: in the SendGrid dashboard under Settings → Mail Settings → Event Webhook, toggle Signed Event Webhook on and copy the verification key. Then verify with the official client, which handles the key conversion and the ECDSA math for you.

// verify-sendgrid.js — Express route using the official @sendgrid/eventwebhook client.
const express = require('express');
const { EventWebhook } = require('@sendgrid/eventwebhook'); // SendGrid's signing client

const app = express();
const eventWebhook = new EventWebhook();

// Convert the base64 verification key from the dashboard into an ECDSA public key once at boot.
const publicKey = eventWebhook.convertPublicKeyToECDSA(process.env.SENDGRID_WEBHOOK_PUBLIC_KEY);

const MAX_SKEW_SECONDS = 600; // reject anything older than 10 minutes to limit replay

app.post('/webhooks/sendgrid',
  // express.raw gives us req.body as a Buffer — the EXACT bytes SendGrid signed.
  // Using express.json() here would re-serialize and the signature would never match.
  express.raw({ type: 'application/json' }),
  (req, res) => {
    const signature = req.get('X-Twilio-Email-Event-Webhook-Signature');
    const timestamp = req.get('X-Twilio-Email-Event-Webhook-Timestamp');

    if (!signature || !timestamp) {
      return res.status(400).send('missing signature headers'); // SendGrid always sends both
    }

    // Replay guard: SendGrid prepends this timestamp to the body before signing,
    // so a captured-and-replayed request keeps its old timestamp and is rejected here.
    const skew = Math.abs(Date.now() / 1000 - Number(timestamp));
    if (skew > MAX_SKEW_SECONDS) {
      return res.status(403).send('stale timestamp');
    }

    // Verify ECDSA signature over (timestamp + raw body). req.body is the untouched Buffer.
    const valid = eventWebhook.verifySignature(publicKey, req.body, signature, timestamp);
    if (!valid) {
      return res.status(403).send('invalid signature'); // forged or tampered — drop it
    }

    // Only now is it safe to parse and act on the events.
    const events = JSON.parse(req.body.toString('utf8'));
    enqueue(events); // hand off to the idempotent consumer; return 200 fast
    return res.status(200).send('ok');
  });

If you cannot add the dependency, the same check in raw crypto: prepend the timestamp to the raw body, then verify the base64 signature with the ECDSA public key.

// verify-raw.js — dependency-free ECDSA verification for the SendGrid Event Webhook.
const crypto = require('crypto');

function verifySendGridSignature(publicKeyPem, rawBody, signature, timestamp) {
  // SendGrid signs the concatenation of timestamp and the raw request body, in that order.
  const signedPayload = Buffer.concat([Buffer.from(timestamp), rawBody]);

  const verifier = crypto.createVerify('sha256'); // SendGrid uses ECDSA over SHA-256
  verifier.update(signedPayload);
  verifier.end();

  // The header is base64; the PEM is built from the dashboard verification key.
  return verifier.verify(publicKeyPem, Buffer.from(signature, 'base64'));
}

The non-negotiable detail in both versions: rawBody is the byte stream SendGrid sent. The moment a framework parses it into an object and you re-stringify, key ordering and whitespace differ from the signed bytes and verification fails even on a legitimate request.

Variant: Framework Body-Parser Pitfalls

The most common failure is a global JSON body parser consuming the stream before your route sees it. In Express, app.use(express.json()) mounted globally means req.body is already a parsed object by the time the webhook handler runs, and there is no way to recover the original bytes. Mount the raw parser on the webhook path only, and order matters:

Raw body versus re-serialised body The signature covers the exact bytes received; parsing to an object and serialising back changes whitespace and key order, so verification fails on a valid request. Verify the Bytes, Not the Object JSON middleware runs first bytes parsed into an object re-serialised for the hash Key order and spacing shift — every valid request now fails verification. raw body captured first buffer kept before parsing hash computed over those bytes Parse afterwards — verification and parsing are separate steps, in that order.
This failure mode is silent in development and total in production: scope the raw-body capture to the webhook route only.
// Mount raw parsing for the webhook BEFORE any global express.json().
app.use('/webhooks/sendgrid', express.raw({ type: 'application/json' }));
app.use(express.json()); // global JSON parsing for the rest of the API, applied after

The equivalent trap exists in other stacks. The rule is always identical — capture bytes, verify, then parse — but each framework hides the body somewhere different.

Next.js. The Pages Router silently JSON-parses the body unless you opt out per route; the App Router does not parse but you must read the raw text yourself, not await req.json():

// Next.js Pages Router: disable the built-in body parser for this route ONLY.
export const config = { api: { bodyParser: false } };
import getRawBody from 'raw-body';
export default async function handler(req, res) {
  const raw = await getRawBody(req);                 // Buffer of the exact signed bytes
  const sig = req.headers['x-twilio-email-event-webhook-signature'];
  const ts  = req.headers['x-twilio-email-event-webhook-timestamp'];
  if (!eventWebhook.verifySignature(publicKey, raw, sig, ts)) return res.status(403).end();
  // ... enqueue raw, return 200
}

// Next.js App Router (route.ts): use req.text(), NEVER req.json(), or the bytes are lost.
export async function POST(req) {
  const raw = await req.text();                      // raw string, byte-identical to what SendGrid signed
  const sig = req.headers.get('x-twilio-email-event-webhook-signature');
  const ts  = req.headers.get('x-twilio-email-event-webhook-timestamp');
  if (!eventWebhook.verifySignature(publicKey, Buffer.from(raw), sig, ts))
    return new Response('bad signature', { status: 403 });
  return new Response('ok');
}

Fastify. Fastify registers a global JSON content-type parser that consumes the stream before your handler; add a buffer parser for the webhook's content type:

// Fastify: capture the raw Buffer for application/json instead of auto-parsing it.
fastify.addContentTypeParser('application/json', { parseAs: 'buffer' },
  (req, body, done) => done(null, body));            // body is now the raw Buffer in req.body

Flask. request.json and request.get_json() both consume and parse the stream; use request.get_data() to read the cached raw bytes, which Flask preserves so you can call it before parsing:

# Flask: get_data() returns the raw request body bytes SendGrid signed.
raw = request.get_data()  # bytes; do NOT use request.json before verifying
sig = request.headers["X-Twilio-Email-Event-Webhook-Signature"]
ts  = request.headers["X-Twilio-Email-Event-Webhook-Timestamp"]
# verify(ts + raw) with the ECDSA public key, then json.loads(raw) only on success

In every framework the failure mode is the same and silent: a legitimate SendGrid request fails verification not because the signature is wrong but because a parser mutated the bytes before you saw them. If a real test event returns 403, suspect a body parser before you suspect the key.

Replay-guard detail

The signature alone proves authenticity but not freshness: a valid request captured off the wire (or replayed from your own logs) carries a real signature and would pass verifySignature forever. The timestamp closes this. Because SendGrid prepends X-Twilio-Email-Event-Webhook-Timestamp to the body before signing, a replayed request keeps its original timestamp — you cannot change it without invalidating the signature — so comparing it against the current clock rejects stale replays:

// Replay guard: the signed timestamp is immutable, so a stale one means a replay.
const MAX_SKEW_SECONDS = 600;                         // 10-minute window; tighten if your clock is reliable
const ageSeconds = Math.abs(Date.now() / 1000 - Number(timestamp));
if (ageSeconds > MAX_SKEW_SECONDS) {
  return res.status(403).send('stale timestamp');     // reject before verifySignature even runs
}

Pick the window deliberately: too tight and legitimate retries (SendGrid redelivers failed batches minutes later, carrying the original timestamp) get rejected as stale; too loose and the replay window widens. Ten minutes tolerates SendGrid's retry cadence while keeping the replay surface small. For defense in depth, the idempotent consumer downstream also dedupes on sg_event_id, so even a replay inside the skew window is processed exactly once — replay guard and idempotency are complementary layers, not substitutes.

Pipeline Integration

This verification is the second stage of the broader webhook event pipeline: the receiver verifies, then enqueues the raw body for asynchronous processing. Keep verification synchronous and cheap so the receiver still returns 200 in milliseconds and SendGrid does not retry. Do not push verification into the consumer — an unverified event should never reach the queue, because once it is enqueued it will be processed as genuine. After verification passes, hand the events to an idempotent consumer keyed on sg_event_id, which dedupes the at-least-once redeliveries. Bounce and complaint events that survive verification then drive suppression as described in bounce and complaint handling.

Validation & Deployment Checklist

Frequently Asked Questions

Can we skip verification if the endpoint URL is secret?

No. The URL appears in the provider's dashboard, in your own deploy configuration, in proxy and CDN logs, and in any error report that includes the request target. Treating it as a secret means the endpoint's security depends on something that is copied into half a dozen systems and never rotated. Signature verification costs a few milliseconds and does not depend on the URL staying private.

What timestamp skew window should we allow?

Five minutes in either direction is the usual choice. It absorbs ordinary clock drift between your server and the provider while keeping the replay window short. Going much wider defeats the purpose of checking the timestamp at all; going much narrower produces spurious rejections on a host whose clock has drifted, which is a failure mode that looks like an attack and is not.

Does verification protect against replay of a genuine event?

Only partially. A captured, genuine request stays valid until its timestamp falls outside your skew window, so the timestamp check bounds the exposure rather than eliminating it. Deduplicating on the provider's event id closes the remainder: a replayed event is recognised as one already processed and discarded, which is the same mechanism that handles the provider's own at-least-once redeliveries.

Should a failed verification return 401 or 404?

Return a 4xx and log it; which code matters less than that it is not a 2xx. Some teams prefer 404 to avoid confirming the endpoint exists, though the provider's own dashboard already confirms that. What matters more is that a verification failure is alerted on rather than only logged — a sustained stream of them means either a misconfigured key after a rotation or someone probing the endpoint.

How do we rotate the verification key without dropping events?

Accept both keys during the overlap: verify against the new key first and fall back to the old one, then remove the old key once the provider dashboard shows the new one in use and a test event has verified against it. This is the same publish-then-switch-then-retire shape used for DKIM key rotation, and for the same reason: messages in flight were signed under the old key.


← Back to Building Webhook Event Pipelines