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.
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.
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:
// 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
- Signed Event Webhook is enabled in Settings → Mail Settings → Event Webhook.
- The verification key is loaded from config/secrets, never hardcoded.
- The webhook route uses a raw/buffer body parser, not a JSON parser.
- No global
express.json()(or equivalent) runs before the webhook route. - Signature is verified over
timestamp + raw bodybeforeJSON.parse. - Requests with a missing signature or timestamp header are rejected with
4xx. - Stale timestamps (older than your skew window) are rejected to limit replay.
- A deliberately tampered payload returns
403in a test, and a real test event returns200.
Related
- Building an Idempotent Webhook Consumer — dedupe the verified events SendGrid redelivers
- Bounce and Complaint Handling — act on the verified bounce and complaint events
- ESP Selection and Integration — how SendGrid compares as a transactional provider
← Back to Building Webhook Event Pipelines