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