Running Behind a Reverse Proxy

Make links, redirects and client addresses correct when TLS ends at a load balancer or proxy, without letting clients forge forwarded headers.

Syntaxserver.forward-headers-strategy=native | framework | none server.tomcat.remoteip.internal-proxies=<CIDR list or regex of proxy addresses> X-Forwarded-For, X-Forwarded-Proto, X-Forwarded-Host, X-Forwarded-Port, X-Forwarded-Prefix

In production a Spring Boot API rarely faces the internet directly. A load balancer, ingress controller or nginx terminates TLS and forwards plain HTTP to the application. From the application's point of view every request then comes from the proxy's address, over http, to an internal host name. Anything built from the request goes wrong: Location headers point at http://bookings:8080, redirects downgrade to HTTP, and every log line and rate limit sees one client.

Forwarded headers

Proxies report the original request in headers: X-Forwarded-For (client address), X-Forwarded-Proto (scheme), X-Forwarded-Host and X-Forwarded-Port, X-Forwarded-Prefix (a path the proxy stripped), or the standard Forwarded header. The application has to be told to use them, with server.forward-headers-strategy:

  • native: the embedded server applies them. For Tomcat this is the remote IP valve, which only trusts headers from addresses matching server.tomcat.remoteip.internal-proxies.
  • framework: Spring's ForwardedHeaderFilter applies them, including X-Forwarded-Prefix. It trusts every request, so the proxy must overwrite or remove these headers from clients.
  • none: headers are ignored.

When Spring Boot detects Kubernetes or another supported cloud platform and the property is not set, it uses native. Elsewhere the default is to ignore the headers, which is why an application that worked on a cluster can produce wrong links on a virtual machine.

Trust is the whole point

If anyone can reach the application directly, anyone can send X-Forwarded-For: 1.2.3.4 and appear to come from that address, defeating IP allow lists, rate limits and audit logs. Tomcat's default trusted ranges cover all private networks, which is often wider than your proxy. Narrow internal-proxies to the proxy's subnet (it takes a comma-separated CIDR list or a regular expression), and keep the application's port off the public network.

Other settings to line up

  • Body size: the proxy (client_max_body_size in nginx) and spring.servlet.multipart.max-file-size should agree, or users see different errors depending on which limit hits first.
  • Timeouts: the application's keep-alive timeout should be longer than the proxy's idle timeout, otherwise the proxy occasionally sends a request on a connection the server has just closed and returns a 502.
  • Path prefixes: if the proxy strips /bookings, either send X-Forwarded-Prefix with the framework strategy or set server.servlet.context-path and stop stripping.

Test it end to end through the proxy: create a resource and check the Location header uses https and the public host.

Example

Example · properties
# Let Tomcat apply X-Forwarded-For / X-Forwarded-Proto from trusted proxies
server.forward-headers-strategy=native

# Only these addresses are trusted to set the headers (CIDR list or a regular expression)
server.tomcat.remoteip.internal-proxies=10.0.0.0/16
server.tomcat.remoteip.remote-ip-header=x-forwarded-for
server.tomcat.remoteip.protocol-header=x-forwarded-proto

# Keep connections open longer than the proxy does, so it never reuses a closed one
server.tomcat.keep-alive-timeout=75s

# Match the proxy's body limit, or uploads fail with two different errors
spring.servlet.multipart.max-file-size=20MB
spring.servlet.multipart.max-request-size=20MB

When to use it

  • Return correct https Location headers from an API whose TLS ends at a cloud load balancer.
  • Log and rate-limit by the real client address instead of the ingress controller's address.
  • Serve an API under a path prefix such as /bookings on a shared domain.

More examples

An nginx proxy that sets the headers

<code>$proxy_add_x_forwarded_for</code> appends the client address to any existing list, and the remote IP valve walks that list from the right, skipping trusted proxies. Because nginx strips <code>/bookings/</code>, it sends <code>X-Forwarded-Prefix</code>; Tomcat's valve does not apply that header, so this setup needs the <code>framework</code> strategy or a context path instead of stripping.

Example · text
# nginx: TLS ends here, requests go to the app over plain HTTP
server {
    listen 443 ssl;
    server_name api.example.com;
    client_max_body_size 20m;

    location /bookings/ {
        proxy_pass http://bookings:8080/;
        proxy_set_header Host               $host;
        proxy_set_header X-Forwarded-For    $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto  $scheme;
        proxy_set_header X-Forwarded-Port   $server_port;
        proxy_set_header X-Forwarded-Prefix /bookings;
        proxy_read_timeout 60s;
    }
}

Location headers built from the request

<code>ServletUriComponentsBuilder.fromCurrentRequest()</code> builds the URI from the request as the server sees it. With forwarded headers applied, that is the public scheme and host the client used; without them, the client receives an internal address it cannot reach. The <code>native</code> strategy fixes scheme, host and port but not a stripped path prefix. The log line is a quick check that <code>getRemoteAddr()</code> returns the client, not the proxy.

Example · java
import java.net.URI;
import java.util.UUID;

import jakarta.servlet.http.HttpServletRequest;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.servlet.support.ServletUriComponentsBuilder;

record NewBooking(String roomId, String guestEmail) {}

record Booking(UUID id, String roomId, String guestEmail) {}

@RestController
@RequestMapping("/api/bookings")
public class BookingController {

    private static final Logger log = LoggerFactory.getLogger(BookingController.class);

    @PostMapping
    ResponseEntity<Booking> create(@RequestBody NewBooking body, HttpServletRequest request) {
        Booking booking = new Booking(UUID.randomUUID(), body.roomId(), body.guestEmail());

        // With forwarded headers applied: the client's address, https, the public host.
        log.info("booking created from {} via {}", request.getRemoteAddr(), request.getScheme());

        URI location = ServletUriComponentsBuilder.fromCurrentRequest()
                .path("/{id}")
                .buildAndExpand(booking.id())
                .toUri();
        // https://api.example.com/api/bookings/{id}, not http://bookings:8080/...
        // The /bookings prefix appears only when X-Forwarded-Prefix is applied (framework strategy).
        return ResponseEntity.created(location).body(booking);
    }
}

Check the result through the proxy

The first request should return an <code>https</code> location with the public host; the <code>/bookings</code> prefix is only there when the <code>framework</code> strategy applies <code>X-Forwarded-Prefix</code>. The second goes around the proxy: if the log then shows <code>1.2.3.4</code>, the trusted range is too wide, or the application port is reachable from places it should not be.

Example · bash
curl -si https://api.example.com/bookings/api/bookings \
  -H 'Content-Type: application/json' \
  -d '{"roomId":"r-12","guestEmail":"[email protected]"}' | grep -i '^location'
# location: https://api.example.com/bookings/api/bookings/6f1c...  (prefix needs the framework strategy)

# A forged header sent straight to the app must not change the logged address
curl -s http://10.0.4.17:8080/api/bookings -H 'X-Forwarded-For: 1.2.3.4' \
  -H 'Content-Type: application/json' -d '{"roomId":"r-1","guestEmail":"[email protected]"}'

Discussion

  • Be the first to comment on this lesson.