SSD Nodes Learn 🎉 VPS from $5.50/mo
How to do am Matt ConnorBy Matt Connor

How to Stop Subscription Bombing for Signup Forms

Subscription bombing fit bury person inbox with thousands emails. Confirmed opt-in and rate limits stop your server from sending signup messages to attackers’ targets.

Wetin be subscription bombing?

Subscription bombing na attack wey use your signup form bury another person inbox. Attacker go take one victim email address submit am to hundreds or thousands unprotected forms within short time. Each website go send welcome message or confirmation message go that address. All the messages together go hide the mail wey victim really need read.

The target na person wey own that inbox. As subscription confirmations dey fill the inbox, attacker dey spend money from that person card or reset password for one of their accounts. Fraud alert from bank still go arrive. E go land underneath two thousand other messages wey enter within the same hour, so nobody go see am on time.

Your server na the tool wey attack dey use. Nothing for your box spoil. No account wey belong to you get compromised. Somebody type an address into public form, and your software do wetin dem write am to do: e send mail go that address. Na this make the attack hard to notice. No intrusion dey your logs because no intrusion happen.

Wetin the attack dey look like for your side

E dey come in one of two forms.

The loud form na burst. Several hundred POST requests go hit one form within few minutes, from plenty different source IP addresses, carrying addresses for domains wey you never send to before. This one easy to see once you check.

The quiet form na the one people dey miss. The attacker get list of thousands vulnerable forms, so your form only need contribute one or two submissions every hour. Jye Cusch describe an attack with exactly this form for one site wey e dey run: no traffic spike, just steady signups dey arrive for hours wey no match the audience. One form look harmless because one form almost no dey do anything. The damage na the total across every form for the attacker's list.

Both forms get the same signature afterward: nothing happen next. The addresses never confirm. Dem never open any message and never click any link. For confirmed opt-in list, dem remain for status unconfirmed forever, and that pile na the clearest evidence wey you go get.

Start by counting submissions per minute for your access log.

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

$4 for the default combined log format na the timestamp inside brackets, so this command go print count for each minute, highest one first. If form wey normally get four signups per day suddenly show sixty for one minute, that no be normal day.

Confirmed opt-in: defence wey get biggest effect

Confirmed opt-in, wey dem usually dey call double opt-in, mean say address no be subscriber until person click link for message wey dem send go that address. Turn am on, and one address wey person submit go produce exactly one message, ever. Address no go join list, so e no go receive campaign or welcome sequence.

For listmonk, self-hosted newsletter server this na setting for each list: list fit use single opt-in or double opt-in. Documentation explain the difference clearly. For 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." Subscriber dey for unconfirmed first, e move go confirmed after the click, and na only confirmed subscribers for opt-in list dey receive campaign mail.

Make you talk true about wetin this one dey solve. Confirmed opt-in no make your contribution become zero. E limit am to one message for each address. Victim still go receive that message, and one message from each of one thousand sites fit complete the attack. Wetin confirmed opt-in remove na everything wey happen after that: your list go remain clean, and you no go ever send second message to person wey no ask for the first one.

Two more settings matter, and people fit easily forget both. First, limit how many times dem fit resend the confirmation. If person fit submit the same address again and receive another confirmation email every time, attacker no need one thousand forms, because your form alone go send one thousand messages. Address wey already dey for unconfirmed on that list suppose receive nothing else for at least one day. Second, delete unconfirmed rows based on schedule. Address wey never confirm after thirty days no be pending subscriber. Keeping am only create chance say something go mail am by mistake later.

Limit signup form rate for reverse proxy

Put the limit for front of the application instead of inside am. If proxy block request, e no go open database connection and e no go start SMTP (simple mail transfer protocol) conversation. Limit wey dey inside application run after request don already use worker process and query, and for many stacks, message don enter queue before any abuse check run. Proxy limit still dey work after application upgrade, because e no dey inside code wey you replace.

The example below na nginx. The idea still apply to any reverse proxy wey you run for front of your app, even though directive names dey different.

Put this for the http block, inside file like /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 dey do real work. nginx no dey count request wey key na empty string, so na only POST requests enter the zone. If reader load signup page several times, e no spend anything. Without that map, person wey refresh page twice for don use im own budget before e ever submit anything.

$binary_remote_addr na client address for packed form, na why 10 megabyte zone fit hold around 160,000 of dem. rate=2r/m allow one submission every thirty seconds. limit_req_status 429 return HTTP 429 Too Many Requests instead of nginx default 503. Na the correct code, and na the one client library dey expect.

Then for 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 allow person wey double-click button to pass, and e reject the fourth request immediately instead of putting am for queue.

sudo nginx -t && sudo systemctl reload nginx

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

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

Blocked request go write one line, and na this string you dey look 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"

If no line show at all, limit no dey apply. The usual cause na say limit_req dey inside location block wey request no reach, so use curl -si -X POST https://news.example.com/subscription/form several times one after another and confirm say you get 429.

Two traps dey worth knowing before you depend on per-IP limit.

Behind CDN or another proxy, $binary_remote_addr na that proxy. Every visitor go enter one bucket, so first few submissions for each minute go lock out everybody else. Fix am with real IP module: set_real_ip_from for each published range of your CDN (Cloudflare list their own for cloudflare.com/ips) and real_ip_header CF-Connecting-IP. Confirm the fix by reading $remote_addr for your access log and checking say na visitor address e show, not your CDN address.

IPv6 make per-address limit weak. $binary_remote_addr hold the full /128, and residential IPv6 allocation usually na /64 or bigger. That one get far more addresses than attacker fit use, with clean budget for each one. Add second zone as ceiling for the endpoint itself, keyed on constant, so form get total rate no matter how many source addresses dey involved:

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 extra space. This control no fine-grained: during attack, e go also turn away real signups. Na the correct trade-off, because the other option na your server sending the mail.

Why per-address limit no fit dey for proxy

The email address dey inside the POST body, and nginx no dey parse request bodies. Every variable wey limit_req_zone fit use as key dey come from request line, headers, or connection. So rule like “this address fit receive only one confirmation per day” gatz dey inside the first component wey read the body, wey be your application.

No try bypass this by moving the address go query string so $arg_email go become available. This one go write every subscriber address for your access log as cleartext, and for any log shipper wey dey downstream from am. You go exchange rate limit for privacy problem.

One real exception dey. nginx JavaScript module, njs, fit read request body and set variable from am. This one allow you build per-address key for proxy. E be real option, but e also add new code to your request path. For most sites, per-address cap suppose dey near the database wey already know whether this address get pending confirmation, while proxy handle per-IP and per-endpoint limits wey e good at.

No repeat the text person submit inside the message wey you send

Keep every string wey attacker supply out of the message wey you send. Two separate reasons dey, and attackers don use both for real attacks.

If your confirmation email greet reader with name wey come from the form, attacker fit write their message inside the name field. Your server go then deliver that text to the victim from your domain, signed with your DKIM (DomainKeys Identified Mail) key. Your site don become delivery service for another person abuse, and the receiving provider go see your domain for the message.

The second reason worse. If you join any submitted field into mail header by hand, newline character inside that field fit add headers wey attacker choose, including Bcc. Modern mail libraries reject newlines for header values. Code wey pipe text go sendmail from shell script often no dey do that.

Safe confirmation message get your site name and one link, with one sentence of explanation. The address itself suppose appear only where mail transfer agent need am, inside the To header. Test am like this: submit the form with a name field wey hold newline and obvious link. Then read the raw message wey you receive with less, and check say none of dem remain.

As you dey do this, make the success page show the same thing for every address. Page wey say "you don already subscribe" for one address and "check your inbox" for another one fit turn your form into membership checker for anybody wey get list of addresses to test.

Wetin bot check you suppose use?

Choose am based on accessibility with the same seriousness wey you use for effectiveness. Person wey no see no fit solve image-selection captcha, and audio fallback hard for people wey get ordinary hearing. If check make legitimate person abandon signup, na defence wey still get cost. Here be four options, in the order to try dem.

Proof of work for browser. Browser go compute hash wey server fit verify cheaply, and person no get anything to solve. listmonk dey offer this under Settings, then Security, with ALTCHA, wey no need third party service. As of August 2026, na listmonk own recommendation instead of the deprecated hCaptcha option. The cost go fall on whoever submit pass, and na attacker be that person.

Managed non-interactive check. Cloudflare Turnstile no dey show most visitors anything, and e only challenge visitor when its signals look bad. E effective, but e put third party inside your signup path.

Honeypot field. Na text input wey person no dey see but naive bot go fill. Give am name wey your form no dey use anywhere else, then set autocomplete="off", tabindex="-1" and aria-hidden="true" so password manager no go fill am and screen reader no go announce am. Field wey get name email2 or address browser go autofill, and you go 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>

Time-to-submit check. Put signed timestamp for hidden field when page render, then reject submission wey arrive less than two seconds later. Person no fit read form and type address that fast. Sign the timestamp, otherwise bot go simply send old one.

One thing you must verify, no matter which one you choose: token must dey consumed once only. If script fit solve check once and replay that token against one thousand addresses, the check only prove say browser run once, and nothing more.

How you fit find out before abuse report land?

You want your own graphs to tell you, no be hosting provider abuse desk. Monitor 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 make fail2ban read the same limiting requests lines wey nginx already dey write, and ban repeat offenders. fail2ban get 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 go list the jail filter and the failed and banned counts wey e get now. Currently banned: 0 for a quiet day dey correct. If the jail no show at all, fail2ban no load the file, and sudo fail2ban-client -d | grep nginx-limit-req go dump the configuration wey e actually parse. The shipped filter dey match every limit_req zone. Narrow am to your signup zone by setting ngx_limit_req_zones = signup inside a [Definition] section of /etc/fail2ban/filter.d/nginx-limit-req.local. The jail file layout and ban commands dey explained more deeply for the fail2ban guide for Ubuntu 24.04.

The second signal na ratio, and e no need new software: submissions divided by confirmations. For healthy list, most people wey submit address go click the link, usually well over half of dem. When that ratio drop sharply while submissions dey rise, dem dey use you. Compare the count of unconfirmed subscribers wey dem create for the last hour with the count of confirmed ones, for any schedule wey you already dey use run reports.

Wetin e go cost you: sender reputation and blocklists

Na this part dey turn small wahala into bill.

Dem dey harvest the address lists wey dem use for bombing. Harvested lists dey contain spamtraps: addresses wey nobody ever sign up for anything with anywhere. Dem publish am only to catch senders wey dey send mail without permission. Your confirmation message reach one of dem. Some blocklist operators no need anything pass that.

Recipients wey no ask for your message no dey click unsubscribe. Dem dey click "report spam". Google's bulk sender rules, wey start to apply since February 2024, tell senders wey dey send 5,000 or more messages every day to Gmail make dem keep reported-spam rate for Postmaster Tools below 0.3%. Smaller sender no dey measured against that number, but the same complaint signal still enter the filtering decisions wey send your mail go spam folder. The fake addresses for the run go bounce hard too, and rising hard-bounce rate na separate reputation signal for every major provider.

If you run your own mail server for VPS with mailcow, the listing go land for your IP address and your domain. To delist with operator like Spamhaus, you go fill form and wait. While you dey wait, your invoices and password resets no go deliver too. If you send through shared provider instead, expect dem to suspend your account first and read your explanation later, because your traffic na risk to every other sender for that IP.

Compared with that, the work small. Turn on confirmed opt-in today, because na one setting for each list. Add the proxy rate limit next, because na one file and one reload. The bot check and alerting fit follow this week.

FAQ

Double opt-in fit stop subscription bombing?

E fit stop your list from getting polluted, and e limit your own contribution to one message for each address wey person submit. Na the biggest single improvement wey dey available to you. But e no go stop the victim inbox from filling up, because the attack na the total of one message from each of one thousand sites. Combine am with per-IP rate limit for your proxy and limit on confirmation resends, so submitting the same address twice no go produce another message.

How I fit know subscription bombing run from one good day of real signups?

Check wetin happen after submission. Real signups dey confirm, and dem usually confirm within hours. Bombing run dey leave plenty addresses wey never confirm, never open, and never click. The submissions too dey form strange pattern: many source addresses wey you never see before, recipient domains wey you no normally send to, and arrival times wey spread evenly across the whole day instead of following when your audience dey awake.

I suppose delete the addresses wey people submit?

Yes. Delete unconfirmed records wey don pass about thirty days, and do am on schedule instead of by hand. Never send anything else to those addresses, including apology or a "was this you?" message, because that one na another message wey the person no ask for, after dem don already receive plenty of them. If any of those addresses na spamtraps, follow-up na the confirmation wey the blocklist operator dey wait for.

Rate limiting go reject real subscribers?

Per-IP limit of one submission every thirty seconds, with burst of three, no go affect person wey fill form once. E go show when many real people share one address, like office wey dey behind one NAT (network address translation) gateway, or when your proxy dey see your CDN address instead of the visitor address. Read $remote_addr for your access log before you make the limit tighter, and keep the endpoint ceiling above your busiest real hour.

My sending IP dey on blocklist after one run. Wetin I suppose do first?

Stop sending from am before you request anything. Pause the campaign queue, fix the form, and delete the unconfirmed addresses, because delisting followed by more of the same traffic go make dem relist you faster than the first time. Then find the list wey carry your IP, because most operators get lookup page wey you fit use with your IP address, and follow their removal process. Expect the wait to take days, and use that time confirm say your SPF (sender policy framework) record and DKIM signing still dey pass.

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