Skip to content

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.

LayerDefends againstDefault
Max body sizeResource exhaustion1MB (safe default; bump up for large uploads)
HoneypotDrive-by scraper botsunset (recommend setting one)
Origin/Referer checkDirect-POST bots that skip the form pageunset (fail-open)
Rate limitBasic targeted abuse, Postmark quota burnunset (recommend setting one)

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.

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 headerReferer headerOutcome
In allowlist(anything)Pass
Not in allowlist(anything)403
MissingIn allowlist (by URL host)Pass
MissingNot in allowlist403
MissingMissing403 (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.”

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).

See Rate limiting for the full treatment. The short version:

[endpoints.rate_limit]
count = 5
interval = "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.

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.

FeatureStatusNotes
Time-based form tokensfutureReject submissions that complete impossibly fast (< 3s)
reCAPTCHA / hCaptcha(not planned)Forces a third-party dependency; conflicts with the self-hosted ethos
Proof-of-work challengev3Computational 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.

For reference, the spam-protection layers run in this order in the request pipeline:

  1. Method (POST only).
  2. Auth (api-mode endpoints check Authorization: Bearer here).
  3. Idempotency-Key (api-mode endpoints look up the cache here; replays short-circuit before any other check).
  4. Content-Type (form-encoded for form mode, JSON for api mode).
  5. Origin/Referer (form mode only) — header-only check.
  6. Rate limit — header-only check, before parsing the body. Per-IP in form mode, per-API-key in api mode.
  7. Max body size — enforced by http.MaxBytesReader wrapped before any read.
  8. (body is parsed here)
  9. Honeypot (form mode only) — after parse, since it needs the form values.
  10. CSRF (form mode only, when csrf_secret is set) — checks the _csrf_token form field.
  11. 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.