Skip to content

forward-headers-strategy defaults differently by environment

Severity: mediumApplies to: Spring Boot 4.1.0Applies to: Spring Boot 3.xFacts last verified 2026-08-16 against Spring Boot 4.1.0 (reference and API docs; no JVM available to reproduce)

Most settings on this site have one default. This one has two, and which you get is decided at startup by the environment rather than by anything in your configuration.

From the Boot reference: “If your application runs in a supported Cloud Platform, the server.forward-headers-strategy property defaults to NATIVE. In all other instances, it defaults to NONE.”

So the same artifact, with the same application.properties, honours X-Forwarded-For and X-Forwarded-Proto in one environment and ignores them completely in another.

settings on this page
server.forward-headers-strategyproperty

Whether X-Forwarded-* headers are honoured, and by which layer. The default is conditional rather than fixed — Boot resolves it from the platform it detects at startup, so the same artifact behaves differently in two environments with identical configuration.

accepts
NONE | NATIVE | FRAMEWORK
default
NATIVE on a detected cloud platform, NONE everywhere else
set in
application.properties
Read in: Spring Boot 4.1.0 reference — Running Behind a Front-end Proxy Server
server.tomcat.remoteip.internal-proxiesproperty

Which upstream addresses Tomcat will accept forwarded headers from. Defaults to a regex covering loopback and the RFC 1918 private ranges, so NATIVE on Tomcat trusts a proxy on a private address and ignores one that reaches it from a public address. Setting it empty trusts every proxy — the reference says explicitly not to do that in production.

accepts
<regex> | (empty)
set in
application.properties
Read in: Spring Boot 4.1.0 reference — Running Behind a Front-end Proxy Server; exact regex in the application-properties appendix
spring.main.cloud-platformproperty

Overrides platform detection. Setting it pins the behaviour above instead of leaving it to whatever environment variables happen to be present, which is the way to stop this default moving under you.

accepts
NONE | KUBERNETES | CLOUD_FOUNDRY | HEROKU | SAP | NOMAD | AZURE_APP_SERVICE | AWS_ECS
set in
application.properties
Read in: Spring Boot 4.1.0 API — org.springframework.boot.cloud.CloudPlatform

What “supported Cloud Platform” covers

Section titled “What “supported Cloud Platform” covers”

Boot detects the platform from environment variables at startup. The CloudPlatform values in 4.1.0 are CLOUD_FOUNDRY, HEROKU, SAP, NOMAD, KUBERNETES, AZURE_APP_SERVICE, AWS_ECS and NONE.

KUBERNETES is the one that matters, because it is where this stops being trivia. A service deployed to a cluster gets NATIVE and reads forwarded headers. The same service running on a developer’s machine, in a plain docker run, or on a VM behind nginx gets NONE and does not — so request.getRemoteAddr() returns the proxy and request.isSecure() returns false, and every symptom looks like a proxy misconfiguration rather than a defaulting rule.

The direction that catches people is usually the second one: something works in the cluster, then does not work in staging on a VM, and the configuration files are identical because the difference was never in them.

the fix — state it, do not inherit it
application.properties
# Say what you mean, in every environment.
server.forward-headers-strategy=FRAMEWORK
# And pin the platform so detection cannot move the answer.
spring.main.cloud-platform=NONE

Setting the strategy explicitly is the whole fix. Which value you choose matters less than the fact that it is written down:

  • NATIVE hands the job to the embedded server — Tomcat’s RemoteIpValve, or the Jetty and Reactor Netty equivalents. It is efficient and it brings Tomcat’s own trust rules with it, described below.
  • FRAMEWORK installs Spring’s ForwardedHeaderFilter (or ForwardedHeaderTransformer on the reactive stack), which behaves the same way on every server.
  • NONE ignores the headers.

NATIVE carries a second default you did not set

Section titled “NATIVE carries a second default you did not set”

Choosing NATIVE on Tomcat does not mean “trust X-Forwarded-For”. It means “trust X-Forwarded-For from an address matching server.tomcat.remoteip.internal-proxies”, which defaults to a regex covering loopback and the RFC 1918 private ranges.

That is a sensible default and it is also a second thing that varies by deployment. A proxy on the same private network is trusted; a proxy that reaches the application from a public address is not, and the headers are silently ignored — the same visible symptom as NONE, from a different cause.

The reference gives the escape hatch and immediately warns against it: “You can trust all proxies by setting the internal-proxies to empty (but do not do so in production).” An empty value means any client that can reach the port can assert its own address. Narrow the regex to the proxy you run instead of widening it to everything.

verify it workedRun this in: framework cli
Terminal window
# what did Boot actually resolve at startup?
curl -s -u user:PASSWORD http://localhost:8080/actuator/env \
| grep -o '"server.forward-headers-strategy":{[^}]*}'

Reading it back from the running application is the point — the value you care about is the resolved one, not the one in the file, and on a cloud platform those differ by design. This needs the env endpoint exposed, which is not the default and should not be left on; see Actuator endpoint exposure and what /env actually shows before enabling it.

The behavioural check needs no Actuator at all: log request.getRemoteAddr() and request.isSecure() on a test endpoint, call it through the proxy, and see whether they describe the client or the proxy.

before you ship this

Turning the strategy on where it was previously NONE changes getRemoteAddr(), isSecure(), getServerName() and every absolute URL the application generates, all at once. Redirects and links start using the external host and scheme, which is usually the reason for enabling it — and which will break anything that was quietly relying on the internal hostname.

isSecure() becoming true also activates any requiresChannel().requiresSecure() rule and lets Secure cookies through for the first time. If the proxy is not in fact terminating TLS, you have just told the application it is.

Pinning spring.main.cloud-platform has effects beyond this setting, since other auto-configuration consults the same detection. Change it deliberately and check what else moves rather than setting it purely to stabilise this one property.