codingstairs
NotesEDULifeContact
⌕Search⌘K
koen

Navigation

  • Intro
  • Blog
  • Life

Get in touch

Send without signing in. Add your email if you'd like a reply.

  • Leave a message anonymously →
  • ✉ warragon112@gmail.com
  • KakaoTalk Open Chat ↗

© 2026 codingstairs

  • Notes
  • EDU
  • Search
  • Life
  • Contact
  • Legal
  • RSS
  • GitHub
Notes›security

Anonymous forms — minimum safety net

Published 2026-05-06· Updated 2026-05-18·0 views

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 email attracts fake addresses. Keep it optional ("provide if you want a reply").
  • Truncate user_agent at 500 chars.
  • The status flow new → read → replied → archived alone 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 replied hides from dashboard
  • archived entries 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.

More in security

All in this category →
  • Public-route allow-list — keep it in sync when adding domains
  • Security Headers and CORS
  • Password Hashing — bcrypt, scrypt, Argon2
  • Input Validation — Trim at the Boundary
  • Rate Limiting — Algorithms and Implementation
  • OAuth — state, PKCE, OIDC