Security Hardening — spam signups, abuse, and uptime
What protects the app today, what each layer actually stops, and what it cannot stop.
The honest summary
You cannot tell whether [email protected] is a real mailbox without sending it mail.
Gmail publishes MX records, the address is syntactically valid, and Google accepts mail for
non-existent local parts at the SMTP transaction stage before bouncing it later. Probing withRCPT TO is unreliable against Google, and it gets your sending IP blocklisted for the trouble.
So the defence is not detection — it is making an unverified account worthless:
- Registration creates a user with status
UNVERIFIED UNVERIFIEDaccounts never appear in the admin approval queue- They have no password, so they cannot log in
- They are deleted automatically after 24 hours if the link is never used
A spammer registering 10,000 fake Gmail addresses gets 10,000 rows that vanish the next day and
never reach a human being. That is the real answer, and everything below is noise reduction on
top of it.
Layer 1 — Email quality (src/lib/email-quality.ts)
Runs on registration, before the user row is created.
Disposable domain blocklist
Roughly 200 known throwaway providers (mailinator.com, tempmail.org, guerrillamail.com,yopmail.com, and the rest). Offline, no API calls, no cost, no rate limits to worry about.
[email protected] -> 400 Temporary or disposable email addresses are not accepted.
[email protected] -> 400
Add domains directly to DISPOSABLE_DOMAINS. The list will never be complete — treat it as a
speed bump rather than a wall, because new throwaway providers appear every week.
MX record check
Confirms the domain can actually receive mail before an account is created for it.
[email protected] -> 400 (s.com publishes no MX records)
[email protected] -> 400 (NXDOMAIN)
[email protected] -> 201
Results are cached in memory for six hours per domain, so repeated signups from the same mail
provider cost a single lookup rather than one per registration attempt.
It uses explicit resolvers (1.1.1.1, 8.8.8.8), not the system resolver. This matters
more than it sounds: inside a container the default resolver may be unreachable or hijacked,
and a hijacked NXDOMAIN response makes every domain look valid. Set EMAIL_MX_RESOLVERS to
override that behaviour.
It fails open. If DNS times out, or returns anything other than NXDOMAIN or ENODATA, the
address is allowed through. A DNS outage must never block legitimate signups — availability
wins over strictness here, because the verification link is the real gate anyway.
| Variable | Default | Purpose |
|---|---|---|
EMAIL_REQUIRE_MX |
true |
Set false to skip MX checks entirely |
EMAIL_MX_RESOLVERS |
1.1.1.1,8.8.8.8 |
Comma-separated DNS servers |
EMAIL_MX_TIMEOUT_MS |
3000 |
Per-lookup timeout |
What it deliberately does not do
- No SMTP mailbox probing. Unreliable against big providers, and it gets you blocklisted.
- No paid verification API. ZeroBounce, NeverBounce and similar all charge per check.
- No Gmail alias collapsing.
[email protected]and[email protected]reach one mailbox,
so a single Gmail account can still create several signups today. Fixing that properly needs a
storedemailCanonicalcolumn with a unique index, which means a schema migration. ThecanonicalEmail()helper in the same module already implements the normalisation if you want
to take that step later.
Layer 2 — Account lifecycle
| Stage | Status | In admin list? | Can log in? |
|---|---|---|---|
| Registered, no password yet | UNVERIFIED |
❌ No | ❌ No |
| Password set via emailed link | PENDING |
✅ Yes | ❌ Not until approved |
| Admin approves | APPROVED |
✅ Yes | ✅ Yes |
Password links are single use and expire after 5 minutes (PASSWORD_TOKEN_MINUTES), and
issuing a new link immediately invalidates any previous unused one. Tokens are stored SHA-256
hashed, so the database never holds a usable link.
Cleanup
npm run cleanup:unverified -- --dry-run
npm run cleanup:unverified
Deletes UNVERIFIED users that still have no password and are older thanCLEANUP_UNVERIFIED_AFTER_HOURS (default 24), then prunes expired or already-spent tokens.
Run it on a schedule. On the k3s node:
sudo k3s kubectl -n bachelor-point create job cleanup-$(date +%s) \
--from=cronjob/bachelor-point-cleanup
A host crontab calling kubectl exec works just as well and is simpler to reason about.
Layer 3 — Rate limiting
Global, every API route
Enforced in withAudit, which wraps every handler in the application. These settings previously
existed in config and were applied to absolutely nothing — that gap is now closed, so the
numbers on the admin security page finally mean something.
| Setting | Default | Applies to |
|---|---|---|
SECURITY_MAX_READ_REQUESTS_PER_MINUTE |
600 | GET / HEAD / OPTIONS, per IP |
SECURITY_MAX_WRITE_REQUESTS_PER_MINUTE |
120 | POST / PUT / PATCH / DELETE, per IP |
SECURITY_MAX_REQUEST_BODY_BYTES |
1 MB | Rejects oversized bodies with 413 |
Every response carries X-RateLimit-Limit and X-RateLimit-Remaining, and a 429 additionally
carries Retry-After so clients can back off intelligently instead of hammering the endpoint.
Verified in practice: 135 rapid POSTs produced 120 × 200 followed by 15 × 429.
Endpoint-specific
| Limit | Default | Storage |
|---|---|---|
| Registrations per IP | 5 / hour | Database |
| Failed logins per account | 5, then 15 min lockout | Database |
| Failed logins per IP | 40, then 15 min lockout | Database |
set-password attempts |
20 / 15 min per IP | Memory |
Known weakness
The global limiter is in-memory and per pod. It resets on every deploy and is not shared
between the blue and green colours. For a single-pod free-tier node that is an acceptable
trade-off, but it is emphatically not a distributed limiter. The account and IP lockouts that
matter most for credential stuffing are database-backed and do survive restarts.
The durable fix lives at the edge — see Layer 4.
Layer 4 — Cloudflare (free, and the highest-value work remaining)
Traffic already flows through Cloudflare, so everything here costs nothing and stops abuse
before it reaches a t3.small with two vCPUs. This is where uptime is genuinely won or lost.
Turn on now
- Bot Fight Mode — Security → Bots → toggle on. Blocks known-bad automated traffic.
- Rate limiting rule — Security → WAF → Rate limiting rules. The free plan allows exactly
one rule, so spend it on the authentication endpoints:- If:
URI Pathcontains/api/auth/ - Then: Block for 10 minutes when one IP exceeds 10 requests per minute
- If:
- Managed Challenge on the register page — Security → WAF → Custom rules:
- If:
URI Pathequals/auth/register - Then: Managed Challenge
- If:
Rule 3 on its own eliminates most scripted signup spam, with no CAPTCHA widget and no code.
Worth doing next: Turnstile
Cloudflare's free CAPTCHA alternative, unlimited on any plan. It is the single most effective
remaining measure against scripted registration, and it integrates in well under an hour.
- Cloudflare dashboard → Turnstile → Add widget for
team-sober.com - Copy the site key and the secret key
- Render the widget on the register form, then verify the returned token server-side against
https://challenges.cloudflare.com/turnstile/v0/siteverifybefore the user row is created
Not implemented yet, because it needs the two keys from your dashboard first.
Layer 5 — Input safety
src/lib/xss.ts blocks script tags, event-handler attributes, javascript: and vbscript:
URLs, srcdoc, document.cookie, eval( and the common entity-encoded variants of each.
It is enforced server-side in the registration API, not merely in the browser, because
client-side checks are bypassed by anyone willing to type curl. The popup is a courtesy for
real users who paste something odd; the 400 response is the actual defence.
React escapes interpolated values by default, so treat this as defence in depth rather than the
only thing standing between you and a stored cross-site scripting bug.
Uptime checklist for a single 2-vCPU node
- Rate limiting at the app layer
- Request body size cap
- Cleanup job so the database does not grow unboundedly
- Cloudflare Bot Fight Mode
- Cloudflare rate limiting rule on
/api/auth/ - Turnstile on the register form
- Uptime monitoring with alerts (UptimeRobot free tier, five-minute checks)
-
kubectl topsanity check — the ResourceQuota caps CPU at 2000m, and a rolling update
needs spare headroom to surge. Seedocs/JENKINS-SETUP.md.
The most likely cause of downtime on this setup is not an attacker at all. It is the deploy
quota deadlock, and a database that nobody ever prunes.