How I reviewed client-IP handling, rate limiting, and admin authentication in the Know Nepal backend — what changed, how it was tested, and what remains open.

Introduction

Know Nepal is an open knowledge platform that organizes information about Nepal — geography, education, healthcare, culture, and destinations — into structured, searchable data. It has a Next.js frontend and a Spring Boot backend deployed as a modular monolith: seven domain modules (geography, education, healthcare, destinations, culture, history, and wildlife) running in a single application against PostgreSQL. The platform serves public knowledge pages and APIs, and it also includes a small admin/CMS area used to manage content. One of its newer features is an AI trip planner that accepts a trip brief and returns a generated itinerary.

Security review matters here for a simple reason: as features are added, the number of places where untrusted input crosses a trust boundary grows. A public POST endpoint that calls an external AI provider has a real cost, so it needs rate limiting. An admin area that can create and modify data needs real authentication and authorization. And rate limiting that identifies clients by a header the client controls is not really rate limiting at all. This article describes the security work I did on the backend, what I verified, and what is still open.

Reviewing the Security Boundaries

Before changing anything, I mapped out who is allowed to do what. In the current backend, the boundaries fall into two groups.

Public, unauthenticated access:

  • Read-only API access: GET requests under /api/v1/** are public.
  • The AI trip planner: POST /api/v1/ai/trip-planner is deliberately public.
  • Operational and documentation endpoints: actuator health/info, Swagger UI, and the API docs.
  • Content pages served by the Next.js frontend.

Privileged, admin-only access:

  • All non-GET /api/v1/** requests (POST, PUT, PATCH, DELETE) require the ADMIN role.
  • The admin/CMS frontend signs in through POST /api/auth/login and then calls the API with a JSON Web Token.

Authentication here belongs to the admin/CMS area only. There is no general visitor account system, and public users do not authenticate to read content or to use the trip planner. Keeping that distinction clear matters: hardening admin authentication is not the same as protecting public users, and a public, unauthenticated endpoint is a deliberate decision that has to be rate limited and made resilient.

The backend is a modular monolith, not a set of independently deployed microservices. That matters for the rate limiter: with a single application instance in the current deployment, an in-memory limiter is a reasonable first step, but it would not be correct if the application were scaled horizontally.

Preventing Forwarded-IP Header Spoofing

The weakness

The AI trip planner is rate limited per client, and the original implementation derived the client key from the X-Forwarded-For header:

String forwarded = request.getHeader("X-Forwarded-For");
if (forwarded != null && !forwarded.isBlank()) {
    int comma = forwarded.indexOf(',');
    return (comma > 0 ? forwarded.substring(0, comma) : forwarded).trim();
}
String remote = request.getRemoteAddr();
return remote == null ? "anonymous" : remote;

X-Forwarded-For is just a request header. Unless a trusted proxy is known to have set it, any client can send whatever value it likes. Because the limiter keyed on the first value in that header, a caller could rotate the header on every request and never reach the limit. The same property means one client could exhaust another client's bucket by spoofing their address. Neither outcome requires a sophisticated attack; it is a matter of setting a header.

The fix

The corrected implementation ignores the header at the application layer and uses the address the server actually sees:

String clientKey(HttpServletRequest request) {
    String addr = request.getRemoteAddr();
    return (addr == null || addr.isBlank()) ? "anonymous" : addr;
}

The method was also made package-private so it could be unit tested directly.

Using getRemoteAddr() alone would rate limit every request by the proxy's address, because in most proxy deployments the immediate peer is the proxy, not the client. The way to resolve the real client address safely is to let the server process forwarded headers only from proxies it trusts:

server:
  forward-headers-strategy: native

With the native strategy, Spring Boot delegates forwarded-header handling to the embedded servlet container's valve, which only honours X-Forwarded-For when the request arrives from a peer that matches its configured trusted-proxy list (private address ranges by default). A client that simply adds a forged header is not a trusted proxy, so the header is ignored and getRemoteAddr() keeps returning the real peer.

A deployment assumption to verify

This fix has a deployment-specific edge. The trusted-proxy list is a property of the running server, not something the application code guarantees. If the production platform's proxy is not within the trusted range, forwarded headers will be ignored and the limiter will see the proxy's address, causing all clients to share one bucket. The opposite risk — trusting a header from the wrong peer — is exactly why the trusted list matters. I have not verified the production proxy configuration from the code alone, so this remains a deployment-level check rather than a proven property.

Tests

TripPlannerControllerClientKeyTest covers this behaviour with seven tests. They assert that a normal request returns the remote address, that a spoofed X-Forwarded-For value is ignored, that multiple forwarded values are ignored, that a missing or blank header still returns the remote address, and that a null or blank remote address falls back to "anonymous".

Preventing Rate-Limiter Memory Retention

The design

The limiter is a small in-memory sliding window. Each client key maps to a deque of request timestamps. A request older than the configured window is discarded, and a client that has reached the maximum number of requests in the window is rejected with HTTP 429.

private final Map<String, ArrayDeque<Long>> requestLog = new ConcurrentHashMap<>();

The window and limit are configurable: the default allows ten requests per sixty seconds, overridable through the environment. The map is a ConcurrentHashMap, and each bucket is an ArrayDeque guarded by synchronizing on that deque.

The weakness

A map keyed by client address only stays small if entries are removed when they are no longer needed. The original code trimmed expired timestamps inside each client's deque, but it did not remove the now-empty deque from the map. Every address that ever made a request left an empty bucket behind. Over time, and especially under scanning or abuse from many addresses, the map would keep growing even though no client was being limited. That is a slow memory leak rather than an immediate failure, which is exactly the kind of issue that is easy to miss.

The fix

The limiter now removes an empty bucket both during a normal check and during a scheduled cleanup pass.

long now = System.currentTimeMillis();
ArrayDeque<Long> log = requestLog.computeIfAbsent(clientKey, k -> new ArrayDeque<>());

synchronized (log) {
    while (!log.isEmpty() && now - log.peekFirst() > windowMillis) {
        log.pollFirst();
    }
    if (log.isEmpty()) {
        requestLog.remove(clientKey, log);
    }
}

log = requestLog.computeIfAbsent(clientKey, k -> new ArrayDeque<>());
synchronized (log) {
    if (log.size() >= maxRequests) {
        throw new TooManyRequestsException(
                "Too many planning requests. Please wait and try again.");
    }
    log.addLast(now);
}

The scheduled pass trims every bucket and then removes the empty ones:

public void cleanup() {
    long now = System.currentTimeMillis();
    requestLog.values().forEach(log -> {
        synchronized (log) {
            while (!log.isEmpty() && now - log.peekFirst() > windowMillis) {
                log.pollFirst();
            }
        }
    });
    requestLog.values().removeIf(ArrayDeque::isEmpty);
}

Scheduling is enabled on the application class, and the cleanup interval defaults to ten minutes. The remove(key, value) call is a conditional remove, so it will not delete a bucket that another thread has just repopulated.

Concurrency, stated carefully

Per-bucket mutation is guarded by synchronizing on the deque, and the map is thread-safe. The intent is that a client's window is only mutated by one thread at a time. The tests verify lifecycle behaviour — that expired and empty buckets are removed, that an active bucket is preserved, and that the limit still applies after the window passes. They do not exercise the limiter under sustained concurrent load, so what is verified is the correctness of the lifecycle rules, not throughput or contention behaviour.

Securing Admin Authentication and Authorization

This is authentication for the admin/CMS area. It is not a visitor login, and it does not change the fact that the public read APIs and the trip planner are intentionally open.

Password hashing

The original backend compared the submitted password against a configured value in plain text and issued a token without any role information. The current login compares against a BCrypt hash instead. Spring Security's BCryptPasswordEncoder is registered as the PasswordEncoder bean, and login uses passwordEncoder.matches(...):

if (adminUsername.equals(username) && passwordEncoder.matches(password, adminPasswordHash)) {
    String token = jwtUtil.generateToken(username, "ADMIN");
    return ResponseEntity.ok(Map.of("accessToken", token, "username", username));
}
return ResponseEntity.status(401).body(Map.of("error", "Invalid credentials"));

Storing a hash rather than a password means a leaked configuration value does not directly reveal the password, and BCrypt is deliberately slow, which raises the cost of offline guessing.

JWT role claims

Tokens now carry a role claim. The authentication filter reads the subject and role from a validated token and maps the role into a Spring Security authority:

String username = jwtUtil.extractUsername(token);
String role = jwtUtil.extractRole(token);
List<SimpleGrantedAuthority> authorities = role != null
        ? List.of(new SimpleGrantedAuthority("ROLE_" + role))
        : List.of();

If the signature does not verify or the token is expired, validateToken returns false and no authentication is set for the request.

Authorization rules

The security filter chain makes the boundary explicit:

.requestMatchers("/api/auth/login").permitAll()
.requestMatchers("/actuator/**").permitAll()
.requestMatchers("/swagger-ui.html", "/api-docs/**").permitAll()
.requestMatchers(HttpMethod.GET, "/**").permitAll()
.requestMatchers(HttpMethod.POST, "/api/v1/ai/trip-planner").permitAll()
.requestMatchers(HttpMethod.POST, "/api/v1/**").hasRole("ADMIN")
.requestMatchers(HttpMethod.PUT, "/api/v1/**").hasRole("ADMIN")
.requestMatchers(HttpMethod.PATCH, "/api/v1/**").hasRole("ADMIN")
.requestMatchers(HttpMethod.DELETE, "/api/v1/**").hasRole("ADMIN")
.anyRequest().denyAll()

Reads are public; writes are admin-only; the trip planner is the one deliberate public write endpoint; and anything not matched is denied rather than allowed. Sessions are stateless, CORS is restricted to configured origins, and CSRF is disabled, which is the usual posture for a stateless token API rather than a cookie-session API.

On the frontend, the admin login route forwards credentials to the backend and stores the returned token in an httpOnly cookie, so the token is not readable from JavaScript, and the session check calls the backend's /api/auth/me endpoint.

Remaining concerns in this area

Two defaults deserve attention before this can be considered production-hardened. The JWT signing secret and the admin password hash both have committed fallback values that are used when the environment variables are not set. Production sets them explicitly, but a fallback secret or fallback credential that ships in the repository is a weak default; the safest configuration fails fast when they are missing. This is a known open item, not a solved problem.

There is also a correctness bug worth recording. The /api/auth/me endpoint treated Spring Security's anonymous authentication as authenticated, because isAuthenticated() returns true for the anonymous token, so the endpoint could report an authenticated principal where none existed. A fix that explicitly excludes AnonymousAuthenticationToken exists on the development branch but is not yet merged to main.

Security Testing and CI

Focused unit tests

Two unit tests cover the changes directly:

  • PlannerRateLimiterTest: nine tests covering the request limit, per-client independence, null and blank keys, and the cleanup lifecycle.
  • TripPlannerControllerClientKeyTest: seven tests covering client-key resolution and the ignoring of spoofed forwarded headers.

I ran both locally while preparing this article. Sixteen tests ran with zero failures.

Integration and security tests

SecurityBoundaryTest is a Spring Boot integration test backed by a Testcontainers PostgreSQL instance. It starts the application on a random port and checks the boundary end to end: unauthenticated GET /api/v1/** is permitted; unauthenticated POST, PUT, PATCH, and DELETE are rejected with 401 or 403; the login endpoint is reachable; the root health check is public; and a valid admin token can perform a read and a write. It contains eleven test methods.

I could not run this test in my local environment because it needs a container runtime, which was not available. It is configured to run in continuous integration instead. I am not claiming a local pass for it.

Test discovery

There was a real problem here worth calling out. SecurityBoundaryTest is named with a "Test" suffix, which is the default pattern Maven Surefire uses for unit tests, but it is an integration test: it needs a database and a container runtime. Running it as a unit test is wrong, and skipping it by accident is worse.

The project resolves this by explicitly excluding it (along with the other integration tests) from Surefire and explicitly including it in Failsafe. Failsafe's default patterns are names like *IT or IT*, which SecurityBoundaryTest does not match, so the explicit include is what actually makes it run:

<!-- Surefire -->
<exclude>**/SecurityBoundaryTest.java</exclude>

<!-- Failsafe -->
<include>**/SecurityBoundaryTest.java</include>

The lesson is that a test only counts if it actually executes. A test that never runs because of a naming pattern gives false confidence.

CI

The backend job in GitHub Actions runs the full Maven reactor with:

mvn -B -ntp clean verify -fae

Because it uses verify rather than test, Failsafe runs and the integration tests are included. The job also prints a compact summary of the surefire and failsafe reports so failures are visible in the log, and it uses fail-at-end so one failing module does not hide the others. The workflow triggers on pushes to the main, develop, and development branches and on pull requests into main.

What I can and cannot claim: the two unit tests above passed locally, and the security integration test is wired to run during verify in CI. I have not included a verified CI run for this article, so the integration test's status in CI is configured, not confirmed.

Lessons Learned

  • Trust boundaries are the whole game. The interesting questions were not "is this endpoint secure" but "where does untrusted input enter, and what do we do with it". The X-Forwarded-For header and the rate-limit map were trust-boundary problems in different forms.
  • Client-supplied headers are not automatically trustworthy. X-Forwarded-For is useful exactly when a trusted proxy sets it, and dangerous when anyone can.
  • Rate limiting needs both correct request identification and lifecycle management. Getting the key right is half the job; the other half is not leaking memory as clients come and go.
  • Security tests must actually execute in CI. Test-discovery patterns decide whether a test runs, and a passing build that skipped the important test proves nothing.
  • A passing build is not proof that a system is secure. It means the checks that were run passed, and a full review is broader than the changes described here.

Conclusion and Remaining Work

The work described here tightened three concrete areas. Spoofed forwarded headers no longer control the rate limiter's client key, and the server now resolves forwarded addresses only from trusted peers. The rate limiter removes empty client buckets instead of retaining them indefinitely. Admin authentication uses BCrypt-hashed passwords and JWT role claims, and the security filter chain restricts writes to the ADMIN role while keeping reads and the trip planner public by design. Focused unit tests for the header handling and limiter lifecycle pass locally.

A few things remain open, and I would rather state them than overstate the result:

  • The rate limiter is in-memory and single-instance. It limits cost in the current deployment but is not correct under horizontal scaling; a shared store such as Redis would be needed.
  • The trusted-proxy behaviour behind the native forward-headers strategy depends on the production proxy configuration and should be confirmed in the deployed environment.
  • The committed fallback values for the JWT secret and the admin password hash should be replaced by required configuration that fails fast when they are unset.
  • The admin session-hardening change and the /api/auth/me anonymous-principal fix currently live on the development branch and are not yet merged to main.
  • The security integration test has not been observed passing in this review; it should be confirmed in CI.

None of this amounts to a complete security audit. It is a focused set of fixes with tests, and the value is in the engineering: identify a trust boundary, change the behaviour, and verify it with something that actually runs. The Know Nepal project is the system this work applies to.