Anonymous forms — minimum safety net
Anonymous forms — minimum safety net
Public contact forms, comment boxes, suggestion pages. The most abused surface on any site. Before jumping to "we must use CAPTCHA", there are lower-cost measures that already stop a lot.
1. Threat model
| Threat | Trait | Frequency |
|---|---|---|
| Spam bots | HTML-parse + auto-submit. Many IPs | most common |
| Manual spam | Human copy-paste loop. Few IPs | medium |
| Targeted attacks | Fake reports against a specific business | low, high cost |
| Resource abuse | Thousands/s request floods | low, rare |
For a public contact form, category 1 is ~90%. Stop that and the rest becomes manageable with operational handling.
2. Honey-pot — first line at zero UX cost
An invisible field. If it comes back filled, treat the sender as a bot.
<input
type="text"
name="website"
tabIndex={-1}
autoComplete="off"
aria-hidden="true"
style={{ position: 'absolute', left: '-9999px', opacity: 0 }}
/>
Server:
if (body.website?.trim()) {
return NextResponse.json({ ok: true }, { status: 200 });
// 200 on purpose, so the bot does not retry
}
Humans never fill invisible fields; many bots only parse HTML and skip CSS. Zero UX cost, significant coverage.
3. Hash IPs instead of storing them
Storing raw IPs creates a personal-data retention obligation. If you only need dedup / abuse detection, store a hash prefix.
function hashIp(ip: string): string {
return createHash('sha256').update(ip).digest('hex').slice(0, 32);
}
- Detect loops:
WHERE ip_hash = ... AND created_at > now() - interval '1 hour' - Raw IPs never touch the DB or logs
- 32-char prefix balances storage vs collision risk
Extract the first hop of X-Forwarded-For with split(',')[0].trim().
4. Rate limiting
// Redis sliding window (preferred)
await redis.incr(`inquiries:${ipHash}:${Math.floor(Date.now() / 60000)}`);
// limit to N per minute
If Redis is overkill, Postgres SELECT count(*) with an interval predicate works and removes a dependency.
5. Input validation — zod / Valibot
Revalidate on the server. Client validation is UX, the security boundary is server.
const InquirySchema = z.object({
name: z.string().max(40).optional(),
email: z.string().email().max(120).optional(),
subject: z.string().max(80).optional(),
message: z.string().min(5).max(2000),
website: z.string().optional(),
});
Length ceilings (max) matter most — they cheaply reject megabyte payloads trying to waste DB / memory.
6. XSS — sanitize on display, not on save
Keep the raw message in the DB; sanitize only when rendering. If you encode HTML on write you lose "preserve-and-edit".
const safe = DOMPurify.sanitize(marked.parse(message));
<div dangerouslySetInnerHTML={{ __html: safe }} />
For plaintext forms, React's auto-escaping is already sufficient.
7. PII fields optional
CREATE TABLE inquiries (
id BIGSERIAL PRIMARY KEY,
name TEXT, -- nullable
email TEXT, -- nullable
subject TEXT NOT NULL DEFAULT '',
message TEXT NOT NULL,
user_agent TEXT,
ip_hash TEXT,
status VARCHAR(12) NOT NULL DEFAULT 'new'
CHECK (status IN ('new','read','replied','archived')),
admin_note TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
- Requiring
emailattracts fake addresses. Keep it optional ("provide if you want a reply"). - Truncate
user_agentat 500 chars. - The status flow
new → read → replied → archivedalone gives operators enough tracking.
8. Reply workflow
If the form has inbound only and no reply path, operators burn out.
- If email present, admin UI gets a
mailto:${email}?subject=Re: ... - Status
repliedhides from dashboard archivedentries get archived or anonymized (email=NULL) by batch after retention
9. When CAPTCHA becomes necessary
The five above (honey-pot · IP hash · rate limit · zod · XSS) carry most small sites. Add CAPTCHA when:
- Dozens of spam items per day, consistently
- Targeted abuse
- Forms that move money (coupons, payments)
hCaptcha · Cloudflare Turnstile have better UX/privacy trade-offs than reCAPTCHA.
10. Gotchas
CAPTCHA first — heavy UX cost, and bot-solving services exist. Start with honey-pot + rate limit.
Raw IP stored — PIPA / GDPR personal data. Retention obligations follow. Hashing avoids them.
Returning 4xx — bots learn to route around. Return 200 on honey-pot-triggered rejects.
Filtering by user_agent — bots mimic real UAs. Useful for logging, weak as a filter.
Admin UI renders raw message — admin pages are not immune to XSS. Assume the admin browser is a target.
Closing
A defense-in-depth stack of cheap measures usually beats a single heavy front gate. Honey-pot alone buys you the first year; add heavier tools only when real traffic logs justify them.
Next
- input-validation-zod
- rate-limit-redis
References: OWASP Automated Threats · Cloudflare Turnstile · DOMPurify · RFC 7239 X-Forwarded-For.