PubTrivia has no mailbox. Every address at the domain is a forwarding alias, and the thing doing the forwarding is a Next.js route handler 121 lines long. The FAQ ends with support@pub-trivia.app in its "Not answered here?" block, and the privacy policy publishes privacy@pub-trivia.app, because a data request has to go somewhere. Mail to either one arrives at Resend, which POSTs a webhook, and the route forwards it on.
That is a small amount of code with an unusual property: it is a public URL on a public domain that calls an API which sends email from our own verified sending domain. Here is what it got wrong, and the shape of the mistake is more useful than the specifics.
The fallback that was a second door
The original flow read like a reasonable piece of defensive code:
let payload
if (webhookSecret && svixId && svixTimestamp && svixSignature) {
payload = verify(...)
} else {
payload = JSON.parse(rawBody) // no
}
Verify the signature when you can, parse the body when you cannot. Written out as an if, it even looks careful. What it actually says is: a caller who presents a valid signature gets in, and a caller who presents no signature at all also gets in.
Leaving a header out is not hard. Any curl that does not set svix-signature took the else branch, and the else branch runs the same handler as the verified one:
async function handleEmailReceived(data) {
const { error } = await getResend().emails.receiving.forward({
emailId: data.email_id,
to: FORWARD_TO,
from: FORWARD_FROM,
})
}
So an unauthenticated stranger could call a send API on our account with a payload they control. Two things fall out of that. The first is quota: every request is a billable call to a third party, and the loop that makes it is one line of shell. The second is worse, because FORWARD_FROM is an address on our verified domain, which means the mail that lands in the forwarding inbox is mail our own domain sent and signed. That is the kind of thing that ends with a sending reputation nobody will take back.
And there was nothing upstream to slow it down. Our proxy matcher excludes /api entirely, by design, because webhook senders have no cookies and no session to refresh. The global rate limiter lives in that same proxy. No session refresh and no rate limit are the same exclusion, so this route had neither.
What it looks like now
There is no unverified path. The secret being absent is a configuration failure and answers 500. The headers being absent is a caller failure and answers 401.
if (!webhookSecret) {
console.error('[resend-webhook] RESEND_WEBHOOK_SECRET is not set, rejecting')
return NextResponse.json({ error: 'Webhook not configured' }, { status: 500 })
}
if (!svixId || !svixTimestamp || !svixSignature) {
return NextResponse.json({ error: 'Missing signature headers' }, { status: 401 })
}
try {
payload = getResend().webhooks.verify({
payload: rawBody,
headers: { id: svixId, timestamp: svixTimestamp, signature: svixSignature },
webhookSecret,
})
} catch {
return NextResponse.json({ error: 'Invalid signature' }, { status: 401 })
}
The two status codes being different matters more than it looks. A 500 on the missing secret is a page for us: the deploy is broken, the sender will retry, and nothing has been let through. A 401 is a page for nobody, because it is the normal answer to a stranger. If both cases returned 401 we would have no signal at all that a deploy had lost its secret, and the only symptom would be forwarded mail quietly stopping.
The body is read with await request.text() before anything else, and the raw string is what gets verified. Reading it as JSON first and re-serialising it would change bytes, which changes the signature, which fails for reasons that take an afternoon to find.
Verified is not the same as well formed
A signature proves who sent the body. It proves nothing about what is in it. The type guard sits between the verification and the dispatch, on its own:
if (typeof payload !== 'object' || payload === null || typeof payload.type !== 'string') {
return NextResponse.json({ error: 'Malformed payload' }, { status: 400 })
}
That check used to live inside the try/catch below it, where a payload with no type threw and came back as a 500 with a stack trace in the logs. Same event, two different answers: 400 says the request was understood and refused, 500 says we fell over. Webhook senders treat those differently, and so does anybody reading the logs at midnight.
The switch lists the events it ignores
The handler acts on exactly one event type. It still names the other four:
switch (type) {
case 'email.received':
await handleEmailReceived(data)
break
// Delivery events, logged automatically, no action needed
case 'email.delivered':
case 'email.bounced':
case 'email.delivery_delayed':
case 'email.complained':
break
default:
break
}
The four no-op cases are redundant next to default: break, and they stay because the file is the only place in the repository that says what this webhook actually receives. When email.bounced eventually needs to mark an invite as undeliverable, the case to fill in is already sitting there with a name.
The filter that could not be written on the subject line
One real piece of domain knowledge in the file. Publishing a domain means receiving DMARC aggregate reports, which are XML attachments, daily, from every large mail provider. They should not be forwarded to a human.
The obvious filter is on the subject, and it does not work. Google's aggregate report subject does not contain the word DMARC at all; it is a report domain and a submitter and an identifier. The only reliable signal is the address it was sent to, because the address is dmarc@ and we chose it.
const to = Array.isArray(data.to) ? data.to : [data.to]
if (/dmarc/i.test([subject, ...to].join(' '))) return
Subject and recipients joined into one string and tested once. The subject stays in the test because some providers do say DMARC there, and the recipient list is what catches the rest. It returns rather than throwing, because an unforwarded report is not a failure, it is the point.
Two small things worth copying
The Resend client is built inside a function, not at module scope:
function getResend() {
return new Resend(process.env.RESEND_API_KEY)
}
Module scope runs during the build, when the key is not necessarily in the environment, and a constructor that throws at import time fails a page collection pass rather than a request. One function wrapper and the module is safe to import anywhere.
And the route declares its own caching and duration:
export const dynamic = 'force-dynamic'
export const revalidate = 0
export const maxDuration = 30
A cached webhook response is a special kind of bad, because the sender gets a 200 that your handler never produced. The 30 seconds is for the forward path specifically: the SDK's forward() helper fetches the original message with its attachments and posts it on, and a DMARC report from a big provider is not a small file.
What the mail is actually for
Hardly any of PubTrivia's email is marketing. The product sends an invite when a venue owner adds a second person who is allowed to run the quiz, which is the whole of the multi host feature, and the auth provider sends password resets. Everything else is inbound: a landlord replying to an invite, somebody asking a question from the address on the FAQ, a privacy request to the address in the policy.
Which is why the route is worth 121 lines. It is the only way a customer can reach a human, and until the fallback came out it was also the only part of the stack a stranger could make send mail as us.
The general rule, stated as plainly as I can: an or between a verified path and an unverified path is not a fallback. It is a second door, and attackers read your code for exactly that shape. Missing configuration denies.
Top comments (0)