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.
CSRF tokens
Section titled “CSRF tokens”Posthorn 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.
The escalation ladder
Section titled “The escalation ladder”The defenses above stop naive and cross-site bots. The checks below target the harder case: automated form spam that fills valid-looking fields, varies its wording and language, and reuses throwaway sender addresses. They’re content-agnostic — they key on identity and browser behavior, not the message text, because the message has no reliable signature. Turn them on in order, escalating only if spam persists.
Reputation ([endpoints.reputation])
Section titled “Reputation ([endpoints.reputation])”An optional StopForumSpam lookup on the submitter’s email and IP before send. It catches the sender rather than the message, so it works across every language and length the spam takes, and a repeat offender is caught on every attempt after the first. Fails open by default so a provider outage never blocks real mail. This is usually the highest-value first step against contact-form spam. It sends the submitter email and IP to a third party — opt-in, note it in your privacy policy.
Proof of browser ([endpoints.proof_of_browser])
Section titled “Proof of browser ([endpoints.proof_of_browser])”A JavaScript-gated submit token: Posthorn serves a challenge token from a GET on the endpoint, a small inline script fetches it and injects it as a hidden _pob_token field, and the token is verified on submit. Bots that POST the form directly without running the page’s JavaScript never get a token and are rejected. No third party involved. An optional min_age adds a time-trap that rejects submissions completing impossibly fast.
Captcha ([endpoints.captcha])
Section titled “Captcha ([endpoints.captcha])”Cloudflare Turnstile verification — the backstop for bots that render a real browser and would pass proof-of-browser. It adds a third-party dependency and a visible widget, so it’s the last rung, not the first. A failed token returns a byte-identical silent 200 so a bot can’t tell it was caught; on a provider outage it fails closed by default.
See the TOML reference for every field, and Reading logs for the spam_blocked kinds these emit.
What doesn’t ship
Section titled “What doesn’t ship”| Feature | Status | Notes |
|---|---|---|
| Proof-of-work challenge | v3 | Computational cost imposed on submitter |
Content-shape rules (max_links, min_message_length) | deprioritized | Real-world spam had no content signature; a length floor rejects legitimate short inquiries |
| Bayesian content classification | (not planned) | Too easy to evade with modern LLMs; the reputation/browser checks are content-agnostic by design |
The honeypot, origin, and rate-limit basics catch drive-by bots with zero operator friction. The escalation ladder handles the automated form spam that gets past them — start with reputation, add proof-of-browser, and reach for Turnstile only if the first two aren’t enough.
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. - Proof of browser (when configured) — checks the
_pob_tokenfield. - Captcha (when configured) — verifies the Turnstile token (network call).
- Reputation (when configured) — StopForumSpam lookup on email/IP (network call).
- Validation (required fields, email format).
The ordering matters: cheap header-only checks reject obvious junk before the server pays to parse the body, and the local form checks (honeypot, CSRF, proof-of-browser) run before the network checks (captcha, reputation) so a cheap rejection short-circuits before any outbound call.
See Core concepts → Request pipeline for the full ordered list.