Build a Contact Form Backend with Spam Protection
A complete contact form backend: validation, honeypot and timing heuristics, rate limiting, email delivery, and spam-rejection tests.
Project goal
Accept contact submissions, screen obvious spam with cheap heuristics, store legitimate messages, and notify the operator without becoming an open relay.
Final features
- Field validation
- Honeypot and timing
- Rate limit
- Store and notify
- Silent accept
Architecture
The endpoint validates fields, checks a hidden honeypot and a minimum time-to-submit, applies a per-IP rate limit, and stores the submission. Legitimate messages trigger a notification email; everything is recorded for audit.
Prerequisites
- Node.js 20 LTS or later
- PostgreSQL 15+
- An email provider or SMTP credentials
Dependencies
- express ^4.21.0 — HTTP endpoint
- pg ^8.12.0 — Submission storage
- nodemailer ^6.9.14 — Operator notification
Directory structure
contact-form-backend/
├── src/submit.ts
├── src/spam.ts
├── src/notify.ts
├── src/limits.ts
├── tests/contact.test.ts
└── package.json
Phases
1. Field validation
Validate required fields, email shape, and message length.
if (!isEmail(email) || message.length < 10) return res.status(400).json({ error: 'invalid' });
Expected behavior: Rejecting bad input protects the notification pipeline.
2. Honeypot and timing
Reject submissions that fill a hidden field or arrive too fast.
if (body.website || Date.now() - startedAt < 2000) return silentAccept();
Expected behavior: These catch most bots at near-zero cost.
3. Rate limit
Cap submissions per IP per hour.
if (!allow(ip)) return res.status(429).json({ error: 'rate_limited' });
Expected behavior: An open form invites abuse.
4. Store and notify
Persist the submission then send an operator notification.
await db.insert(submission); await notifyOperator(submission);
Expected behavior: Storage first means a delivery failure never loses the message.
5. Silent accept
Return success to obvious bots to avoid teaching them.
return res.status(200).json({ ok: true });
Expected behavior: A distinct error helps bots tune their payloads.
Testing
Submit a valid message and assert storage plus a notification; submit with the honeypot filled and an instant timestamp and assert no notification is sent.
Troubleshooting
Legitimate users flagged as spam
The minimum submit time is too long for fast typists; tune it down.
Form becomes a spam relay
The notification used the submitted address as sender; send from your own domain.
Deployment
Run behind a WAF or CAPTCHA for high-traffic sites, though the heuristics handle the common case. Store submissions in a database you can query and purge.
Known limitations
- Heuristics only; no CAPTCHA integration.
- No attachment support.
- Single operator notification target.
Project overview
A public contact form is an invitation to abuse: the moment it exists, bots find it. This build accepts real messages, screens obvious spam with cheap heuristics, and refuses to become an open relay for someone else payload.
You will validate required fields and email shape, check a hidden honeypot and a minimum time-to-submit, apply a per-IP rate limit, store the submission before notifying the operator, and return success to obvious bots so they learn nothing.
The theme throughout is cheap rejection: catch the majority of bots with a hidden field and a timing gate that cost almost nothing, and reserve the expensive path of storage and email for submissions that pass.
This is a small service by design, meant for a marketing site or a support page that gets traffic but has no security team watching it. Everything it needs fits in one process and one database.
The phases build a form that stays useful whether you stop after validation or continue through notification, so the tutorial mirrors how a small feature actually ships: working at every step.
The value of this project is not the form code but the judgment about which checks belong where. Getting a message delivered is easy; making sure the cheapest possible abuse is rejected before it costs you anything is the part worth learning.
What you will build
The backend provides five capabilities, each with a test that drives a real submission:
- Field validation
- Honeypot and timing
- Rate limit
- Store and notify
- Silent accept
Validation rejects malformed input; the honeypot and timing gate drop naive bots; a per-IP limit caps abuse; storage precedes notification so a mail failure never loses a message; and the silent-accept path returns 200 to bots to avoid teaching them the rules.
Nothing here depends on a CAPTCHA or a third-party scoring service, so the form works offline and costs nothing per submission. The trade-off is precision, since cheap heuristics miss a determined human, which the deployment notes address honestly.
Architecture and data model
The endpoint validates fields, checks a hidden honeypot and a minimum time-to-submit, applies a per-IP rate limit, and stores the submission. Legitimate messages trigger a notification email; everything is recorded for audit.
A single endpoint runs a fixed pipeline: validate, screen, limit, persist, notify. Each stage can short-circuit, and the order is chosen so the cheapest rejection happens first and the most expensive work happens only for plausible messages.
The submission record stores the raw fields plus the outcome — accepted, rate-limited, or spam — so operators can tune heuristics against real data instead of guessing. Recording the decision is what makes the heuristics improvable.
The honeypot is a field hidden with CSS that a human never fills and a naive bot always does, while the timing gate rejects submissions faster than a human can plausibly type. Both are evaluated on the server, because client-side checks are trivially bypassed.
The notification is sent as a side effect of a successful store, never as a precondition for it. That ordering is a conscious choice: it costs a little latency and buys the guarantee that a mail outage never turns into a lost customer message.
Prerequisites and dependencies
You need a database for submissions and a way to send mail; neither is optional for a form that must actually reach a human.
- Node.js 20 LTS or later
- PostgreSQL 15+
- An email provider or SMTP credentials
The dependencies cover the HTTP endpoint, storage, and notification:
express(^4.21.0) — HTTP endpointpg(^8.12.0) — Submission storagenodemailer(^6.9.14) — Operator notification
pnpm init
pnpm add express pg nodemailer
pnpm add -D typescript tsx vitest
Keep SMTP credentials and the operator address in the environment, and validate them at boot so a misconfigured form fails loudly instead of silently dropping messages. Pin the email library version, since transport behavior changes quietly across majors.
Directory structure
Split submission handling, spam screening, notification, and limiting so each heuristic can be tested without sending mail:
contact-form-backend/
├── src/submit.ts
├── src/spam.ts
├── src/notify.ts
├── src/limits.ts
├── tests/contact.test.ts
└── package.json
Phase 1: Field validation
Validate required fields, email shape, and message length.
if (!isEmail(email) || message.length < 10) return res.status(400).json({ error: 'invalid' });
Rejecting bad input protects the notification pipeline.
Validate shape and length on the server even if the form also checks on the client. Confirm the email parses, enforce a minimum message length, and cap every field so an oversized payload is rejected before it reaches storage or the mailer.
Phase 2: Honeypot and timing
Reject submissions that fill a hidden field or arrive too fast.
if (body.website || Date.now() - startedAt < 2000) return silentAccept();
These catch most bots at near-zero cost.
The honeypot and the minimum elapsed time are the highest-value lines in the service: they are nearly free and they stop the bulk of automated submissions. Keep the hidden field name plausible and the time threshold short enough not to catch fast human typists.
Phase 3: Rate limit
Cap submissions per IP per hour.
if (!allow(ip)) return res.status(429).json({ error: 'rate_limited' });
An open form invites abuse.
Apply the limit per IP per hour and return 429 with a retry hint. An unbounded public endpoint is a resource leak as much as a spam problem, and a modest limit costs legitimate users nothing.
Phase 4: Store and notify
Persist the submission then send an operator notification.
await db.insert(submission); await notifyOperator(submission);
Storage first means a delivery failure never loses the message.
Persist first, then notify. If the mail provider is down the message still exists for the operator, whereas reversing the order would lose messages exactly when the site is busiest.
Phase 5: Silent accept
Return success to obvious bots to avoid teaching them.
return res.status(200).json({ ok: true });
A distinct error helps bots tune their payloads.
Return a normal success to submissions that are clearly bots, without storing or notifying. A distinct error code is a free lesson for the attacker, while a generic 200 gives them nothing to tune against.
Testing
Submit a valid message and assert storage plus a notification; submit with the honeypot filled and an instant timestamp and assert no notification is sent.
Submit a valid message and assert both a stored row and a notification call, using a fake transport so no real mail leaves the test. Submit with the honeypot filled and with an instant timestamp and assert that neither storage nor notification happened.
Exceed the per-IP limit and assert 429, then advance the window and assert the limit resets. Test malformed input such as a bad email or an empty message and assert 400 with no side effects.
Assert that the operator is the recipient and the sender is your own domain, which is the difference between a notification and an open relay. This deserves a test because it is the mistake that turns a form into an abuse vector.
Drive the spam cases through the real endpoint rather than calling the heuristics directly. A heuristic that works in isolation but is wired into the wrong stage is a bug only an end-to-end assertion will catch.
Assert the pipeline order by making the mailer throw and confirming the submission is still stored, which pins the persist-then-notify decision as a tested invariant rather than a comment.
pnpm vitest run
Deployment and operations
Run behind a WAF or CAPTCHA for high-traffic sites, though the heuristics handle the common case. Store submissions in a database you can query and purge.
Run behind a proxy that sets a trustworthy client IP, and configure the rate limit to read it rather than a spoofable header. For high-traffic sites add a WAF or CAPTCHA in front, though the built-in heuristics handle the common case.
Store submissions in a database you can query and purge, with a retention window that matches your privacy policy. Give operators a simple way to mark or delete a message so the form does not become a growing archive of personal data.
Monitor the acceptance rate and the spam-decision rate; a sudden change usually means a new bot campaign or a heuristic that has drifted from real traffic. Tune thresholds from this data rather than from intuition.
When the mail provider returns a permanent error, record it on the submission rather than retrying forever. A stored message with a visible delivery failure is recoverable by an operator, whereas a silent retry loop hides the problem until a customer complains.
Set the per-IP limit from observed traffic rather than a guessed number, and revisit it after a campaign. A limit that is too tight annoys real users and one that is too loose does nothing, so this value belongs in configuration and in review.
Security considerations
Escape message content in any admin view to prevent stored XSS, never use the submitted email as a reply-to without review, and cap message size.
Treat every field as untrusted and encode on output, because a contact form is a classic vector for header injection if a field is interpolated into an email header unchecked. Reject newlines in single-line fields.
Send notifications from your own domain with the visitor address in the reply-to, never as the envelope sender, so a spoofed address cannot route bounces or reputation damage to a third party.
Keep SMTP credentials and the operator address out of the repository, and cap total submission volume per day as a backstop beyond the per-IP limit. Rate limiting per IP alone is defeated by a botnet, so a global ceiling matters.
Never echo the submitted content back in the response, and never render it into an admin page without escaping, because the form is the one place an attacker fully controls the input you will later read.
Cap the message body length in bytes as well as characters, and reject an oversized request before parsing the form, so a large payload cannot consume memory on a path any anonymous caller can reach.
Observability
Track submissions, spam rejections, and notification failures. Alert on a jump in rejections, which may indicate a new spam wave.
Count submissions, rejections, and notifications by outcome so the heuristic mix is visible at a glance. A rising spam rate at a constant acceptance rate suggests the heuristics still catch bots but that a campaign is running.
Track notification failures separately from storage failures; a failing mailer with a healthy database means messages are stored but unseen, which operators discover late unless the two are charted apart.
Alert when daily volume crosses a threshold you set, since that is the signal that a campaign has scaled beyond what the heuristics absorb and a stronger gate is warranted.
Log the heuristic that fired for each rejection, because that breakdown is the only way to know whether the honeypot or the timing gate is doing the work when you tune the thresholds next.
Chart the time from submit to notification, because a rising value with a healthy acceptance rate means the mail path is degrading while the form still looks fine to visitors.
Troubleshooting
Legitimate users flagged as spam. The minimum submit time is too long for fast typists; tune it down.
Form becomes a spam relay. The notification used the submitted address as sender; send from your own domain.
When legitimate users report a rejection, check the timing threshold first, since it is the heuristic most likely to catch fast typists, then the rate limit, because a shared office IP can exhaust a strict per-IP cap. Both are configuration, not code.
If a submission is stored but no notification arrived, check the mailer credentials and the operator address before suspecting the spam filter, because a stored message proves the pipeline already reached the final stage.
Limitations and next steps
- Heuristics only; no CAPTCHA integration.
- No attachment support.
- Single operator notification target.
The screening is heuristic only: no CAPTCHA, no reputation service, and no content classification, so a determined human spammer will get through.
There are no attachments and a single operator notification target, so routing different subjects to different teams is out of scope.
The per-IP limit assumes a proxy sets a trustworthy client address; without that, the limit either over-blocks behind a shared NAT or under-blocks when an attacker forges the header.
There is no auto-learning feedback loop; operator decisions do not feed back into the thresholds automatically. Adjusting the gates from recorded outcomes is a manual step, which keeps a bad heuristic from quietly reshaping itself.