Spam protection
Posthorn employs a layered spam-protection stack tuned for typical web forms. Each layer is independently configurable; you can omit any of them, but in practice you want all four.
| Layer | Defends against | Default |
|---|---|---|
| Max body size | Resource exhaustion | 1MB (safe default; bump up for large uploads) |
| Honeypot | Drive-by scraper bots | unset (recommend setting one) |
| Origin/Referer check | Direct-POST bots that skip the form page | unset (fail-open) |
| Rate limit | Basic targeted abuse, Postmark quota burn | unset (recommend setting one) |
Honeypot
Section titled “Honeypot”A honeypot is a form field that’s invisible to humans (hidden via CSS) but visible to bots that scrape and submit forms blindly. Any non-empty value silently 200s the request without sending mail.
honeypot = "_gotcha"<input type="text" name="_gotcha" tabindex="-1" autocomplete="off" style="position:absolute;left:-9999px">Why silent 200? A 4xx response tells the bot operator that their submission was flagged — they iterate. A 200 makes the bot think the submission succeeded, so the operator keeps wasting their time on a form that will never deliver mail.
Choose a name that looks plausible to a bot (‘email_confirm’, ‘website’, ‘_gotcha’) but isn’t a field you actually use. Bots target common form-field names; obscure names work less well because the bot may not fill them in.
Origin/Referer checks
Section titled “Origin/Referer checks”Spam bots that don’t bother loading your form page first will POST directly with no Origin or Referer headers. Setting allowed_origins makes that fail closed:
allowed_origins = ["https://example.com", "https://www.example.com"]Behavior when allowed_origins is configured:
Origin header | Referer header | Outcome |
|---|---|---|
| In allowlist | (anything) | Pass |
| Not in allowlist | (anything) | 403 |
| Missing | In allowlist (by URL host) | Pass |
| Missing | Not in allowlist | 403 |
| Missing | Missing | 403 (fail closed) |
When allowed_origins is not configured, Posthorn allows any origin (fail-open by absence of config). This is fine for internal-only or dev environments, but for any public form, set it.
An explicitly empty list (allowed_origins = []) is rejected at config-validation time. This prevents the surprise of “I cleared the list to disable the check, but actually I just disabled all origins.”
Max body size
Section titled “Max body size”Protects against attackers sending multi-gigabyte form bodies to exhaust your process’s memory:
max_body_size = "1MB"Supports human-readable sizes: "32KB", "512KB", "1MB", "5MB". Default is "1MB".
Enforced via http.MaxBytesReader at the start of the handler — Posthorn never reads beyond the cap into memory. Exceeding it returns 413.
For most contact forms, 32-64 KB is plenty. Increase only if you legitimately accept long-form submissions (think: detailed bug reports with stack traces).
Rate limiting
Section titled “Rate limiting”See Rate limiting for the full treatment. The short version:
[endpoints.rate_limit]count = 5interval = "1m"A token-bucket limiter per client IP, per endpoint. 5 submissions per minute means a burst of 5 immediately, then refilling at 5 per minute thereafter. Exceeding triggers 429.
What ships beyond the basics
Section titled “What ships beyond the basics”Posthorn also ships Cross-Site Request Forgery (CSRF) tokens, signed with a Hash-based Message Authentication Code (HMAC) — csrf_secret, off by default; form-mode only — api-mode endpoints reject csrf_secret at parse time, since server-to-server callers are authenticated. Operators issue tokens server-side at form-render time using the same csrf_secret; Posthorn verifies the HMAC and Time-To-Live (TTL) on submit. The token field name is _csrf_token. See the csrf_secret and csrf_token_ttl rows in the TOML reference for the config shape.
What doesn’t ship
Section titled “What doesn’t ship”| Feature | Status | Notes |
|---|---|---|
| Time-based form tokens | future | Reject submissions that complete impossibly fast (< 3s) |
| reCAPTCHA / hCaptcha | (not planned) | Forces a third-party dependency; conflicts with the self-hosted ethos |
| Proof-of-work challenge | v3 | Computational cost imposed on submitter |
| Bayesian content classification | (not planned) | Too easy to evade with modern LLMs |
For most operators, the v1.0 stack catches 95%+ of automated abuse with no operator friction. The cases it doesn’t catch are mostly low-rate manual abuse — much rarer in practice than the drive-by bots that the honeypot handles.
Order of checks
Section titled “Order of checks”For reference, the spam-protection layers run in this order in the request pipeline:
- Method (POST only).
- Auth (api-mode endpoints check
Authorization: Bearerhere). - Idempotency-Key (api-mode endpoints look up the cache here; replays short-circuit before any other check).
- Content-Type (form-encoded for form mode, JSON for api mode).
- Origin/Referer (form mode only) — header-only check.
- Rate limit — header-only check, before parsing the body. Per-IP in form mode, per-API-key in api mode.
- Max body size — enforced by
http.MaxBytesReaderwrapped before any read. - (body is parsed here)
- Honeypot (form mode only) — after parse, since it needs the form values.
- CSRF (form mode only, when
csrf_secretis set) — checks the_csrf_tokenform field. - Validation (required fields, email format).
The ordering matters: cheap header-only checks reject obvious junk before the server pays to parse the body. The honeypot check has to wait until after parse because it needs to look at the form value of the honeypot field.
See Core concepts → Request pipeline for the full ordered list.