1. Transport and headers: the reality of production
If up to this point Spring Security felt to you like some kind of “smart turnstile at the controller entrance,” then today we will add a second layer of reality: the turnstile may be perfect, but the doors nearby still have to close properly. Any authentication and authorization in our project travels over HTTP in one form or another: Authorization for Basic, cookie for session, Authorization: Bearer ... for JWT. That means transport and response headers are not decorative ornaments, but conditions under which your security model actually makes sense.
Imagine that you did everything right: roles USER/EDITOR/ADMIN, @PreAuthorize, owner checks, JSON errors. In tests everything is green. Then the project is deployed behind a reverse proxy that terminates TLS. The application suddenly “thinks” requests are coming over plain HTTP. As a result, odd things may show up: somewhere a redirect goes to http://..., a cookie suddenly does not become Secure, HSTS does not work, and some clients start behaving as if you “broke login.” And yes — it will look like Spring Security is “being picky,” even though the request context is to blame.
In this lecture we will keep one simple idea in mind: Spring Security protects applications in HTTP reality, and HTTP reality in production almost always includes HTTPS and reverse proxy. So we need three pieces of knowledge that give us an operational baseline: what security headers are and why you should not just disable them all at once, why HTTPS matters even for APIs, and how Spring Boot/Spring Security understand that “it was HTTPS on the outside” when HTTP reached the application internally.
Even if secrets and TTL have already been moved out of the code, security still depends on how the request actually reaches the application. So next we will look at the external HTTP perimeter: which headers we emit, when the application considers a request secure, and what changes when a proxy appears between the client and the application.
2. Security headers: Spring Security baseline mode
When you first see http.headers(...) in a config, a beginner’s hand sometimes reaches for a heroic solution: “let’s disable it, maybe it is getting in the way.” That is roughly the same as fixing a door squeak by removing the door from the apartment. It works, but then you wonder why there is a draft. Security headers are a set of HTTP response headers that tell the client (especially a browser) how to behave more safely: what to cache, whether the page may be embedded in an iframe, whether it may “guess” the content type, and so on.
Spring Security takes a very mature approach here: secure behavior is enabled by default. And that is not “to make life harder,” but so your project does not depend on the discipline of “we will definitely enable it later.” In most security courses (and in real life), trouble comes not from someone creating a sophisticated vulnerability, but from someone leaving a hole “by default.” Headers are exactly that kind of story: you rarely “feel” them until a problem happens.
In our Secure Content Platform API project, these headers are especially important in places where the client may potentially be a browser. Even if we are testing through Postman right now, the real world often includes Swagger UI, a possible SPA integration, admin tools, and a login page in the stateful branch. So the basic principle is this: keep the defaults first, and make targeted changes only when you understand the reason.
The minimal, correct baseline directly in SecurityFilterChain looks like this:
package com.example.securecontent.security.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;
@Configuration
public class SecurityConfig {
@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
// Keep Spring Security's default security headers enabled
// (this is a safe baseline that can be tuned later in a targeted way).
http.headers(Customizer.withDefaults());
// Important: return the assembled filter chain, otherwise the config "will not apply".
return http.build();
}
}
The point of this fragment is not that “we enabled something.” The point is that we explicitly fix the fact that headers are part of the security model, and we deliberately keep the secure baseline enabled.
3. Key security headers: a practical overview
It is very easy to get lost in headers, because there are many of them, and each one sounds like a spell from an ancient manuscript: X-Content-Type-Options, Strict-Transport-Security, Content-Security-Policy. But as backend developers, we need a practical level: understand what each header is about, why Spring Security adds it, and why turning it off wholesale almost always makes things worse, even if “nothing broke right now.”
It is also important to remember that headers differ in meaning. Some headers protect against specific browser attacks, such as clickjacking; some protect against “strange client behavior,” such as content sniffing; some enforce caching discipline. API clients like curl usually react to this neutrally, but a browser does not. And in the Java backend world, the browser appears more often than you might think: even if you have “pure REST,” Swagger UI or a simple admin panel already turns your service into a web application.
Below is a compact table for orientation, not as a list to memorize, but as a map of the terrain:
| Header | Idea in simple words | What can happen if it is missing |
|---|---|---|
| X-Content-Type-Options: nosniff | “Do not guess the content type yourself, trust Content-Type” | The browser may try to interpret the response “the wrong way,” which sometimes turns into XSS-like problems |
| X-Frame-Options or CSP frame-ancestors | “Do not embed the page in someone else’s iframe” | Clickjacking: the user clicks a button without realizing it is overlaid |
| Cache-Control, Pragma, Expires | “Do not cache sensitive responses” | Private data may end up in a browser/proxy cache for later |
| Strict-Transport-Security (HSTS) | “If you are a browser, do not come here over HTTP at all” | The user may accidentally land on the HTTP version and hit a downgrade attack; HSTS reduces the risk |
| Content-Security-Policy (CSP) | “Which scripts/styles/resources are allowed at all” | Reduces the impact of XSS, but requires care; more important for UI pages than for JSON |
Notice the word “browser” in many descriptions. That is normal. A backend developer needs not to mix things up: CORS is also a browser story, CSRF too. Security headers belong to the same category: the “client-side” layer of security, but controlled by the server.
And one more practical thought: headers themselves do not replace @PreAuthorize, roles, authorities, and owner checks. They do not solve the task of “who can publish an article,” but they help ensure the client does not become your unpredictable accomplice in an attack.
4. HTTPS for JSON APIs: what it gives you
A beginner often thinks about HTTPS like this: “well, we do not put tokens and passwords in the URL, so everything is fine.” That is a dangerous oversimplification. HTTPS (TLS) is not about where the token lives; it is about whether someone along the way can read or tamper with your HTTP traffic. If you send Authorization: Basic ..., then without HTTPS you are literally sending a login and password in a format that is easy to recover. If you send a cookie with JSESSIONID, then without HTTPS you are handing over the session identifier. If you send Authorization: Bearer ..., then without HTTPS you are handing over the “key to the apartment” in plain view.
Even with JWT, where there is “a signature and all that inside,” a bearer token remains bearer: whoever holds the token is the user. No signature helps if the token was stolen from the network. That is why transport security is the foundation for all three models we compared in the course: session, basic, JWT.
You can add extra protection in the application: explicitly say that we expect HTTPS only. For an educational baseline, this is useful at least as a “safety net against accidentally running in the wrong environment.” In Spring Security it looks like this:
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
// Require HTTPS for all requests.
// Important: behind a reverse proxy, this will work correctly only when forwarded headers are configured.
http.requiresChannel(channel -> channel
.anyRequest().requiresSecure()
);
The meaning of this code snippet is simple: if a request arrives over HTTP, the application will try to redirect it to HTTPS. And here comes the important “but,” which is why in real projects this fragment is always considered together with reverse proxy: if TLS ends at the proxy and HTTP reaches the application, then from the application’s point of view all requests look like HTTP, and you may accidentally get an infinite redirect, strange behavior, or a situation where “everything became unavailable.”
So the rule is this: requiresSecure() is a good tool, but it works correctly only if the application properly understands that the external request was HTTPS. And that is exactly where we get to reverse proxy and forwarded headers.
5. Reverse proxy in production: role and effects
If up to now you have been running the application “directly on port 8080” and accessing it straight away, production usually looks different. In front of your Spring Boot application there is a reverse proxy: Nginx, Envoy, HAProxy, a Kubernetes ingress controller, or even a managed cloud load balancer. It accepts external requests and then forwards them inward to your application.
At a human level, a reverse proxy does several things at once. It often terminates TLS (meaning the client connects to the proxy over HTTPS, and the proxy talks to the application over HTTP inside the network), can balance load across multiple service instances, can compress responses, restrict access by IP, and apply rate limits. And yes — sometimes it “adjusts” headers in a way that makes your application see a different request context.
Here is the typical diagram where “HTTPS awareness” usually begins:
flowchart LR
%% The client always talks to the external perimeter (proxy), while the application already sees an "internal" request.
Client["Client (browser/SPA/Postman)"]
Proxy["Reverse proxy / LB
TLS terminate"]
App["Spring Boot application
Secure Content Platform API"]
Client -- "HTTPS" --> Proxy
Proxy -- "HTTP inside the network + Forwarded headers" --> App
The key point for your application here is that it sees the incoming request as HTTP. But from the outside, the client was talking over HTTPS. If the application cannot reconstruct the “external reality,” you get impressive bugs: redirects to http://, incorrect links in responses, inability to set a Secure cookie correctly, HSTS not being enabled, and requiresSecure() turning into a self-inflicted loop.
And no, this is not “pure DevOps.” This is the point where a backend developer should at least understand the terminology, otherwise you will be fixing symptoms in controller code instead of the request context settings.
6. Forwarded headers: HTTPS awareness
The most common cause of “strange behavior behind a proxy” is that the application does not understand the original request scheme. For this, the reverse proxy adds special headers. In educational reality you will more often see X-Forwarded-Proto: https, X-Forwarded-Host, X-Forwarded-Port, and sometimes the standard Forwarded: proto=https;host=... These headers carry one idea: “the external request was like this, I just, the proxy, got to you differently.”
Spring Boot can take these headers into account, but this needs to be enabled and it is important to enable it correctly. In our baseline, the simplest and clearest option is to configure the forwarded headers processing strategy at the application server level:
server:
# Tell Spring to take forwarded / X-Forwarded-* headers from the reverse proxy into account.
# This is needed so that scheme/host/port are restored correctly (for example, HTTPS "from the outside").
forward-headers-strategy: framework
This setting says something like: “use Spring Framework capabilities to process forwarded headers and correctly restore the request scheme/host/port.” After that, many things start to work as expected: request.isSecure() becomes true where the external request really was HTTPS, redirects are formed more correctly, and solutions that depend on the scheme work more reliably.
There is an important security caveat here that is worth stating at the fundamentals level. Forwarded headers are headers, and headers can be forged by the client. If your application blindly trusts forwarded headers from anyone, an attacker may try to “play proxy” and inject X-Forwarded-Proto: https so that the application considers the request secure. That is why in real systems trust in forwarded headers is usually limited at the infrastructure level: the proxy should overwrite those headers itself, and direct external requests to the application should be blocked. We are not diving into DevOps configuration, but you should understand why this matters: “HTTPS awareness” is not faith in headers, but a correctly built chain of “client → proxy → application.”
If after enabling forwarded headers you use requiresSecure(), then the logic becomes sensible: the application sees a secure request and does not try to endlessly redirect itself. Plus you get more correct behavior from headers tied to HTTPS, such as HSTS (it only makes sense when the browser is already talking to you over HTTPS).
7. Common mistakes when working with security headers, HTTPS, and reverse proxy
Mistakes in this topic are especially insidious because they look like “Spring Security is acting weird again,” when in fact it is just a mismatch between request context and your expectations. And the closer you get to production, the more often such mistakes appear “suddenly,” because locally you were working without a proxy and without HTTPS.
Mistake #1: global headers().disable() “so it does not get in the way”.
This is a classic. Headers rarely get in the way of your API client, but often protect the browser client from unpleasant scenarios. By disabling them, you usually do not solve the problem; you make the project less secure and complicate future audits. If something “gets in the way,” it is better to configure a specific header precisely than to turn everything off.
Mistake #2: thinking that “we use JWT, so HTTPS is not necessary.”
JWT is not a bulletproof vest; it is a token format. If the bearer token went over HTTP, it went in the clear. This is the case where the cryptography inside the token does not save you from leakage in transit. HTTPS is needed for session, basic, and JWT — for different reasons, but the result is the same: without transport, the whole model cracks.
Mistake #3: enable requiresSecure(), but forget about reverse proxy and forwarded headers.
On localhost this may look “fine,” but in production behind a proxy, redirect loops or complete access denial begin. The reason is almost always the same: the application sees the incoming request as HTTP and honestly tries to “move it to HTTPS,” not understanding that HTTPS was already on the outside.
Mistake #4: trust forwarded headers from anyone.
If the application is directly reachable from the internet, the client can forge X-Forwarded-Proto and other headers. So “HTTPS awareness” is not “believe the headers,” but “be behind a proxy that controls those headers.” At the course level, it is enough to remember this: forwarded headers are useful, but trust in them must be deliberate.
Mistake #5: trying to fix transport problems in controllers.
Sometimes you see code where people manually rewrite links, add strange Location headers, or glue on https:// as a string. That is usually a sign that the application does not understand the original request scheme. Such things should be solved at the forwarded headers and security configuration level, not inside business logic.
GO TO FULL VERSION