Throttling a Public Feedback Endpoint: Two Buckets and One Cap
A public feedback endpoint needs defenses on two axes: a body size cap before JSON decoding and two per-account rate limit buckets mounted after authentication.
TL;DR
A security review flagged a feedback endpoint accepting unlimited payload sizes and submission frequency. Fixes: cap the body at 32 KiB before JSON decoding, mount two per-user rate limit buckets (one per 10 minutes, five per day) after auth middleware, and return 429 with Retry-After. Also normalize nil query results to empty arrays so clients get the promised JSON contract.
A routine security review of a public feedback form found two problems invisible to functional tests. The submit endpoint accepted a JSON body of any size, and nothing stopped one account from sending a hundred feedback submissions in a row. The first guess was that column-length validation was enough. It was not: length checks run at the service layer, long after the server has allocated memory for whatever the client sent.
Two Axes, Two Mechanisms
Size and frequency answer different questions, so they need different defenses. Enforcing a maximum size on all incoming payloads is a baseline practice[3]. In the handler, the body gets capped before the JSON decoder ever runs:
r.Body = http.MaxBytesReader(w, r.Body, 32<<10) // 32 KiB, before decode
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
response.Error(w, http.StatusBadRequest, "BAD_REQUEST", "body tidak valid")
return
}
The 32 KiB number comes from arithmetic, not convention: two text fields of at most 5000 runes each, in a language that averages three bytes per character, add up to roughly 10 KB. A 32 KiB ceiling leaves generous room for legitimate submissions while refusing oversized bodies before they consume memory. Service-layer validation still applies on top: at least one of the two fields must be filled, 5000 runes per field, and an empty side is stored as NULL.
Frequency is the second axis. Public guidance is blunt about it: rate limiting is the foundational control and should be applied at multiple keys, not just IP addresses, with the per-authenticated-identity key being the most reliable because it applies after login[1].
Middleware Order Decides the Bucket Key
The easy mistake is mounting the limiter before authentication. Then the only available key is the client IP, and every user behind one office NAT shares a single bucket. Mounting the limiter after the auth middleware makes the bucket key the user ID from the request context[1]:
saranKritikLimiters := []func(http.Handler) http.Handler{
middleware.RateLimit(middleware.NewRateLimiter(1, 10*time.Minute), middleware.UserKeyFunc("saran-kritik-10m")),
middleware.RateLimit(middleware.NewRateLimiter(5, 24*time.Hour), middleware.UserKeyFunc("saran-kritik-24h")),
}
// limiters mounted AFTER requireUser so the key is CtxUserID
saranKritikHandler.RegisterRoutes(api, middleware.RequiredUserAuth(cfg.JWTSecret), saranKritikLimiters...)
Two chained buckets encode two business rules: one submission per 10 minutes blocks instant flooding, five per 24 hours keeps the daily total sane. Both buckets are checked independently and both must pass. The in-memory fixed-window limiter does have a known weakness, bursts at window boundaries[1]. For a single instance with a limited audience the trade-off is accepted deliberately, and the concrete numbers are tuned to what the business can absorb[3].
When the Bucket Is Full: the 429 Contract
A tripped limit answers with 429 Too Many Requests. RFC 6585 defines the code for "too many requests in a given amount of time" and allows a Retry-After header saying how long to wait before trying again; such responses must not be stored by caches[2]. The message stays generic on purpose, without revealing which bucket fired, matching the guidance that responses should stay generic when a limit is hit[1].
One small contract nearly slipped through: the admin list endpoint. A MySQL query returning zero rows yields a nil slice in Go, and default JSON marshaling turns that into null, while the OpenAPI document promises an array. The fix is two lines: normalize nil rows to an empty slice before serializing. Without it, clients that trust the array type break on empty data instead of on full data.
The result is one public endpoint defended on both axes. Size is capped before decoding, frequency is bounded by two per-account buckets, and the empty-array contract is enforced explicitly. Three small decisions, a few lines each, and only one of them was obvious before the security review started.
Sources
- OWASP Cheat Sheet Series — Bot Management and Anti-Automation: Rate Limiting and Quotas (accessed 2026-10-14).
- RFC 6585 — Additional HTTP Status Codes, Section 4: 429 Too Many Requests (accessed 2026-10-14).
- OWASP API Security Top 10 (2023) — API4:2023 Unrestricted Resource Consumption (accessed 2026-10-14).