A product I work on lets an admin invite teammates. You type some email addresses, we send each one a templated message, they click a link and join your workspace. Every B2B SaaS has this feature. It is not where you look for trouble.
Between 22 and 28 July, three throwaway signups used it to send 127,158 phishing emails — 130,675 deliveries counting resends — from a domain with valid SPF and DKIM, to a purchased list of US consumer mailboxes.
Nothing was breached. No account was taken over, no customer data moved, and not one invitation was ever accepted. The three workspaces they created contain zero contacts, zero deals, zero connected mailboxes. They were never used as a CRM. They were used as a mail relay, because that is what we had built.
Two framing notes before the numbers, because they change how you should read this.
Authorisation was never going to be the control here, and I wasted an hour assuming otherwise. The endpoint checked that the caller was an admin. It passed, correctly, every time.
Every guard that should have stopped this already existed. A rate limiter, a seat cap, an anomaly detector. All three were present, all three reported healthy, and all three were structurally incapable of firing. That is the part that generalises.
Names, identifiers and product details are omitted — this was a client engagement — but every number is as measured in the system's own database.
The shape of the burst
The first thing I ran was a count by day. Do this before any theorising, because the shape tells you what kind of problem you have.
date | invitations
------------+-------------
2026-07-23 | 67,068
2026-07-24 | 30,600
2026-07-25 | 12,452
2026-07-29 | 20,537
everything else, all month | <= 17
The rest of the platform — every real customer combined — sent 64 invitations that month. Peak hour was 14,873.
A burst that arrives in one minute is a retry storm. A burst sustained for twenty-two hours at a steady rate is somebody working.
The second query said who:
workspace | invitations | accepted | contacts | deals | mailboxes
-----------+-------------+----------+----------+-------+-----------
A | 63,668 | 0 | 0 | 0 | 0
B | 42,953 | 0 | 0 | 0 | 0
C | 20,537 | 0 | 0 | 0 | 0
Zero accepted, zero of everything else. Nobody was trying to use the product. And the batch sizes gave up the automation immediately:
invites per API call | number of calls
----------------------+-----------------
25 | 2,984
24 | 596
23 | 436
22 | 310
The endpoint caps a batch at 25. 2,984 requests landed exactly on the cap. A human clicking a button does not produce that histogram. At minimum 5,089 requests hit an endpoint that had no rate limit.
The payload was in a field we asked them to fill in
Here is the part I did not expect, and the reason this post exists.
The scam was not in an attachment or a link. It was in the workspace name.
The mailer did this:
subject: "#{@inviter.full_name} invited you to #{@tenant.name}"
Both halves are user-authored. The attackers named their workspace, and themselves, with a tech-support refund scam — the "your subscription will renew for $497, call this number to cancel" genre. So the subject line landing in 127,158 inboxes was a complete scam message, phone number included, delivered by a domain we had carefully authenticated so that it would land.
We had spent real effort on SPF, DKIM and DMARC. That effort worked perfectly.
There is a corollary that cost me a second pass. My first fix sanitised the workspace name and left the inviter name alone. That buys nothing — the attacker controls both, and half a subject line is still a whole subject line. Either bound every user-controlled string that reaches a subject, or make the subject static. I made it static.
Why each guard couldn't fire
The rate limit didn't cover the path
The rate limiter had a rule for bulk endpoints. It listed the bulk paths for contacts, deals and companies. The invitations path was not in the list.
Nothing errors when a rate limit matches nothing. It just sits in the file looking like coverage.
The seat cap was wired to a billing flag
There was a quota check. It began:
return false unless Billing::Enforcement.enabled?
Billing enforcement is off in production, deliberately, until pricing ships. So
the quota check returned false — not exceeded — on every call.
This is the one I'd tattoo somewhere. An abuse limit must never depend on billing enforcement, a plan catalog, or an environment flag. Those protect revenue. Abuse limits protect third parties who never signed up for anything, and they have to hold when the commercial machinery is switched off.
The anomaly detector wrote 4,226 rows to nobody
The system did notice. While the three accounts were blasting, it wrote 4,226
session_anomaly audit rows — 2,988 on one account alone. Every one persisted
correctly.
Nothing consumed them. No alert, no threshold, no digest.
And when I went to wire something up, I found the existing alert path routed to the tenant's own admins — who, in this incident, were the attackers.
A detector with no consumer is a log file with extra steps.
The only real ceiling was per-request
MAX_INVITES = 25. That is a batch size, not a rate. It bounds one call and
says nothing about five thousand of them. If your only limit is per-request, you
do not have a limit.
Fixing the rate limit exposed a worse bug
I added throttles to the invite paths and wrote a spec that drove real requests past the limit and asserted a 429. That spec failed in a way that had nothing to do with invitations.
The discriminator — the function deciding which bucket a request counts against — resolved the current user like this:
warden.user
warden.user reads the session. It does not run strategies. And in this app
both the cookie and JWT paths authenticate inside the controller — which runs
after the rate-limiting middleware.
So warden.user was nil for essentially every real request, and every
discriminator fell through to the client IP.
All sixteen throttles documented "per authenticated user" were sharing one bucket per IP. Everyone behind a company NAT competed for one budget. Nobody was isolated from anybody. It had been in production for months and reads perfectly in code review.
That is the lesson under the lesson. The bug was not in the limits. It was in the identity the limits counted against, and no amount of staring at the config would surface it. What surfaced it was one spec that drove a real session cookie and asserted a real rejection.
Three more things that were quietly wrong
Once you have the "who can write this, who receives it" lens, you use it on everything nearby.
Windows are fixed, not sliding. The limiter keys its bucket on
Time.now.to_i / period, so a patient attacker gets roughly double the nominal
limit — I wrote up that arithmetic and the fix in
your rate limiter's fixed window is a 2× burst.
What I hadn't appreciated until this week is that it also makes your tests
flaky: a spec firing 31 requests at a 30-per-minute limit fails whenever the
loop straddles a boundary, at a rate of loop duration ÷ 60 seconds. Invisible
on a laptop, routine on a loaded CI runner. Freeze time in the spec.
The counter store was per-process. It was an in-memory store whose comment
claimed production swapped in a shared one. Nothing swapped it. It happened to
be correct — one web process, no concurrency setting — which is a different
thing from being right. Every deploy reset every counter, which the daily
ceilings felt most. The trap when fixing it: don't fall back to a store whose
increment is a read-modify-write with no lock. You would trade a correct
per-process counter for an incorrect shared one.
A public booking endpoint let a stranger choose a mail subject. Unrelated
feature, same shape: it permitted a title parameter, and that title reached
five mail subjects. An unauthenticated visitor could pick both the subject line
and the recipient address. This incident, with no signup step and no account
to suspend afterwards. The frontend had never sent the field. It was pure
surface, sitting there.
Containment, and the guard that fought me
Suspending the three accounts should have been one method call. It failed on all three:
Validation failed: cannot remove the tenant's last active admin
Each spam workspace had exactly one user. The guard that stops a legitimate workspace locking itself out also stops you freezing a single-admin workspace that is the threat.
I ended up bypassing model validation on production to write the suspension directly. That worked, and it is not a thing anyone should be improvising during an incident. The real fix is a named operator path that waives the guard, audited and behind step-up auth — because freezing a workspace's last admin is a tenant suspend by another name.
Every lockout guard needs a documented escape hatch, or the operator will invent one under pressure.
Then the 127,000 outstanding invite links. Those had to die without disturbing the database, on a small burstable instance. The move is boring and worth stating: never do it as one statement. A single 127k-row update holds locks, bursts the write-ahead log, and hands the vacuum process a large mess while you're on borrowed CPU. Batched a thousand at a time with a pause and a lock-contention check between batches, it took four minutes and the host load never went above 0.03.
Expiring them turned out to close the phishing page too. The accept action returns a generic error for an expired invite before it renders anything, so the scam text stopped displaying on our domain as a side effect of the same loop.
What I'd tell you to go check
Not "add a rate limit". You probably have rate limits. Check these instead.
- List every endpoint that sends mail on user-supplied text. Invites, shares, quotes, booking confirmations, notifications. For each one: can the caller choose the recipient? Can the caller write text that reaches the subject or body? No rate limit on that combination means you have a relay.
- For each rate limit, name a request that trips it. If you cannot, it does not exist. Then write the spec that actually trips it — including one that proves two different users land in different buckets.
- Grep for abuse controls behind a billing or environment flag. They will look like protection and behave like nothing.
- For every detector, name its consumer. If the answer is "it writes an audit row", you do not have a detector.
- For every lockout guard, name the escape hatch.
The thing I keep coming back to is that this was not a story about a clever attacker. Every condition was something we had already built a control for. The controls were the wrong shape, gated on the wrong flag, or counting the wrong identity — and all of them reported healthy right up to the day someone sent a hundred and thirty thousand phishing emails through them.
The question is not "do we have a limit here". It is: has anyone ever watched this limit reject a request? Until then it is a hypothesis with good syntax highlighting.