The engineering work is in the gap between shipping and looking.
9 min read · 2,325 words
By Akash Bhuiyan · Senior backend engineer. Currently building Kredvox.
A guest lands on the generator, types a topic, and gets three free posts. The application processes the request, decrements a counter, and enforces the limit exactly as designed.
The system holds anonymous access to three generations. It holds until the visitor opens the browser's dev tools, attaches a custom X-Forwarded-For header, and sets it to a random IP. Changing a single digit on the next request resets the rate limiter. The caller now has unlimited free generations.
The rate limit engine was not broken. It counted correctly, updated its keys reliably, and rejected requests the millisecond the limit was crossed. It simply counted the wrong thing: a client-controlled string instead of a verified network origin.
That was the first of three places where the guest gate trusted an assertion it could not prove. Hardening an unauthenticated public endpoint is not a matter of configuring rate limits. It is a matter of finding everywhere you trusted the client, and stopping.
Anonymous users have no identity, no sessions, and no accounts, but they still consume compute. To offer three free generations on a public landing page, you must restrict access by the only marker available: the network IP address. The original design solved this by tracking the client IP across two distinct datastores, splitting the ephemeral rate-limiting logic from the durable session records.
This dual-persistence pattern assigned a specific job to each store based on its natural strengths. Redis managed the high-throughput, short-lived data. When a request came in, the application evaluated guest:ip:{ip}. If the key did not exist, Redis initialized the integer counter and attached a 24-hour time-to-live (TTL) clock. Fast, volatile, and self-expiring, a key-value cache is the correct tool for a counter that needs to vanish after a day.
PostgreSQL handled the long-term data in the guest_sessions table. This relational database recorded the session record: the generated token, the resolved IP address, and the creation timestamp. If Redis suffered a total cache flush mid-day, the underlying session history remained intact. A rate-limit counter and a session record have different lifecycles. They require different homes.
The generation result itself followed the same split pattern. When the asynchronous LLM pipeline finished generating a post, it committed the final payload to both destinations. It wrote the JSON string to PostgreSQL for the permanent record, and to Redis under guest:result:{jobId} with a 24-hour TTL. The frontend, which used a standard short-polling mechanism to check on the status of the background job, could then read the generation directly from memory without hammering PostgreSQL.
The storage architecture was sound, but the gate guarding it was thin. The diagram below illustrates the hardened three-checkpoint gate that ultimately stands between public requests and this storage layer. As originally built, however, the system lacked the Cloudflare Turnstile proof-of-humanity check and the native, server-trusted IP resolution process. The rest of this article explains how those three checkpoints were engineered to defend the data behind them.
Proxies append the client's network address to the X-Forwarded-For header as a request routes through a proxy chain. Without it, an application situated behind a reverse proxy or load balancer only sees the IP of that intermediate infrastructure. Someone must carry the real network origin forward, which makes the header a standard requirement for legitimate traffic tracking.
Reading this value directly from the request, however, creates a security trap. The original code parsed this string via a custom resolveIp() helper method, trusting the leftmost entry because convention dictates it represents the originating client. As shown in the BEFORE panel of the diagram, this convention falls apart under adversarial input. The X-Forwarded-For header is a client-supplied string. It lacks cryptographic signatures or structural verification. Because an attacker can set this string to any random address, the application was reading a client-controlled variable and using it as the foundation for a security decision. The caller effectively dictated its own rate-limit key.
Hardening this layer required shifting the trust boundary out of the application code entirely. In the AFTER configuration, adding the following to the properties file instructs the underlying servlet container to handle the proxy header resolution internally:
server:
forward-headers-strategy: NATIVEIn production, CloudFront is that trusted upstream. The infrastructure layer now forms the trusted boundary. It evaluates the header chain up to the immediate upstream proxy (such as Cloudflare or a cloud load balancer), strips out untrusted assertions, and resolves the real client IP.
The custom resolveIp() method was deleted. The application now calls request.getRemoteAddr() directly to obtain a validated, server-resolved identifier.
The architectural principle here is clear: a trust boundary cannot exist inside application logic that reads client-supplied strings. The fix was not to write a more sophisticated regex parser for the incoming header. The fix was moving the decision to a layer the client cannot manipulate. The original design trusted the client to tell the truth about its own identity.
The intuitive contract for an IP-based rate limiter seems straightforward: allow a user three generations per day. The original design used a Redis counter with a 24-hour time-to-live (TTL) clock. The intent was a rolling window. The logic delivered a fixed one, creating a flaw that was easy to miss during code review.
The bug hid within a standard conditional block in GuestGenerationService:
String count = redisTemplate.opsForValue().get(key);
if (count == null) {
redisTemplate.opsForValue().set(key, "1", SESSION_TTL);
} else {
redisTemplate.opsForValue().increment(key);
}This code looks correct at a glance because it covers both possible states. It creates the counter with a 24-hour duration when empty, and increases it when populated. The gap, however, is defined entirely by what is absent. The increment operation modifies the underlying integer value but does not interact with the key's expiration metadata. As a result, the 24-hour countdown starts on the first attempt and runs out precisely 24 hours later, unaffected by subsequent attempts.
The top timeline in the diagram shows the exact math of this exploit. If a user triggers their first generation at hour 0, Redis initializes the key and sets the 24-hour clock. The user triggers attempt two midway through the day and saves their third attempt until the 23:59 mark. The counter hits the max limit of three, but the original clock is still ticking down to its absolute deadline. One minute later, at hour 24, the key expires, the counter vanishes, and the user receives a fresh quota. For anyone who times their attempts near the window edge, the intended 24-hour cooldown collapses.
The bottom timeline shows the fix: adding redisTemplate.expire(key, SESSION_TTL) directly after the increment operation refreshes the expiry on every attempt, so the window slides forward with use.
This second flaw represents a different species of systemic failure than the first. While the network origin problem stemmed from trusting client input, this bug stemmed from trusting the apparent semantics of your own code. A time-to-live clock set only on creation is a fixed deadline, not a rolling window.
Even with a server-trusted network address and a sliding expiration window, an unauthenticated endpoint remains vulnerable. The rate limiter now correctly counts a trustworthy IP over a proper rolling window, yet the boundary is still trivially evadable. The problem is not that the IP resolution is wrong or that the window logic failed. The problem lies in what an IP address is.
An IP address is an identifier: it names a temporary network endpoint. It is not an identity: it does not establish who a visitor is, nor does it prove that two requests originated from the same person. The mapping between people and IP addresses is many-to-many. One person can access the web through multiple networks, such as a home connection, a mobile data hotspot, or a VPN exit node. Conversely, thousands of users can share a single public IP address behind a carrier-grade NAT.
The top region of the diagram illustrates the practical impact of this gap. A single user switches from their home network to a mobile hotspot and then activates a VPN. Because an IP-keyed rate limiter counts by network address, it evaluates these requests as User A, User B, and User C. The system does not malfunction here. It executes its tracking logic perfectly, but it counts three separate users where only one exists. The system fails because tracking an identifier answers the wrong question.
Hardening the gate required changing the question from "what is your IP" to "are you human?". Cloudflare Turnstile, verified server-side before the rate-limit check, introduces a proof-of-humanity challenge. Turnstile is not an absolute identity check. It does not identify the person behind the screen, but it raises the cost of presenting as multiple users. As shown in the bottom region of the diagram, automated script abuse gets stopped at the perimeter, and a human user cycling through multiple network addresses must solve a challenge on every jump, collapsing the exploit path down to a single quota.
Turnstile has limits. A dedicated human adversary can still manually solve challenges across different networks to get extra generations. The fix does not make the guest gate unbreakable. It makes breaking the system no longer free.
This is the deepest of the three flaws. Flaw one trusted the client's claim about its network origin. Flaw two trusted the apparent semantics of a fixed time-to-live clock. Flaw three trusted an identifier to do the work of an identity. In this final case, nothing was broken. The system correctly tracked an assertion that was never sufficient to track. An IP address answers where a request comes from, not who is making it.
Adding Turnstile introduced a new failure point: a request fails if the CAPTCHA check rejects it. The frontend must recognize this failure distinctly to reset the widget and instruct the user to try again. In a stringly-typed architecture, this requires introducing new magic strings and hoping they match across the API boundary. Kredvox instead relies on the typed error system introduced in the previous article.
As shown in the error lifecycle in the diagram below, absorbing this change required small additions across four distinct stages while leaving the core contract untouched. The backend added CaptchaVerificationException and mapped it in GlobalExceptionHandler to the CAPTCHA_FAILED error code. The network layer required no changes, as the existing ApiResponse envelope carried the structured payload over the wire without modification. On the client, the UI simply added a switch case for the CAPTCHA_FAILED code and registered a corresponding message in errorMessages.
The typed error system was not designed with CAPTCHA challenges in mind. It did not need to be. When the API envelope remains stable, a new failure mode requires only local additions rather than structural redesign. You do not find out if an abstraction was worth the effort when you build it. You find out when it absorbs a requirement you did not anticipate, and the contract holds.
Securing the guest gate required making tough product decisions on a tight deadline. While hardening the entry checkpoints made the system resilient against abuse, several architectural shortcuts and product gaps remain in the production codebase.
The most visible product gap is the lack of a signup migration path. When a guest user generates a post and decides to register a formal account, their work is orphaned. AuthService.register() contains no logic to associate existing guest context with the new user record. The new account starts blank, forcing the user to recreate their generation from scratch. This was a deliberate choice to prioritize perimeter security over onboarding mechanics, but it remains an unresolved user experience issue.
On the architectural side, the backend infrastructure for Server-Sent Events (SSE) is built but unused. GenerationProgressService maintains an in-memory ConcurrentHashMap of emitters designed to stream real-time updates, but the frontend ignores them. Instead, the client relies on a standard HTTP polling loop. Because the client polls instead of subscribing, the UI progress bar is simulated, faking linear progress from 10% to 85% over a fixed 90-second window. The mismatch between the built streaming architecture and the deployed polling implementation is an operational debt worth its own writeup.
Finally, the guest_sessions table has no automatic garbage collection. Old rows accumulate indefinitely in PostgreSQL without any scheduled cleanup. At current traffic levels the storage impact is negligible, but at scale it represents an index performance bottleneck and a data retention issue.
Naming these gaps is not a confession of failure. Shipping software requires choosing exactly what to leave undone, and an honest engineering audit is simply an accounting of those choices.
The guest gate's three flaws were three species of one mistake: trusting an assertion the system could not verify. The rate limiter worked, the expiration logic fired, and the database tracked exactly what it was told to track. Each component did precisely what it was built to do. Yet the gate failed because new attack surfaces emerge from features that were never designed to be attacked.
Securing a public endpoint is not about designing a flawless system on day one. You cannot anticipate how an adversary will twist standard, working code until that code faces real pressure. The actual engineering work happens in the gap between shipping and looking.
We started by looking for everywhere we trusted the client. We closed those gaps by moving network trust boundaries, correcting code semantics, and changing the underlying question entirely. You do not secure an unauthenticated gate by assuming your code works. You secure it by watching where it bends, finding where you trusted an unproven claim, and stopping.