They Didn't Spoof Your Email. They Used It: When Notifications Become a Message Bus

The most convincing phishing email you will get this year will not come from a lookalike domain with a broken certificate. It will come from your own domain, with your DKIM signature valid and your sending reputation intact, because a stranger asked you to send it.
That is exactly what happened on DEV.to on November 4. A bot account published a post titled "Mandatory user verification," tagged hundreds of users in the comments ten at a time, and let the platform's own mention-notification system do the delivery. No spoofing. No domain trickery. The recipient's inbox showed a legitimate notification from a platform they trust, and the message inside pointed at a URL shortener that redirected to a pixel-perfect clone of the login page.
The community response was fast and mostly correct: check the profile, notice the name, don't click the shortener. But the fix that matters is not a user-education fix. It's a design fix. The scam worked because the mention feature is an outbound messaging product with no messaging controls, and most teams reading this have the same feature with the same gap.
The attack didn't break your trust boundary. It rented it.
Email authentication — SPF, DKIM, DMARC — answers one question: did this domain really send this message? It says nothing about whether the domain meant to send it, or whether the content inside was authored by someone trustworthy.
That is the whole trick. When you build a feature that lets user A cause a message to land in user B's inbox, you are lending A the one asset attackers cannot cheaply fake: your deliverability. Your domain's reputation is the product. Fan-out is the pricing model. And unless you've built controls around it, anyone who can type an @ can spend your reputation down to zero.
Once you frame it that way, the DEV.to incident stops looking like a moderation failure and starts looking like four separate engineering bugs that most codebases share.
Failure one: you counted requests instead of recipients
Rate limits are usually written against the thing the attacker does — N requests per minute. But the cost of a mention is not paid by the requester. It is paid by every recipient, in inbox clutter, in unsubscribe clicks, in spam complaints, and in the reputation of the sending domain.
The DEV.to bot tagged ten users per comment, in alphabetical order, walking the list. Ten mentions in one comment is one request and ten sends. From the API's perspective, nothing unusual happened. From the mail provider's perspective, a brand-new account generated hundreds of first-party emails in an hour.
Flip the denominator. Limit in recipients per account per day, not requests per minute, and gate that number on account age:
- Account under 24 hours old: 5 total recipients per day, 1 mention per comment, no bulk operations.
- Account under 7 days: 50 recipients per day, hard stop, no carryover.
- Established account with history: generous, but still a daily cap and a per-post cap.
Then add the rule that actually breaks the attack's economics: collapse fan-out into a digest. If one post mentions 300 people, that is 300 emails either way — but a digest keyed on the post ID makes the second, third, and fourth mention of the same post free, and makes the attacker's marginal cost per victim go to zero in the wrong direction. Even better: don't email mentions from accounts the recipient has never interacted with at all. Silent in-app only.
This is a real trade-off. Aggressive fan-out caps hurt legitimate launch announcements and community managers who genuinely need to reach a lot of people. The answer is not a single global number; it's a tiered policy with a manual override pathway that logs who used it and why.
Failure two: you stored the display name, not the identity
The fake account did not call itself "Dev Support Team." It called itself 𝘿𝙚𝙫 𝙎𝙪𝙥𝙥𝙤𝙧𝙩 𝙏𝙚𝙖𝙢, using Unicode mathematical alphanumeric characters that a human eye reads as ordinary bold letters and a naive string filter reads as nothing at all. Another writeup described a Cyrillic disguise: "Suрроrt Воt," where two letters are Cyrillic and the rest are Latin.
This is a homoglyph attack, and it is cheap because most systems never normalize.
The fix is boring and well-specified. Apply Unicode normalization form NFKC to display names at write time — not at render time. NFKC folds compatibility variants like the mathematical alphanumerics back to their ASCII skeletons, so 𝘿𝙚𝙫 becomes Dev before it ever touches a database row. Then run a confusable-skeleton check against a reserved list: your product name, your support email local part, and the handful of words users are trained to trust. Reject mixed-script names outright.
Two caveats worth stating plainly. First, NFKC is not free: it changes a small number of legitimate names, and the people affected will notice. Second, normalization at storage is a migration, and every existing row needs a backfill plus a grandfathering policy for accounts that would now fail. Do it anyway, because the alternative is that you are permanently unable to answer the question "is this account impersonating us?" with code.
And do it everywhere the name is rendered — including the From display name in the email itself. That field is where a homoglyph does the most damage, and it is frequently assembled by a template that never touched your validation layer.
Failure three: the moderation check ran at queue time, not send time
Here is the failure nobody demos. A notification is enqueued the moment the mention is created. It sits in a queue. A worker picks it up a few seconds — sometimes a few minutes, sometimes after a retry — later. In that window, forty people report the post and it gets flagged.
The email goes out anyway, because the decision to send was made before the decision to hide existed.
Notifying systems need a publishability predicate evaluated immediately before the send call, not when the job is created. For low-trust accounts, that check should fail closed: if you can't confirm the parent content is still live and unflagged, don't send. For verified organizations with a long clean history, fail open and log it, because you don't want a moderation database blip to swallow a legitimate password-adjacent notification.
You also need to be honest about the limits. Once an email is delivered, you cannot retract it. The best you get is a follow-up correction, which half the recipients will never read. That asymmetry — cheap to send, impossible to unsend — is the reason this check belongs in the send path and not in a cleanup job.
Failure four: you put a stranger's prose in your own template
Open any mention notification template and look at the body. Many of them quote the comment verbatim, or worse, render a URL the triggering user supplied.
Cut that. A transactional notification should carry a minimum of attacker-controlled content:
- A truncated, HTML-escaped excerpt — 120 characters, no formatting, no links.
- Exactly one actionable link, generated by you, pointing at your canonical domain.
- A static footer line that sets the baseline: we will never ask you to re-authenticate from a notification email.
That footer sounds like user education, but it's really a design constraint in disguise: the moment someone tries to add a call-to-action that contradicts it, you have to argue about it out loud.
The 30-minute inventory
You cannot fix what you have not listed. Sit down with your codebase and do this:
- Inventory every trigger. Grep for every send-mail call where the recipient is a user and the trigger is another user. Mentions, invites, shares, replies, "someone viewed your profile," DM digests, collaborator additions, comment replies. The list is longer than you think.
- For each one, write three numbers. Maximum recipients from a single action, maximum recipients per account per day, and time-to-suppress after a moderation action.
- Rank by recipients multiplied by trust in the triggering account. A verified org mentioning 200 users is a different risk class than a day-old account mentioning 10. Your worst entry is the one where both numbers are high.
- Fix the top three with the four controls: recipient-denominated limits, NFKC plus confusable rejection at write time, a send-time publishability check, and generated-only links.
Then instrument it. Track the share of your daily outbound mail volume that came from accounts younger than 24 hours. If it crosses a couple of percent on any given day, something is wrong and you want a human looking at it before your mail provider starts throttling you or your domain lands on a blocklist.
One more thing, and this one is for the engineering leaders rather than the coders: nobody owns this. Outbound mail triggered by user content sits in the seam between growth, trust and safety, and platform infrastructure, and in most organizations it is owned by all three and maintained by none. Assign it. Put a name on the inventory. The DEV.to scam cost the platform a day of cleanup and a chunk of user trust; the same class of bug in a product with password reset in the same sending domain costs considerably more.
The uncomfortable takeaway is not "add a scam warning banner." It's that a mention feature is an open relay with a nice UI. Treat it like one.
Key Takeaways
- Your deliverability is the asset. Email authentication proves your domain sent the message; it says nothing about whether the content deserves trust. Attackers borrow the first to get the second.
- Rate limit in recipients, not requests. One comment that tags 300 people is one request and 300 emails. Gate the daily recipient count on account age, and collapse repeat mentions of the same post into a digest.
- Normalize names at write time with NFKC. Mathematical alphanumerics and Cyrillic lookalikes fold to plain ASCII skeletons, which is what makes reserved-name and mixed-script checks possible at all. Apply the same treatment to email From display names.
- Check publishability at send time. A flag that lands 40 seconds after the job is queued is a flag that never happened. Evaluate moderation state immediately before the send call, fail closed for new accounts.
- Keep attacker prose out of the template. Truncated escaped excerpt, one generated link to your own domain, no user-supplied URLs, and a static line that you never ask for credentials from a notification.
- Instrument the share of mail from brand-new accounts. A single metric — percentage of outbound volume from accounts under 24 hours old — catches this class of abuse earlier than any complaint queue will.