Subscription bombing turns your newsletter form into a weapon pointed at someone else. A bot takes one victim’s email address and submits it to thousands of sign-up forms across the web in a few minutes. The victim’s inbox fills with “please confirm your subscription” messages. Often that is the point: the flood buries a real email, such as a purchase receipt or a password-reset notice, from an account the attacker is busy taking over.
Your site is not the target, but it pays a price. Each confirmation you send goes to someone who never asked for it. They hit “report spam,” mailbox providers notice, and the reputation of the domain that sends your real newsletter takes the damage.
Why the usual fixes fall short
Double opt-in is good practice and keeps the victim off your list. It does nothing for the flood, because the confirmation email is the flood.
Rate limiting by IP helps a little. Bombing tools spread submissions across many forms and often across proxies, so each form sees only a submission or two per victim, from addresses that look clean.
Rate limiting by email address is better: the same address should rarely subscribe twice in an hour. But bombing works across many sites, so your form may see the victim’s address only once. You still sent one unwanted email, and multiplied across every site that’s targeted, those are what bury the victim.
A visible CAPTCHA cuts bot submissions but also cuts real sign-ups, and newsletter forms are exactly where friction is most expensive. See stopping contact form spam without a CAPTCHA for the trade-off.
The goal is to decide, before any email is sent, whether a human typed this address into this form.
Signals that separate a person from a bomber
Bombing tools are built for volume: submit to as many forms as possible as fast as possible. That leaves traces.
- Form behaviour. The form is submitted seconds after it renders, with no focus or keystrokes, and hidden fields get filled. Prynt’s
protectForm()captures these as honeypot, timing and interaction signals. - Automation fingerprints. Headless browsers and HTTP clients give themselves away through
BOT,TLS_AUTOMATION(a JA4 TLS fingerprint that doesn’t match a real browser) andAUTOMATION_BEHAVIOR. - Network. Submissions from cloud hosting ranges (
DATACENTER) or rotating residential proxies (RESIDENTIAL_PROXY). - Device velocity. The same device submitting several different email addresses in a short window. A person subscribes themselves, once.
None of these require the subscriber to do anything.
Protect the form
protectForm() adds an invisible honeypot field, stamps the render time and watches for real interaction. With autoGuard, it intercepts the submit, runs an identification with those signals attached, and blocks the submission client-side on a bad verdict:
<form id="newsletter" action="/subscribe" method="POST">
<input type="email" name="email" required>
<input type="hidden" name="prynt_request_id">
<button>Subscribe</button>
</form>
<script src="https://api.pryntid.com/cdn/prynt.umd.js"></script>
<script>
Prynt.load({ apiKey: 'pk_live_…' }).then((agent) => {
const form = document.getElementById('newsletter');
agent.protectForm(form, {
autoGuard: true,
onVerdict: (result) => {
form.elements.prynt_request_id.value = result.requestId;
},
onBlocked: () => {
form.insertAdjacentHTML('beforeend', '<p>Something went wrong. Please try again.</p>');
},
});
});
</script>
The client-side block is a convenience. It stops casual bots and spares your server, but anything running in the browser can be bypassed, and bombing tools often post straight to your endpoint without loading the page. The real decision happens on the server.
Decide on the server, before sending anything
Your subscribe handler fetches the event with the secret key and applies three checks: the verdict, the form signals, and per-device velocity.
import { PryntServer } from '@prynt/node';
const prynt = new PryntServer({ secretKey: process.env.PRYNT_SECRET_KEY });
app.post('/subscribe', async (req, res) => {
const { email, prynt_request_id: rid } = req.body;
const ok = () => res.redirect('/subscribe/check-your-inbox'); // same page either way
const ev = rid ? await prynt.getEvent(rid).catch(() => null) : null;
if (!ev) { // no agent or no event: don't send instantly
await queueDelayedConfirmation(email);
return ok();
}
const formBot = ev.smartSignals?.formBot?.result;
const distinctEmails = await redis.sadd(`sub:dev:${ev.visitorId}`, email.toLowerCase())
.then(() => redis.expire(`sub:dev:${ev.visitorId}`, 3600))
.then(() => redis.scard(`sub:dev:${ev.visitorId}`));
if (ev.decision === 'block' || formBot || distinctEmails > 2) {
log.warn('subscribe dropped', { visitorId: ev.visitorId, reasons: ev.risk?.reasons });
return ok(); // silent drop, no email sent
}
await sendConfirmation(email);
ok();
});
Notice the response is the same whether you sent the email or not. Telling a bombing tool “rejected” only helps it tune; showing the same “check your inbox” page gives it nothing. A real person whose submission was wrongly dropped can simply try again, which is why the threshold on distinct emails should be low but not one.
For the case with no requestId, don’t send instantly. Queue the address and send the confirmation after a short delay with a per-address limit; a bombing run is over in minutes, so a delayed, de-duplicated send stops you from joining the flood without losing real subscribers whose browsers block scripts.
Add a silent challenge for the grey zone
When the verdict is challenge rather than block, ask the browser to prove it isn’t free to run at scale. The agent’s challenge() solves a proof-of-work puzzle in the background and returns a passToken:
const { passed, passToken } = await agent.challenge(requestId);
// send passToken with the form
If you use autoGuard, pass challengeAs: 'allow' to protectForm() so challenge verdicts reach your code instead of being stopped in the browser; by default autoGuard treats challenge like block. Your server validates the token with POST /v1/challenge/validate and your secret key. The token is single-use and bound to your account, so it can’t be replayed. A real visitor never sees anything; a bot farm submitting thousands of forms pays a compute cost on each one. The approach is compared with visible challenges in honeypot vs CAPTCHA for form spam.
Look after the victim, too
Two habits make your form a worse tool for attackers and a better neighbour:
- Make confirmation emails boring. No marketing content, no links other than “confirm” and “this wasn’t me.” Bombers sometimes target forms that let them inject text into the confirmation.
- Cap sends per address. Never send more than one confirmation to the same address in a day, regardless of what else passes.
Rolling it out
Turn on protectForm() and server-side logging first, without dropping anything. Within a few days you will see what share of submissions trip formBot, come from datacenter ranges, or share a device with many addresses. If your form has been abused, those numbers make it obvious. Then enable the silent drop. Timing-based signals are covered in depth in submit timing analysis for form bots, and the form protection page lists what’s included on each plan.
Your newsletter form exists to collect people who want to hear from you. Keeping it from being used against people who don’t is part of the same job.
Try it free
Prynt is device intelligence with a free tier — visitor IDs, bot & fraud Smart Signals, and behavioral biometrics, powered by a cross-site network. Start free.