Verifying Inbound Email Authenticity
Use validateEmailAuth and isTrustedSender to turn an inbound email's SPF/DKIM/DMARC results into a trust decision, anchored to the domain you expect the mail to come from.
validateEmailAuth and isTrustedSender, both exported from @primitivedotdev/sdk/api, turn the SPF, DKIM, and DMARC results Primitive already computed into a decision about whether an inbound email really came from the domain you expect. Use them before an inbound email triggers a privileged action such as issuing a refund, granting access, or replying with sensitive data.
If you just need the normalized email object, see Receiving Inbound Email.
Why the raw verdict isn't enough#
The bare verdict says whether an email authenticated at all, not which domain it authenticated as, so it can't answer "did this come from our domain?" on its own. Every email.received event carries the server's SPF, DKIM, and DMARC results on event.email.auth, and validateEmailAuth(event.email.auth) turns them into an email authenticity verdict: legit, suspicious, or unknown, with a confidence level and human-readable reasons.
A fully authenticated email from any domain, including one an attacker registered, returns legit. If your handler only checks the verdict, an attacker sending fully authenticated mail from a domain they control passes the check just as easily as your real partner does.
That's what domain-anchored sender trust (isTrustedSender) is for: it anchors the verdict to an expected From domain.
If you only need to know whether an email is spam-like or forged in general (no specific domain to check against), validateEmailAuth alone is enough. Reach for isTrustedSender the moment you're about to say "because this came from our domain."
Compute the bare verdict#
Call validateEmailAuth with the auth block off the raw event; it returns a verdict, a confidence level, and the reasons behind them.
import { validateEmailAuth } from "@primitivedotdev/sdk/api";
const result = validateEmailAuth(email.raw.email.auth);
console.log(result.verdict); // "legit" | "suspicious" | "unknown"
console.log(result.confidence);
console.log(result.reasons);
Both validateEmailAuth and isTrustedSender are importable from @primitivedotdev/sdk/api (not just the root import), so they also work inside Primitive Functions, which import from the Workers-safe /api subpath.
Anchor the verdict to a domain#
Call isTrustedSender(event, { domain, sender? }) with the raw email.received event and the domain you expect the From address to belong to.
- 1
Import isTrustedSender#
import { isTrustedSender } from "@primitivedotdev/sdk/api"; - 2
Call it with the raw event and the expected domain#
Pass
email.raw(the rawEmailReceivedEvent, not the normalizedReceivedEmail) and the domain you expect the sender to belong to:const trust = isTrustedSender(email.raw, { domain: "example.com" });Optionally pass
senderto require an exact address match, not just the domain:const trust = isTrustedSender(email.raw, { domain: "example.com", sender: "billing@example.com", }); - 3
Branch on the result#
if (trust.trusted) { // Authenticated mail whose From address is @example.com // (and, if you passed `sender`, exactly matches it) } else if (trust.retryable) { // Transient DNS failure during DMARC evaluation. Respond with a 5xx // so webhook redelivery retries this email later. } else { console.warn("Untrusted:", trust.reason, trust.auth.reasons); }Expected result:
trust.trustedistrueonly when all of the following hold:- the verdict is
legit - the domain DMARC evaluated equals
domain - the From header strict-parses to a single valid address in
domain(exactly matchingsenderwhen you passed one)
trust.reasonis a stable, machine-readable code naming the first check that failed, so you can branch on it without parsing message text. - the verdict is
Handle the retryable case correctly#
An unknown verdict can be temporary or permanent, so branch on trust.retryable before deciding whether to reject the email. unknown has two very different causes, and isTrustedSender separates them for you via trust.retryable:
| Cause | retryable | What it means |
|---|---|---|
| Transient DNS failure while evaluating DMARC | true | Retry later, the same email might resolve to legit or suspicious on redelivery |
| Sender domain publishes no DMARC record | false | Permanent for this email, retrying won't change the outcome |
If trust.retryable is true, respond to the webhook with a 5xx status instead of accepting it. Primitive's webhook redelivery will retry the email later, and by then the DNS failure may have cleared. Treating a retryable failure as a hard rejection can permanently drop mail that would have authenticated cleanly.
Don't build your own check from the raw From header#
Hand-rolled From-header checks are unsafe, because the header can carry an allowlisted address in its display name and the fields that look sender-identifying are attacker-controlled. If you're tempted to skip isTrustedSender and regex the From header yourself, three things will bite you:
- Display-name spoofing:
From: "trusted@example.com" <x@evil.com>puts an allowlisted address in the display name while DMARC evaluates, and can pass, forevil.com. A naive regex over the header text findstrusted@example.comand gets it wrong. - Sender-controlled fields are not authorization anchors:
email.replyTargetandemail.smtp.mail_fromare both fully sender-controlled. Never gate a decision on them. - The normalized
email.senderis for display only: it's parsed leniently and falls back to the SMTP envelope sender when the From header doesn't strict-parse, which is exactly the behavior you don't want for a security decision.
isTrustedSender requires the From header to strict-parse to a single valid address in the expected domain, so an ambiguous header fails the check instead of being guessed at. The SDK exports the same strict parser directly as parseFromHeader, which rejects multi-address and group-syntax headers outright.
The same helper exists with identical semantics in the Python SDK (is_trusted_sender, see Authenticating Senders with SPF, DKIM, and DMARC) and the Go SDK (IsTrustedSender, see Validating Email Authenticity).
Full example: gating a reply on sender trust#
This Next.js route handler receives an inbound email, retries on a transient DMARC DNS failure, skips untrusted senders, and replies only to authenticated mail from example.com.
import primitive from "@primitivedotdev/sdk";
import { isTrustedSender } from "@primitivedotdev/sdk/api";
export const runtime = "nodejs";
export const maxDuration = 300;
const client = primitive.client({
apiKey: process.env.PRIMITIVE_API_KEY!,
});
export async function POST(req: Request) {
const email = await primitive.receive(req, {
secret: process.env.PRIMITIVE_WEBHOOK_SECRET!,
});
const trust = isTrustedSender(email.raw, { domain: "example.com" });
if (trust.retryable) {
// Ask Primitive to retry delivery later.
return new Response("temporary auth failure", { status: 503 });
}
if (!trust.trusted) {
console.warn("Rejecting untrusted sender:", trust.reason);
return Response.json({ ok: true, skipped: true });
}
await client.reply(email, "Thank you for your email.");
return Response.json({ ok: true });
}
Next steps#
Learn the full ReceivedEmail shape that isTrustedSender's raw event comes from.
Webhook Signature VerificationVerify the Primitive-Signature HMAC header before you even get to auth verdicts.
Raw MIME Address Parsing (parser/address)See the strict From-header parser isTrustedSender relies on internally.
Handling Payment and Interaction Webhook EventsBranch on payment and interaction events from the same webhook endpoint.
Was this page helpful?