SSD Nodes Learn 🎉 VPS from $5.50/mo
Guides Matt ConnorBy Matt Connor

Stop subscription bombing on signup forms

An attacker submits a victim's address to hundreds of signup forms at once. Confirmed opt in and rate limits stop your server sending one of them.

What is subscription bombing?

Subscription bombing is an attack that uses your signup form to bury someone else's inbox. The attacker takes one victim's email address and submits it to hundreds or thousands of unprotected forms in a short window. Each of those sites sends a welcome message or a confirmation message to that address. Together those messages hide the mail the victim actually needs to read.

The target is the person who owns that inbox. While it fills with subscription confirmations, the attacker is spending money on that person's card or resetting a password on one of their accounts. The fraud alert from the bank still arrives. It arrives underneath two thousand other messages that landed in the same hour, so nobody sees it in time.

Your server is the tool the attack is built from. Nothing on your box is broken. No account of yours was compromised. Someone typed an address into a public form and your software did what it was written to do: it sent mail to that address. That is what makes this hard to notice. There is no intrusion in your logs, because there was no intrusion.

What the attack looks like from your side

It arrives in one of two shapes.

The loud shape is a burst. Several hundred POST requests hit one form inside a few minutes, from many different source IP addresses, carrying addresses at domains you have never sent to before. This one is easy to see once you look.

The quiet shape is the one that gets missed. The attacker holds a list of thousands of vulnerable forms, so your form only has to contribute one or two submissions per hour. Jye Cusch described an attack of exactly this shape on a site he runs: no traffic spike, just steady signups arriving at hours that did not match his audience. A single form looks innocent because a single form is doing almost nothing. The damage is the sum across every form on the attacker's list.

Both shapes share one signature afterwards: nothing happens next. The addresses never confirm. They never open a message and never click a link. On a confirmed opt-in list they sit at status unconfirmed forever, and that pile is the clearest evidence you will get.

Start by counting submissions per minute in your access log.

sudo awk '/POST \/subscription\/form/ {print substr($4, 2, 17)}' \
  /var/log/nginx/access.log | uniq -c | sort -rn | head

$4 in the default combined log format is the bracketed timestamp, so this prints a count for each minute, highest first. A form that normally takes four signups a day showing sixty in one minute is not having a good day.

Confirmed opt-in: the defence with the largest effect

Confirmed opt-in, usually called double opt-in, means an address is not a subscriber until a person clicks a link in a message sent to that address. Turn it on and one submitted address produces exactly one message, ever. The address never joins the list, so it never receives a campaign or a welcome sequence.

In listmonk, the self-hosted newsletter server this is a per-list setting: a list is single opt-in or double opt-in. The documentation is blunt about the difference. On a double opt-in list, subscribers "explicitly accept the subscription by clicking on the confirmation e-mail they receive. Until then, they do not receive campaign messages." A subscriber sits at unconfirmed, moves to confirmed on the click, and only confirmed subscribers on an opt-in list get campaign mail.

Be honest about what this buys you. Confirmed opt-in does not take your contribution to zero. It caps it at one message per address. The victim still receives that message, and one message from each of a thousand sites is the whole attack. What confirmed opt-in removes is everything after that: your list stays clean, and you never send a second message to someone who never asked for the first.

Two more settings matter and both are easy to forget. First, cap resends of the confirmation. If the same address can be submitted again and get another confirmation email each time, the attacker does not need a thousand forms, because your form alone will send a thousand messages. An address already sitting at unconfirmed on that list should get nothing further for at least a day. Second, delete unconfirmed rows on a schedule. An address that has not confirmed in thirty days is not a pending subscriber. Keeping it only creates a chance that something mails it by accident later.

Rate limit the signup form at the reverse proxy

Put the limit in front of the application rather than inside it. A request blocked at the proxy never opens a database connection and never starts an SMTP (simple mail transfer protocol) conversation. A limit inside the application runs after the request has already cost you a worker process and a query, and in many stacks the message is queued before any abuse check runs. The proxy limit also survives an application upgrade, because it does not live in code you replace.

The example below is nginx. The idea carries over to whichever reverse proxy you run in front of your app, though the directive names differ.

Put this in the http block, in a file such as /etc/nginx/conf.d/signup-limit.conf:

map $request_method $signup_key {
    POST    $binary_remote_addr;
    default "";
}

limit_req_zone $signup_key zone=signup:10m rate=2r/m;
limit_req_status 429;
limit_req_log_level warn;

The map is doing real work. nginx does not count a request whose key is an empty string, so only POST requests enter the zone. A reader loading the signup page several times spends nothing. Without that map, someone who refreshed the page twice would burn their own budget before they ever submitted anything.

$binary_remote_addr is the client address in packed form, which is why a 10 megabyte zone holds roughly 160,000 of them. rate=2r/m allows one submission every thirty seconds. limit_req_status 429 returns HTTP 429 Too Many Requests instead of nginx's default 503, which is the honest code and the one a client library expects.

Then in the server block for your site:

location = /subscription/form {
    limit_req zone=signup burst=3 nodelay;
    proxy_pass http://127.0.0.1:9000;
    proxy_set_header Host $host;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}

burst=3 nodelay lets a person who double-clicks the button through, and rejects the fourth request straight away instead of queueing it.

sudo nginx -t && sudo systemctl reload nginx

nginx -t should print configuration file /etc/nginx/nginx.conf test is successful. Now submit the form five times quickly and watch the error log:

sudo tail -f /var/log/nginx/error.log

A blocked request writes one line, and this is the string you are looking for:

2026/08/13 09:14:22 [warn] 812#812: *4412 limiting requests, excess: 3.400 by zone "signup", client: 203.0.113.10, server: news.example.com, request: "POST /subscription/form HTTP/1.1", host: "news.example.com"

No line at all means the limit is not being applied. The usual cause is that limit_req sits in a location block the request never reaches, so check with curl -si -X POST https://news.example.com/subscription/form a few times in a row and confirm you get a 429.

Two traps are worth knowing before you rely on a per-IP limit.

Behind a CDN or another proxy, $binary_remote_addr is that proxy. Every visitor lands in one bucket, so the first few submissions of each minute lock out everybody else. Fix it with the real IP module: set_real_ip_from for each of your CDN's published ranges (Cloudflare lists theirs at cloudflare.com/ips) and real_ip_header CF-Connecting-IP. Confirm the fix by reading $remote_addr in your access log and checking it is a visitor address rather than your CDN's.

IPv6 makes a per-address limit weak. $binary_remote_addr holds the full /128, and a residential IPv6 allocation is usually a /64 or larger. That is far more addresses than an attacker can work through, each with its own clean budget. Add a second zone as a ceiling on the endpoint itself, keyed on a constant, so the form has a total rate no matter how many source addresses are in play:

map $request_method $signup_total_key {
    POST    "signup";
    default "";
}

limit_req_zone $signup_total_key zone=signup_total:1m rate=30r/m;

Add limit_req zone=signup_total burst=10 nodelay; to the same location. Set the rate above your busiest real hour with room to spare. This is a blunt control: during an attack it turns away real signups too. That is the correct trade, because the alternative is your server sending the mail.

Why the per-address limit cannot live at the proxy

The email address arrives inside the POST body, and nginx does not parse request bodies. Every variable limit_req_zone can key on comes from the request line, the headers or the connection. So a rule like "this address may receive at most one confirmation per day" has to sit in the first component that reads the body, which is your application.

Do not work around this by moving the address into the query string so that $arg_email becomes available. That writes every subscriber's address into your access log in cleartext, and into any log shipper downstream of it. You would trade a rate limit for a privacy problem.

There is one real exception. The nginx JavaScript module, njs, can read the request body and set a variable from it, which does let you build a per-address key at the proxy. It is a genuine option and it is also new code in your request path. For most sites the per-address cap belongs next to the database that already knows whether this address has a pending confirmation, while the proxy handles the per-IP and per-endpoint limits it is good at.

Do not repeat submitted text back in the message

Keep every attacker-supplied string out of the message you send. There are two separate reasons and both have been used in the wild.

If your confirmation email greets the reader by a name taken from the form, the attacker writes their message into the name field. Your server then delivers that text to the victim, from your domain, signed with your DKIM (DomainKeys Identified Mail) key. Your site has become a delivery service for someone else's abuse, and the receiving provider sees your domain on it.

The second reason is worse. If any submitted field is concatenated into a mail header by hand, a newline character in that field adds headers of the attacker's choosing, including Bcc. Modern mail libraries reject newlines in header values. Code that pipes text into sendmail from a shell script often does not.

A safe confirmation message contains your site name and one link, with one sentence of explanation. The address itself appears only where the mail transfer agent needs it, in the To header. Test this: submit the form with a name field holding a newline and an obvious link, then read the raw message you receive with less and check that neither survived.

While you are there, make the success page say the same thing for every address. A page that says "you are already subscribed" for one address and "check your inbox" for another turns your form into a membership checker for anyone holding a list of addresses to test.

Which bot check should you use?

Pick for accessibility as hard as you pick for effectiveness. An image-selection captcha cannot be solved by a blind reader, and the audio fallback is hard for people with ordinary hearing. A check that costs a legitimate person their signup is a defence that is also a cost. Four options, in the order to try them.

Proof of work in the browser. The browser computes a hash the server can verify cheaply, and there is nothing for a person to solve. listmonk offers this under Settings, then Security, using ALTCHA, which needs no third party service. As of August 2026 that is listmonk's own recommendation over its deprecated hCaptcha option. The cost falls on whoever submits the most, which is the attacker.

A managed non-interactive check. Cloudflare Turnstile shows most visitors nothing at all and only challenges when its signals look bad. It is effective, and it puts a third party in your signup path.

A honeypot field. A text input that a person never sees and a naive bot fills in. Give it a name your form does not otherwise use, and set autocomplete="off", tabindex="-1" and aria-hidden="true" so a password manager does not fill it and a screen reader does not announce it. A field named email2 or address gets autofilled by the browser, and then you reject real people.

<div style="position:absolute; left:-9999px;" aria-hidden="true">
  <label for="hp_ref">Leave this field empty</label>
  <input type="text" id="hp_ref" name="hp_ref" autocomplete="off" tabindex="-1">
</div>

A time-to-submit check. Put a signed timestamp in a hidden field when the page renders, and reject a submission that arrives less than two seconds later. A person cannot read a form and type an address that fast. Sign the timestamp, or the bot will simply send an old one.

One thing to verify whichever you pick: the token must be consumed once. If a script can solve the check once and replay that token against a thousand addresses, the check proved that a browser ran once and nothing more.

How do you find out before the abuse report arrives?

You want your own graphs to tell you, not a hosting provider's abuse desk. Watch two things.

Count submissions per source address across the log:

sudo awk '/POST \/subscription\/form/ {print $1}' /var/log/nginx/access.log \
  | sort | uniq -c | sort -rn | head -20

Then let fail2ban read the same limiting requests lines nginx already writes, and ban the repeat offenders. fail2ban ships a filter for exactly this. Create /etc/fail2ban/jail.d/nginx-limit-req.local:

[nginx-limit-req]
enabled  = true
filter   = nginx-limit-req
port     = http,https
logpath  = /var/log/nginx/error.log
findtime = 600
maxretry = 10
bantime  = 3600
sudo systemctl reload fail2ban
sudo fail2ban-client status nginx-limit-req

The status output lists the jail's filter and its currently failed and banned counts. Currently banned: 0 on a quiet day is correct. If the jail does not appear at all, fail2ban never loaded the file, and sudo fail2ban-client -d | grep nginx-limit-req dumps the configuration it actually parsed. The shipped filter matches every limit_req zone. Narrow it to your signup zone by setting ngx_limit_req_zones = signup in a [Definition] section of /etc/fail2ban/filter.d/nginx-limit-req.local. The jail file layout and the ban commands are covered in more depth in the fail2ban guide for Ubuntu 24.04.

The second signal is a ratio and it needs no new software: submissions divided by confirmations. On a healthy list, most people who submit an address click the link, usually well over half of them. When that ratio collapses while submissions rise, you are being used. Compare the count of unconfirmed subscribers created in the last hour against the count of confirmed ones, on whatever schedule you already run reports.

What it costs you: sender reputation and blocklists

This is the part that turns an annoyance into a bill.

The address lists used for bombing are harvested, and harvested lists contain spamtraps: addresses that never signed up for anything anywhere, published only to catch senders who mail without permission. Your confirmation message reaches one. Some blocklist operators need nothing more than that.

Recipients who never asked for your message do not click unsubscribe. They click "report spam". Google's bulk sender rules, in force since February 2024, tell senders of 5,000 or more messages a day to Gmail to keep the reported-spam rate in Postmaster Tools below 0.3%. A smaller sender is not measured against that number, but the same complaint signal feeds the filtering decisions that drop your mail into the spam folder. The fake addresses in the run also bounce hard, and a rising hard-bounce rate is its own reputation signal at every large provider.

If you run your own mail server on a VPS with mailcow, the listing lands on your IP address and your domain. Delisting with an operator like Spamhaus means a form and a wait, and while you wait your invoices and your password resets are not being delivered either. If you send through a shared provider instead, expect them to suspend your account first and read your explanation second, because your traffic is a risk to every other sender on that IP.

Set against that, the work is small. Turn on confirmed opt-in today, because it is one setting per list. Add the proxy rate limit next, because it is one file and a reload. The bot check and the alerting can follow this week.

FAQ

Does double opt-in stop subscription bombing?

It stops your list being polluted and it caps your contribution at one message per submitted address, which is the largest single improvement available to you. It does not stop the victim's inbox filling, because the attack is the sum of one message from each of a thousand sites. Pair it with a per-IP rate limit at your proxy and a cap on confirmation resends, so the same address submitted twice does not produce a second message.

How do I tell a bombing run from a good day of real signups?

Look at what happens after the submission. Real signups confirm, and they usually confirm within hours. A bombing run leaves a pile of addresses that never confirm, never open and never click. The submissions cluster oddly too: many source addresses you have never seen, recipient domains you do not usually send to, and arrival times spread evenly across the whole day instead of following your audience's waking hours.

Should I delete the addresses that were submitted?

Yes. Delete unconfirmed records older than about thirty days, and do it on a schedule rather than by hand. Never send those addresses anything else, including an apology or a "was this you?" message, because that is a second unasked-for message to someone who has already been buried in them. If any of those addresses were spamtraps, a follow-up is the confirmation the blocklist operator is waiting for.

Will rate limiting reject real subscribers?

A per-IP limit of one submission every thirty seconds with a burst of three is invisible to a person filling in a form once. It becomes visible when many real people share one address, such as an office behind a single NAT (network address translation) gateway, or when your proxy sees your CDN's address instead of the visitor's. Read $remote_addr in your access log before you tighten anything, and keep the endpoint ceiling above your busiest real hour.

My sending IP is on a blocklist after a run. What do I do first?

Stop sending from it before you request anything. Pause the campaign queue, fix the form, and delete the unconfirmed addresses, because a delisting followed by more of the same traffic gets you relisted faster than the first time. Then find which list you are on, since most operators have a lookup page keyed on your IP address, and follow their removal process. Expect the wait to be measured in days, and use that time to confirm your SPF (sender policy framework) record and your DKIM signing both still pass.

#email#double-opt-in#rate-limiting#abuse#deliverability