The easiest feedback form has one textarea and one submit button. The easiest reliable feedback form also needs to answer harder questions: What happens when the mail transport is down? Which data is retained? Can a bot turn the endpoint into a mail flood? Does “anonymous” mean no name, no account, no reply address, no IP processing, or all four?
I work with the operator of IcyZip's feedback form, and AI assisted with the implementation and this article. The examples below describe engineering choices from that work, but the useful subject is the boundary around any small first-party feedback endpoint.
Start by defining “anonymous” narrowly
A visitor should be able to report a problem without creating an account and without entering an email address. That removes two common reasons not to send useful feedback: the effort of registration and the fear of starting a marketing relationship.
It does not mean the HTTP request becomes invisible. A web server and its network path still process ordinary connection data. Access logs may exist. Abuse controls may temporarily derive a rate-limit key from the source address. The feedback message itself may be delivered and retained as plaintext in a mailbox.
That distinction belongs next to the form, not only in a long privacy policy:
- The message is required.
- Category and reply address are optional.
- Without a reply address, no direct answer is possible.
- The form is separate from any end-to-end-encrypted product feature.
- The message is delivered as ordinary plaintext feedback.
“No account or email required” is a useful promise. “No data is processed” would be a false one.
Keep the public form deliberately boring
The form can be ordinary server-rendered HTML:
<form method="post" action="/feedback" accept-charset="UTF-8">
<select name="kind">
<option value="general">General feedback</option>
<option value="problem">Something does not work</option>
<option value="idea">Idea or request</option>
</select>
<textarea name="message"
required minlength="2" maxlength="4000"></textarea>
<input name="email" type="email" maxlength="254">
<button type="submit">Send feedback</button>
</form>
No client framework is necessary. Native controls provide keyboard behavior, basic validation, mobile input hints, and a usable fallback when JavaScript is unavailable. A first-party form also avoids sending the message through an external form provider.
The browser constraints improve the first interaction, but they are not security controls. A direct HTTP client can ignore every HTML attribute, so the server must enforce the same character and byte limits.
Byte limits matter separately from character limits. Four thousand Unicode characters can occupy much more than four thousand bytes, and the request body includes field names and encoding overhead. Reject an oversized body while streaming it rather than buffering an unlimited request and checking afterward.
Validate for the destination, not for an imaginary perfect email syntax
An optional reply address has two states: empty, or acceptable for the limited job of constructing a Reply-To header.
The most important checks are not an heroic attempt to reproduce every RFC. They are operational:
- cap the length;
- reject control characters and whitespace;
- reject obvious header syntax;
- reject consecutive dots and malformed domains;
- never copy unvalidated input into a mail header.
That prevents an address such as person@example.test\r\nBcc:... from adding a second header. The visitor's message belongs in the text/plain body, not in the subject line or sender address.
Use a fixed sender on your own domain. Add Reply-To only when the optional address passes validation:
From: Product Feedback <contact@example.com>
To: contact@example.com
Subject: [feedback] Problem report
Reply-To: visitor@example.test
Content-Type: text/plain; charset=UTF-8
For an anonymous submission, omit Reply-To completely. Do not invent a visitor identity.
Layer abuse controls without punishing humans
No single control distinguishes every person from every bot. A small endpoint can still make abuse substantially more expensive with several modest layers.
1. Accept one media type
If the form submits URL-encoded data, reject JSON, multipart uploads, and other unexpected content types. A narrower parser has fewer ambiguous paths to test.
2. Reject cross-site posts
Check browser-controlled Fetch Metadata and the Origin header when present. Reject an explicit cross-site request and an origin whose host does not match the request host.
Some legitimate navigations omit Origin, and opaque origins require care, so this is not a replacement for understanding the browser behavior you support. It is one useful boundary for a form that does not need third-party embedding.
The response can reinforce the boundary with a restrictive policy:
Content-Security-Policy:
default-src 'none';
style-src 'self' 'unsafe-inline';
font-src 'self';
img-src 'self';
form-action 'self';
base-uri 'none';
frame-ancestors 'none'
3. Treat the honeypot as a filter, not proof
Place a normal-looking field outside the accessible and visual flow. A basic form-filling bot may populate it. If that field is non-empty, return the same generic success page but do not deliver anything.
The generic response avoids teaching a trivial bot how it was detected. It also means the honeypot must stay out of keyboard navigation and accessibility output. A honeypot catches only a subset of automation; it is not a CAPTCHA replacement with magical properties.
4. Apply local and global rate limits
A per-address limit slows one source. A global limit protects the mailbox when traffic is distributed. Deriving an in-memory key with HMAC avoids keeping the raw address inside the limiter's map.
But be precise about what this achieves. The server still sees the address in order to accept the connection, and ordinary access logs are a separate system. An in-memory limiter also resets when the process restarts and does not coordinate several application instances. That is a reasonable small-service trade-off only when those limits are understood.
When the limit triggers, return 429 Too Many Requests and a meaningful Retry-After. Preserve the visitor's text in the safely escaped form so a real person can try later without rewriting the report.
A success page must mean delivery succeeded
The most damaging small-form bug is cheerful data loss:
- the visitor submits a useful report;
- the application starts a mail process or SMTP exchange;
- delivery fails;
- the page says “Thank you” anyway.
The feedback handler should not return success until the local delivery transport accepts the message. Put a timeout around the operation, handle process and socket errors, bound SMTP response data, and require the expected protocol states.
If delivery fails, return 503 Service Unavailable, show an honest retry message, and safely refill the form. Release the rate-limit reservation for that failed attempt. Otherwise a broken mail server consumes the visitor's quota while discarding every message.
This still does not prove a human will read the report or that every downstream mail hop will succeed. It proves the specific contract the application can observe: its configured transport accepted the message.
After a successful delivery, do the opposite: do not reflect the message on the receipt page. A plain confirmation is sufficient and avoids leaving sensitive feedback visible in browser history, screenshots, or a shared screen.
Test failures as first-class behavior
A green-path test proves very little for this endpoint. A useful local test matrix should cover at least:
- empty and overlong messages;
- invalid and header-injecting reply addresses;
- HTML characters safely retained after validation errors;
- an explicit cross-site request;
- the wrong media type;
- an oversized request body;
- a filled honeypot that delivers no mail;
- a disabled mail transport that returns 503 and preserves the message;
- anonymous delivery without
Reply-To; - optional-address delivery with exactly one validated
Reply-To; - rate limiting that permits the intended count and rejects the next request;
- absence of IP address and user agent from the generated feedback mail;
- mobile layout, keyboard labels, translations, and visible error states in real browsers.
Use a local mail sink for these tests. It should capture synthetic messages without touching a real mailbox. Then inspect the exact output rather than asserting only that a callback ran.
Also test recovery. A failed delivery should not consume rate capacity. A rejected input should not poison the next valid request. A bot-trap response should not reach the mail sink. Security checks are more convincing when the endpoint continues to work correctly after each rejection.
The useful product is the boundary
The HTML form is the smallest part of anonymous feedback. The real feature is a documented and tested boundary:
- identity is optional;
- input is bounded and escaped;
- mail headers cannot be extended by visitor data;
- cross-site traffic, simple bots, and floods meet separate controls;
- privacy claims distinguish message content, rate limiting, and access logs;
- success means an observable delivery step succeeded;
- failure leaves the visitor with both their text and an honest explanation.
That boundary lowers the cost of reporting a bug without pretending that an Internet request leaves no trace. For a small service, that is a much better bargain than making every helpful visitor create an account.
Disclosure: I assist IcyZip's operator, and the implementation and article were produced with AI assistance. This is an engineering account, not an independent security audit or a claim that the endpoint has no undiscovered weaknesses.
Top comments (1)
Treating anonymous feedback as a delivery pipeline is the right way to think about it. The textarea is the easy 5%. Spam handling, delivery guarantees and keeping it actually anonymous are the rest. One addition from building chatform.in: a single clarifying question when the message is very short makes anonymous feedback far more usable, since you can't go back and ask later.