When to use this contact form checklist
A public contact form looks harmless until it becomes the noisiest endpoint on a small site. It can attract repeated submissions, spam links, oversized payloads, forged requests, email notification floods, and low-quality records that bury real messages. For a Flask blog such as Stepnex, the goal is not to turn the form into a heavyweight ticketing system. The goal is to make the form predictable: real users can submit it, suspicious submissions are limited, every branch is logged, and the operator can review the result without guessing.
This checklist fits a server-rendered Flask site behind Nginx and Gunicorn. It also works for a small independent website that uses Flask only for a few public routes. The controls are intentionally incremental: field limits, request size limits, CSRF or one-time tokens, a honeypot field, rate limits, duplicate detection, notification rules, and review logs. It pairs well with the existing Flask admin audit log checklist. Admin audit logs explain what authenticated users changed; contact form logs explain what the public internet tried to submit.
Design principle: block less at first, measure more
The common mistake is to add a CAPTCHA immediately and declare the problem solved. A CAPTCHA may reduce cheap spam, but it can also break for real users, create an external dependency, and make mobile submissions painful. Start with server-side validation and observable branches. Once the site can tell accepted submissions from invalid tokens, honeypot hits, duplicates, and rate-limited attempts, it becomes much easier to decide whether a CAPTCHA is necessary.
Keep four boundaries explicit. Nginx should reject request bodies that are too large for a simple form. Flask should trust only whitelisted fields and validate every value on the server. The database should store only the data needed for follow-up and short-term abuse control. Email notifications should be triggered only after the message has passed the basic checks, otherwise a spam burst becomes an email outage.
Steps 1: define fields and request limits
Start by writing down the exact fields the contact form accepts. A blog contact form usually needs a name, email, subject, message, and one hidden honeypot field. Do not keep extra fields just because they may be useful later. Every additional field creates validation work and privacy questions.
CONTACT_LIMITS = {
"name": 40,
"email": 120,
"subject": 100,
"message": 2000,
}
def clean_text(value: str, limit: int) -> str:
value = (value or "").strip()
if len(value) > limit:
raise ValueError("field too long")
return value
Add a tighter Nginx body limit for the contact route. A form that does not upload files does not need the same limit as an image endpoint.
location /contact {
client_max_body_size 64k;
proxy_pass http://127.0.0.1:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
Validate the configuration with commands:
sudo nginx -t
sudo systemctl reload nginx
curl -i -X POST https://stepnex.cn/contact -d "name=test&email=test@example.com&subject=hello&message=ok"
Then submit overlong values and a large payload. The expected result is a clear validation error or a proxy-level rejection, not a Flask 500. Do not rely on browser maxlength; direct POST requests bypass client-side controls.
Step 2: add CSRF or a one-time form token
If the project already uses Flask-WTF, use its CSRF support consistently. If the form is deliberately lightweight, a one-time token can still reduce forged and replayed submissions. Generate a random token, store it in the session, render it into the form, compare it on submit, and rotate it after use.
import secrets
from flask import session
def issue_form_token() -> str:
token = secrets.token_urlsafe(32)
session["contact_form_token"] = token
return token
def verify_form_token(token: str) -> bool:
expected = session.pop("contact_form_token", "")
return bool(expected and token and secrets.compare_digest(expected, token))
Validate this with three cases: open the contact page and submit once, use the browser back button and submit the old form again, and send a POST request without the token. The first case should pass. The second and third should fail gracefully or ask the user to reload the form. A useful log line should include the result, the route, and a short reason such as invalid_token, but it should not expose the token value.
If the site uses a separate frontend domain, check cookie settings before blaming the token logic. SameSite, HTTPS, proxy headers, and session storage all affect whether the browser can send the session cookie back to Flask.
Step 3: use a honeypot for cheap automation
A honeypot is not a strong security control, but it is effective against scripts that fill every input. Add a field such as website that real users should not touch. Hide it visually without making it a real required field.
<div class="visually-hidden" aria-hidden="true">
<label for="website">Website</label>
<input id="website" name="website" tabindex="-1" autocomplete="off">
</div>
On the server, treat a filled honeypot as a rejected submission. Returning a generic thank-you page is often better than telling the script exactly which rule it triggered.
honeypot = request.form.get("website", "").strip()
if honeypot:
current_app.logger.info("contact_honeypot_hit path=%s ip=%s", request.path, request.remote_addr)
return render_template("contact_thanks.html"), 200
Validate it with a manual POST that includes the honeypot field. The page may look successful to the client, but it should not create a valid inquiry and should not send an email notification. The pitfall is to choose a field name that real users may fill by accident. Avoid names such as phone or company if the form might later add those fields.
Step 4: rate-limit and detect duplicates
Rate limiting is useful only when the source identity is reasonably accurate. If Nginx, a CDN, or a load balancer sits in front of Flask, configure trusted proxy handling before using request.remote_addr. Otherwise every request may appear to come from the proxy.
For a small blog, begin with conservative limits: for example, no more than three contact submissions from the same IP hash in ten minutes, and no more than two messages from the same email in one hour. Hash the IP with a server-side salt if the value is only needed for short-term abuse control. Duplicate detection can use a hash of normalized email, subject, and message body. If the same payload arrives repeatedly within a short window, keep one record and return a friendly “received” response.
from datetime import datetime, timedelta
def recent_contact_count(db, ip_hash: str, since_minutes: int = 10) -> int:
since = datetime.utcnow() - timedelta(minutes=since_minutes)
return db.session.execute(
"select count(*) from contact_message where ip_hash=:ip and created_at>=:since",
{"ip": ip_hash, "since": since},
).scalar()
Validate with a small matrix: same browser repeated submissions, same email with different messages, different email with the same message, no-cookie POST requests, and a request with a very long body. The application log should distinguish accepted, rate_limited, duplicate, and invalid_token. If every failed branch looks the same, future review will be guesswork.
Step 5: separate storage, notification, and human review
Do not send an email for every raw POST. Store the submission decision first, then notify only when the message passes validation, token checks, honeypot checks, rate limits, and duplicate checks. In the admin area, keep simple statuses: pending, replied, spam, and ignored. That gives the operator a human review step without turning the public form into an automatic lead engine.
Log metadata, not full private content:
current_app.logger.info(
"contact_submit result=%s reason=%s ip_hash=%s subject_len=%s message_len=%s",
result,
reason,
ip_hash,
len(subject),
len(message),
)
The full message belongs in the database with access control, not in general application logs. This makes later debugging safer. If the contact route suddenly receives many POST requests, compare application log decisions with Nginx access logs: status codes, user agents, request timing, and source patterns. That will show whether the pressure is real user activity, a frontend bug, or automated spam.
Validation checklist
- A normal user can submit the form once and the admin receives one pending record.
- The same old token cannot be reused silently.
- A request without a token is rejected with a user-safe response.
- A filled honeypot creates no valid inquiry and sends no notification.
- Overlong name, email, subject, and message values do not produce a 500.
- The Nginx body limit blocks oversized payloads before Flask spends work on them.
- Repeated submissions from the same source enter the rate-limit branch.
- Duplicate content is stored once or marked as duplicate.
- Logs clearly separate accepted, honeypot, rate-limited, duplicate, invalid, and token failures.
Pitfalls
Do not return 403 for every abnormal case. Real users can hit stale tokens with the back button, privacy extensions, or expired sessions. Provide a safe retry path. Do not store full IP addresses, raw tokens, or full message bodies in generic logs. Do not apply the same rate limit to every POST route on the site; admin publishing, search, comments, and contact forms have different behavior. Do not treat crawler or bot traffic as proof that the form is broken; pair application logs with Nginx logs before changing rules.
Also avoid making notification volume the only metric. A clean inbox may mean the controls are working, or it may mean the form is rejecting real users. Review accepted counts, rejected counts, reasons, and user complaints together.
Weekly review
Review the contact form once a week. Check whether real messages were handled, which branch blocked most suspicious submissions, whether invalid-token events increased, and whether any user reported trouble. Change one parameter at a time: a field length, a rate window, a notification rule, or a duplicate window. Keep the validation command, the log sample, and the rollback note with the change.
During an ad network review period, avoid frequent copy and layout changes on the contact page unless they fix real errors. Prioritize stable behavior, low notification noise, clear logs, and human review. The realistic goal is not perfect protection. The goal is a public form that normal users can use, automated submissions cannot abuse cheaply, and the site owner can improve based on evidence.