In 05-01 we added a dependency and the CicloUrbana API was locked down tight: a user called user, a password that changes on every startup, an HTML form that is no use whatsoever to a mobile application and a 403 from CSRF every time we try to create a station. It is secure, but it is useless. This lesson turns that scaffolding into a deliberate configuration.
We are going to write the first real class of the com.ciclourbana.security package: SecurityConfig, with its SecurityFilterChain bean. We will learn Spring Security 6's lambda DSL section by section, we will define the complete access map of Ribalta's network, we will understand why the order of the rules is what everybody gets wrong most often, and we will take on merit —not out of habit or by copying from the internet— the four decisions that define the character of the API: passwords, CSRF, sessions and headers. By the end, CicloUrbana will have real locks, even if the keys remain provisional until 05-03.
Warning. Every user, password and origin in this lesson is fictional and exists for the example. The plain-text passwords that appear here are only acceptable in an educational example run locally; they are never written into a committed file. Every security configuration must be reviewed by a security professional before being exposed to the internet.
Contents
- The Spring Security 6 component model
SecurityConfig: the first class of.security- The lambda DSL, section by section
authorizeHttpRequestsandrequestMatchers- The golden rule of rule ordering
- CicloUrbana's access map
- In-memory users with
InMemoryUserDetailsManager - Password encoding
- HTTP Basic and form login
- CSRF: what it is and when it can be disabled
- Session management:
STATELESS - Integrating the CORS configuration from 03-02
- Security headers on the response
- Several chains with
@OrderandsecurityMatcher - Debugging security
- Common Mistakes and Tips
- Exercises
- The Spring Security 6 component model
If you look for Spring Security examples on the internet, half of what you find will not compile. The reason is a change of model:
// Spring Security 5 and earlier — REMOVED in version 6. Do not use it.
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception { ... }
}WebSecurityConfigurerAdapter was deprecated in Spring Security 5.7 and removed in 6.0. If you try to extend it with Spring Boot 3.x, the code does not even compile. The replacement is not cosmetic: it is a change of philosophy, from inheritance to composition.
| Aspect | Old model (inheritance) | Current model (beans) |
|---|---|---|
| Extension point | Extend a class and override methods | Declare beans |
| Several chains | Several inner classes, confusing ordering | Several SecurityFilterChain beans with @Order |
Customising the AuthenticationManager |
Override a protected method | Declare a bean or expose it from AuthenticationConfiguration |
| Checking what is configured | Hard: inherited state | Easy: the beans are in plain sight |
| Fits with the rest of Spring Boot | So-so | Just like any other configuration |
Three reasons for the change: inheritance forced a stateful object whose behaviour depended on which methods had been overridden; it did not compose well, because two configurations required inner classes with non-obvious ordering rules; and it was inconsistent with the rest of Spring Boot, where everything is configured by declaring beans (02-01).
The practical consequence is that the whole of CicloUrbana's security configuration will be beans in an ordinary @Configuration class, exactly like CorsConfig (03-02) or OpenApiConfig (03-07).
SecurityConfig: the first class of .security
SecurityConfig: the first class of .securitypackage com.ciclourbana.security;
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.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.web.SecurityFilterChain;
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/v1/stations/**").permitAll()
.anyRequest().authenticated())
.httpBasic(Customizer.withDefaults());
return http.build();
}
}Four elements worth understanding one by one:
@Configuration is an ordinary configuration class (02-01); nothing special.
@EnableWebSecurity. It imports the web security configuration. With Spring Boot it is optional, because autoconfiguration already applies it, but it is written for two reasons: it makes explicit that this class governs security, and it is essential when you want to switch on debug mode (@EnableWebSecurity(debug = true), section 15).
HttpSecurity injected as a parameter is a chain builder with prototype scope: Spring hands over a fresh, pre-configured instance for each SecurityFilterChain bean. Never store it in a field or share it between methods.
http.build() constructs the SecurityFilterChain with the filters that correspond to what has been configured.
The effect of declaring this bean is total: it completely replaces Spring Boot's default configuration. The user user with its generated password disappears from the log, the login form disappears and only what you write remains. It is an all-or-nothing switch: rules are not "added" to the defaults, they replace them.
- The lambda DSL, section by section
HttpSecurity offers one method per configurable aspect, and each of them receives a lambda that customises it. This is the complete skeleton we will work with:
http
.securityMatcher("/api/**") // which requests this chain applies to
.authorizeHttpRequests(auth -> { ... }) // who can access what
.csrf(csrf -> { ... }) // anti-CSRF protection
.cors(Customizer.withDefaults()) // CORS policy
.sessionManagement(session -> { ... }) // session policy
.httpBasic(Customizer.withDefaults()) // HTTP Basic authentication
.formLogin(form -> { ... }) // login form
.logout(logout -> { ... }) // signing out
.headers(headers -> { ... }) // security headers
.exceptionHandling(ex -> { ... }) // what to answer on 401 and 403
.addFilterBefore(ownFilter, OtherFilter.class); // insert your own filtersThree rules of use. Customizer.withDefaults() switches the aspect on with its default values. To disable one, use the lambda with disable(): .csrf(csrf -> csrf.disable()), or its shorthand .csrf(AbstractHttpConfigurer::disable). And not calling a method does not mean disabling it: if httpBasic does not appear, that chain's default value applies. In Spring Security 6.1 and later, moreover, the chained methods without a lambda (.and(), .antMatchers()) are deprecated or removed: the lambda DSL is the only supported form, and its indentation shows at a glance where each block starts and ends.
authorizeHttpRequests and requestMatchers
authorizeHttpRequests and requestMatchersThis is the most important block: it defines who accesses what. Its structure is always a list of criterion → rule pairs.
.authorizeHttpRequests(auth -> auth
.requestMatchers(HttpMethod.GET, "/api/v1/stations/**").permitAll()
.requestMatchers("/api/v1/bikes/**").hasRole("OPERATOR")
.anyRequest().authenticated())Forms of requestMatchers
| Form | Example | What it selects |
|---|---|---|
| By pattern | requestMatchers("/api/v1/stations/**") |
Any method on those paths |
| By method and pattern | requestMatchers(HttpMethod.POST, "/api/v1/rentals") |
That verb only |
| By method only | requestMatchers(HttpMethod.OPTIONS) |
Any path with that verb |
| Several patterns | requestMatchers("/login", "/register") |
Any of them |
| Custom matcher | requestMatchers(new RegexRequestMatcher(...)) |
Cases the pattern does not cover |
The patterns are of the PathPattern kind (the same engine as @RequestMapping, 03-02):
| Wildcard | Meaning | /api/v1/stations/1/bikes |
|---|---|---|
? |
One character | /api/v1/station? does not match |
* |
Any text within one segment | /api/v1/* does not match |
** |
Any number of segments | /api/v1/** does match |
{var} |
Path variable | /api/v1/stations/{id}/bikes matches |
The distinction between * and ** causes a great many holes. Writing requestMatchers("/api/v1/users/*").hasRole("ADMIN") protects /api/v1/users/7, but not /api/v1/users/7/rentals, which ends up governed by the following rule. When in doubt, **.
One Spring Security 6 detail: the old antMatchers and mvcMatchers were unified into requestMatchers, which picks the appropriate implementation depending on whether Spring MVC is on the classpath.
Available access rules
| Rule | Meaning | Typical use in CicloUrbana |
|---|---|---|
permitAll() |
Open access, no authentication | Public station lookups |
authenticated() |
Any authenticated user | Rentals |
hasRole("ADMIN") |
Holds the ROLE_ADMIN authority |
Station management |
hasAnyRole("OPERATOR", "ADMIN") |
Any of those roles | Bike management |
hasAuthority("stations:write") |
Holds that exact authority, no prefix | Fine-grained permission model (05-03) |
hasAnyAuthority(...) |
Any of those authorities | |
denyAll() |
Nobody, ever | Explicitly closing dangerous paths |
anonymous() |
Only users who are not authenticated | A registration form that should not be used once signed in |
access(manager) |
An AuthorizationManager of your own |
Complex rules |
hasRole("ADMIN") and hasAuthority("ROLE_ADMIN") are equivalent: the first adds the ROLE_ prefix automatically. The confusion this generates is constant and we will pick it apart in 05-03; for now the mechanical rule is enough: with hasRole never write the prefix, with hasAuthority always write it if the role carries it.
- The golden rule of rule ordering
Rules are evaluated in the order they are declared, and the first one that matches wins. Not the most specific one: the first. This is the most frequent mistake in the whole module, and it produces silent holes.
// ❌ BROKEN CONFIGURATION: the user listing is left open to anyone
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/**").permitAll() // ← matches EVERYTHING
.requestMatchers("/api/v1/users/**").hasRole("ADMIN") // ← unreachable
.anyRequest().authenticated())A request to GET /api/v1/users matches the first rule, which lets it through. The second is never consulted. And the worst part is that the application starts with no warning at all: the personal data of every citizen in Ribalta is left public and nothing says so.
// ✅ CORRECT: from the most specific to the most general
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/v1/users/**").hasRole("ADMIN")
.requestMatchers("/api/**").permitAll()
.anyRequest().authenticated())Three practical consequences:
- Order from specific to general, always. Concrete paths first, broad wildcards afterwards.
anyRequest()goes last and cannot be repeated. If it appears before another rule, Spring Security throws an error at startup (Can't configure requestMatchers after anyRequest). It is the only protection the framework offers against this problem, and it only covers that case.- Always finish with
anyRequest().denyAll()oranyRequest().authenticated(). This is the deny-by-default policy: any new endpoint somebody adds tomorrow is born protected instead of born open. It is the difference between forgetting to protect something (dangerous, silent) and forgetting to open something up (annoying, obvious straight away).
- CicloUrbana's access map
Before writing code, the business decision. Ribalta's network has three profiles: CITIZEN (rents bikes), OPERATOR (maintains the fleet) and ADMIN (manages the network and the users).
| Endpoint | Method | Who | Rationale |
|---|---|---|---|
/api/v1/stations, /api/v1/stations/{id} |
GET |
Public | The station map is the council's open data |
/api/v1/stations/{id}/bikes |
GET |
Public | Knowing whether there are free bikes before signing up |
/api/v1/stations/** |
POST, PUT, PATCH, DELETE |
ADMIN |
Creating or closing a station is a municipal decision |
/api/v1/bikes/** |
GET |
Authenticated | Fleet detail: battery, incidents |
/api/v1/bikes/** |
Writes | OPERATOR |
Additions, retirements and status changes are done by maintenance |
/api/v1/incidents/** |
All | OPERATOR |
Internal breakdown management |
/api/v1/rentals/** |
All | Authenticated | Who can touch which one is decided in 05-05 |
/api/v1/users/** |
All | ADMIN |
Citizens' personal data |
/api/v1/auth/** |
POST |
Public | Registration and login (05-03 and 05-04) |
/swagger-ui/**, /v3/api-docs/** |
GET |
Development only | Closed in production (05-05, 07-02) |
/actuator/health |
GET |
Public | The load balancer polls it (07-01) |
/actuator/** |
GET |
ADMIN |
Metrics and internal detail |
And the translation into code, with the rules ordered from specific to general:
@Bean
SecurityFilterChain apiFilterChain(HttpSecurity http) throws Exception {
http
.securityMatcher("/api/**")
.csrf(csrf -> csrf.disable()) // see section 10
.cors(Customizer.withDefaults()) // see section 12
.sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
// --- Public: authentication and open lookups of the network ---
.requestMatchers(HttpMethod.POST, "/api/v1/auth/**").permitAll()
.requestMatchers(HttpMethod.GET, "/api/v1/stations", "/api/v1/stations/*",
"/api/v1/stations/*/bikes").permitAll()
// --- Network administration ---
.requestMatchers("/api/v1/users/**").hasRole("ADMIN")
.requestMatchers("/api/v1/stations/**").hasRole("ADMIN") // the remaining verbs
// --- Fleet maintenance ---
.requestMatchers("/api/v1/incidents/**").hasAnyRole("OPERATOR", "ADMIN")
.requestMatchers(HttpMethod.GET, "/api/v1/bikes/**").authenticated()
.requestMatchers("/api/v1/bikes/**").hasAnyRole("OPERATOR", "ADMIN")
// --- Using the service ---
.requestMatchers("/api/v1/rentals/**").authenticated()
// --- Deny by default ---
.anyRequest().denyAll())
.httpBasic(Customizer.withDefaults());
return http.build();
}Three design details. The public stations use /api/v1/stations/* and not /**, so that the single-segment wildcard does not accidentally open up future sub-resources. The bikes GET comes before the general rule, because the first match wins. And anyRequest().denyAll() closes any path under /api/** that nobody has classified.
hasAnyRole("OPERATOR", "ADMIN") repeated twice is a symptom that a role hierarchy is missing: an ADMIN should be able to do everything an OPERATOR can without enumerating it. We will solve it in 05-03 with RoleHierarchy.
- In-memory users with
InMemoryUserDetailsManager
InMemoryUserDetailsManagerTo try out the rules we need users with roles, and until 05-03 we will not have a credentials database. InMemoryUserDetailsManager is a UserDetailsService (05-01) that keeps the users in a map:
@Bean
UserDetailsService inMemoryUsers(PasswordEncoder encoder) {
UserDetails marta = User.withUsername("[email protected]")
.password(encoder.encode("example-password-1"))
.roles("CITIZEN") // → ROLE_CITIZEN authority
.build();
UserDetails luis = User.withUsername("[email protected]")
.password(encoder.encode("example-password-2")).roles("OPERATOR").build();
UserDetails ana = User.withUsername("[email protected]")
.password(encoder.encode("example-password-3")).roles("ADMIN").build();
return new InMemoryUserDetailsManager(marta, luis, ana);
}Three important points. .roles("CITIZEN") adds the ROLE_ prefix automatically: writing .roles("ROLE_CITIZEN") produces ROLE_ROLE_CITIZEN and throws an exception at startup; for authorities without a prefix there is .authorities("stations:write"). User.withDefaultPasswordEncoder() is deprecated and must not be used, not even in examples: it encourages leaving passwords in the source. And these passwords are fictional and only valid locally: in a real project they would be read from environment variables. This is scaffolding that will disappear in 05-03.
With this the rules from section 6 can already be tested:
curl -s -o /dev/null -w '%{http_code}\n' http://localhost:8080/api/v1/stations
# 200 → public
curl -s -o /dev/null -w '%{http_code}\n' -u [email protected]:example-password-1 \
http://localhost:8080/api/v1/users # 403 → authenticated, but not ADMIN
curl -s -o /dev/null -w '%{http_code}\n' -u [email protected]:example-password-3 \
http://localhost:8080/api/v1/users # 200 → ADMIN
curl -s -o /dev/null -w '%{http_code}\n' \
http://localhost:8080/api/v1/rentals/7 # 401 → no credentialsThose four codes are the proof that the configuration works: 200 public, 401 with no identity, 403 with insufficient identity and 200 with the right role.
- Password encoding
The rule is absolute: a password is never stored in a way that allows it to be recovered. Not in the clear, and not encrypted with a key that lives on the same system. What is stored is a hash, and only hashes are compared.
And not just any hash will do. MD5 and SHA-256 were designed to be fast, which is exactly the opposite of what is needed here: a current GPU computes on the order of billions of SHA-256 hashes per second, so a dictionary of common passwords is tried in full in minutes. The appropriate functions are deliberately slow and carry salt (a random value per password, which prevents precomputed tables and makes two users with the same password have different hashes).
| Algorithm | Suitable | Notes |
|---|---|---|
| Plain text | Never | A database leak hands over every account |
| MD5, SHA-1, "bare" SHA-256 | No | Fast by design; no salt by default |
| PBKDF2 | Yes | Standard, NIST-approved; the least GPU-resistant of the three |
| BCrypt | Yes — CicloUrbana's choice | Mature, with built-in salt and adjustable cost |
| SCrypt | Yes | Also demands memory |
| Argon2id | Yes, the most recommended today | Requires an extra library |
CicloUrbana chooses BCrypt: it is Spring Security's default, it needs no extra dependencies, it has twenty-five years of public scrutiny and its cost is adjustable.
@Bean
PasswordEncoder passwordEncoder() {
return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}That factory returns a DelegatingPasswordEncoder, and it is worth understanding why we do not return a BCryptPasswordEncoder directly. A hash produced by the delegating encoder looks like this:
{bcrypt}$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy
^^^^^^^^ ^^^ ^^ ^^^^^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^^^^^^^^^^
prefix ver cost salt (22) hash (31)The prefix in braces identifies the algorithm that password was encoded with. Thanks to it, the system can verify old passwords with {pbkdf2} while encoding new ones with {bcrypt}, and migrate algorithms without forcing anybody to change their password. That is why this is the recommended encoder even on new projects: today you do not need to migrate, but in five years' time you will.
The classic mistake. If the database holds a hash with no prefix —migrated from an earlier system— the delegating encoder does not know which algorithm to apply and throws IllegalArgumentException: There is no PasswordEncoder mapped for the id "null". There are two ways out: the correct one in the long run is to prefix the existing hashes with a Flyway migration (UPDATE users SET password_hash = '{bcrypt}' || password_hash); the alternative is to tell the delegating encoder what to do when the prefix is missing:
@Bean
PasswordEncoder passwordEncoder() {
var delegating = (DelegatingPasswordEncoder)
PasswordEncoderFactories.createDelegatingPasswordEncoder();
// Only during a migration: hashes without a prefix are treated as BCrypt
delegating.setDefaultPasswordEncoderForMatches(new BCryptPasswordEncoder());
return delegating;
}The cost factor. new BCryptPasswordEncoder(12) states the exponent of the number of iterations: each unit doubles the work. The default value is 10; the current recommendation sits between 10 and 12, and the practical criterion is to pick the highest cost whose verification stays below around 250 ms on your production hardware. It is an explicit trade-off: raising it makes a brute-force attack more expensive, but it also makes every legitimate login more expensive and can turn into a denial-of-service vector if somebody fires thousands of logins.
Two final tips: encoding is slow on purpose, so do it only on registration and on validation, never in a loop; and never log a password in the clear, not even while debugging (05-05).
- HTTP Basic and form login
.httpBasic(Customizer.withDefaults()) // Authorization: Basic base64(u:p)
.formLogin(Customizer.withDefaults()) // HTML form at /login| HTTP Basic | Form login | |
|---|---|---|
| How the credential travels | Authorization header, on every request |
POST /login once |
| State | Stateless (but Spring creates a session anyway unless you stop it) | With a session and a cookie |
| Natural client | curl, tools, testing |
Browser |
| Failure | 401 + WWW-Authenticate |
Redirect to /login |
| CSRF | Does not apply to the header | It does apply |
formLogin accepts full customisation —loginPage("/signin"), successHandler(...)— and it is the right option for an application with server-side views. CicloUrbana will disable both in 05-04, and it is worth understanding why. Form login returns an HTTP 302 redirect to an HTML page: a mobile application expecting JSON has no idea what to do with that. HTTP Basic forces the client to store the user's password in order to send it on every request, which is exactly what a token avoids: with JWT, the password is sent once and what is stored afterwards is an expirable, revocable token. Until then, we keep httpBasic because it makes testing with curl very convenient.
- CSRF: what it is and when it can be disabled
CSRF (Cross-Site Request Forgery) is an attack that abuses a property of the browser: cookies are sent automatically to their domain, wherever the request comes from.
sequenceDiagram
participant U as Marta's browser
participant M as malicious-site.example
participant C as CicloUrbana
U->>C: Login → JSESSIONID session cookie
U->>M: Visits any old page
M-->>U: HTML with a hidden self-submitting form
U->>C: POST /api/v1/rentals/7/finish<br/>with Marta's cookie!
Note over C: Without CSRF: the request looks legitimate<br/>With CSRF: token missing → 403
The defence is an unpredictable token that the server hands over and that the client must send back on every state-changing request. The malicious site cannot read it, because the browser's same-origin policy prevents it.
Spring Security switches it on by default for POST, PUT, PATCH and DELETE (the safe, idempotent read methods do not need it, 03-03). It is what produced the 403 in exercise 3 of 05-01.
Can it be disabled in CicloUrbana? Yes, but only under strict conditions, and they have to be spelled out because csrf.disable() is the most copied-without-understanding line in the whole of Spring Security:
| Condition | CicloUrbana from 05-04 onwards |
|---|---|
| Authentication does not use cookies or sessions | ✅ Bearer token in the Authorization header |
| The browser does not attach the credential automatically | ✅ The header is set by the client code, not by the browser |
| There are no HTML forms served by the application | ✅ It is a pure JSON API |
The session is STATELESS |
✅ Section 11 |
| CORS is restricted to known origins | ✅ CorsConfig from 03-02 |
The reasoning, in one sentence: CSRF exploits the fact that the browser sends the credential by itself; if the credential travels in a header that only your own application's code can add, the attack has nothing to work with.
Important warning. If CicloUrbana were later to store the JWT in a cookie —a legitimate option we will look at in 05-04— the condition breaks: a cookie does travel on its own and CSRF becomes necessary again. Disabling it then would be a real vulnerability. The decision to disable CSRF depends on where the credential lives, not on the API being REST.
If it were necessary to keep it enabled with a JavaScript client, the usual configuration is to publish the token in a readable cookie with .csrf(csrf -> csrf.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())).
- Session management:
STATELESS
STATELESS| Policy | Behaviour |
|---|---|
ALWAYS |
Always creates a session, even when it is not needed |
IF_REQUIRED |
The default: creates it when something needs it |
NEVER |
Does not create one, but uses an existing one |
STATELESS |
Neither creates nor uses one. There is no JSESSIONID |
With STATELESS, every request must authenticate itself. There is no session cookie, the SecurityContext is not saved between requests and everything the server knows about the user comes from the credential it has just received. It is exactly the stateless REST constraint we studied in 03-01, applied to security.
Three consequences:
- It scales horizontally with no effort: any instance serves any request, with no sticky sessions and no replication. It will matter in module 7.
- CSRF stops being necessary (under the conditions from section 10), and HTTP Basic keeps working, because it sends credentials on every request.
- There is no "sign out" on the server: there is nothing to invalidate. The problem is dealt with in 05-04.
- Integrating the CORS configuration from 03-02
In 03-02 we wrote CorsConfig as a WebMvcConfigurer. That component acts inside the DispatcherServlet, and now there are security filters in front. The result is a baffling failure: the preflight OPTIONS request the browser sends before a POST carries no credentials —the specification forbids it— so security rejects it with 401 before the CORS configuration gets to respond. The browser then reports a CORS error that is really an authentication error.
The solution is one line:
It tells Spring Security to register its CorsFilter inside the security chain, at a position before authorisation, using whatever CorsConfigurationSource is in the context. For it to be found, it is best to publish the CORS policy as a bean rather than only as a WebMvcConfigurer:
@Bean
CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration config = new CorsConfiguration();
config.setAllowedOrigins(List.of("https://panel.ribalta.example", "http://localhost:5173"));
config.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE"));
config.setAllowedHeaders(List.of("Authorization", "Content-Type", "X-Trace-Id"));
config.setExposedHeaders(List.of("Location", "ETag", "X-Trace-Id"));
config.setMaxAge(3600L);
var source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/api/**", config);
return source;
}Two additions compared with 03-02: Authorization among the allowed headers, without which the browser would not let the token be sent, and X-Trace-Id exposed, so the trace identifier from 03-06 can be shown when something goes wrong. And the usual rule, now more important than ever: never * in the origins alongside credentials. Hardening CORS per environment is completed in 05-05.
- Security headers on the response
By default Spring Security adds a set of headers that instruct the browser. They cost nothing and prevent entire families of attack.
| Header | Default value | What it is for |
|---|---|---|
X-Content-Type-Options |
nosniff |
Stops the browser guessing the content type and interpreting as a script something that is not one |
X-Frame-Options |
DENY |
Prevents the response being embedded in an iframe: a defence against clickjacking |
Cache-Control |
no-cache, no-store, max-age=0, must-revalidate |
Keeps private data out of the browser's or a proxy's cache |
Pragma, Expires |
no-cache, 0 |
The same for older clients |
X-XSS-Protection |
0 |
Disables an obsolete filter in old browsers that was worse than the problem |
Strict-Transport-Security |
Only if the request arrived over HTTPS | Forces the browser to use HTTPS for the stated period |
Adjusting them:
.headers(headers -> headers
.frameOptions(f -> f.sameOrigin()) // the H2 console from 04-02: development only
.httpStrictTransportSecurity(hsts -> hsts.includeSubDomains(true)
.maxAgeInSeconds(31_536_000)) // one year
.contentSecurityPolicy(csp -> csp.policyDirectives("default-src 'self'")))HSTS is a decision with long-lasting effects: once a browser receives the header, it refuses to talk over HTTP with that domain for the stated period. If you switch on includeSubDomains with a year and some subdomain has no valid certificate, it becomes unreachable and there is no way to undo it from the server. Start with a small maxAge. A pure JSON API does not need CSP —it serves no HTML— but it costs little and it protects the pages Spring itself serves, such as the Swagger UI from 03-07.
- Several chains with
@Order and securityMatcher
@Order and securityMatcherA single project usually has areas with incompatible rules. In CicloUrbana there will be three: the API (stateless, with tokens), Actuator (07-01, with its own policy) and the documentation resources in development.
@Bean
@Order(1)
SecurityFilterChain actuatorFilterChain(HttpSecurity http) throws Exception {
http.securityMatcher("/actuator/**")
.authorizeHttpRequests(auth -> auth
.requestMatchers("/actuator/health", "/actuator/health/**").permitAll()
.requestMatchers("/actuator/info").permitAll()
.anyRequest().hasRole("ADMIN"))
.csrf(csrf -> csrf.disable())
.httpBasic(Customizer.withDefaults());
return http.build();
}
@Bean
@Order(2)
SecurityFilterChain apiFilterChain(HttpSecurity http) throws Exception {
http.securityMatcher("/api/**")
// ... the configuration from section 6 ...
;
return http.build();
}
// @Order(3): a third chain with no securityMatcher, with anyRequest().denyAll(),
// closes everything that does not fit the previous two.Three rules that save hours of bewilderment:
securityMatcherdecides which requests the chain applies to;requestMatchers, insideauthorizeHttpRequests, decides which permission is needed. Confusing them is constant.- Only the first chain that matches applies (05-01). The others are not consulted, even if they have more specific rules.
- The lowest
@Orderwins, and the chain without asecurityMatchermatches everything, so it must carry the highest@Order. If by mistake it ended up first, no other chain would ever run.
At startup, with the FilterChainProxy log at DEBUG, you can check at a glance that the order is the one intended.
- Debugging security
Two tools solve ninety per cent of the problems.
The debug log:
# application-dev.yml — development ONLY
logging:
level:
org.springframework.security: DEBUG
org.springframework.security.web.FilterChainProxy: TRACEWith that, every request leaves a trail stating which filter served it and, above all, which one rejected it:
FilterChainProxy : Securing GET /api/v1/users
AuthorizationFilter : Authorizing GET /api/v1/users
AuthorizationFilter : Failed to authorize GET /api/v1/users
with authorization manager ... and decision ExpressionAuthorizationDecision
[granted=false, expression=hasRole('ROLE_ADMIN')]That last line contains the complete answer: the expression that failed and the result. There is never any need to guess.
The chain dump, with @EnableWebSecurity(debug = true) —development only—, prints at startup the ordered list of filters for each chain and, on every request, a summary of the security context.
Warning. Both options dump sensitive information: paths, roles, and in
debugmode also headers that may contain credentials. They must never be switched on in production. Their place isapplication-dev.yml, and the profiles that guarantee that separation are studied in 07-02.
Common Mistakes and Tips
Putting the general rule before the specific one. The number one mistake in the module. requestMatchers("/api/**").permitAll() on the first line opens the whole API and produces no warning at all.
Forgetting anyRequest() at the end. Without it, any unclassified path is left without a rule. Always finish with denyAll() or authenticated().
Writing .roles("ROLE_ADMIN"). It produces ROLE_ROLE_ADMIN. With roles, no prefix; with authorities, with the prefix.
Disabling CSRF "because it is an API". It is only valid if the credential does not travel on its own in the browser. With the token in a cookie, disabling it is a vulnerability.
Using * where ** was needed. /api/v1/users/* does not cover /api/v1/users/7/rentals.
Declaring two SecurityFilterChain beans without @Order, or leaving the chain without a securityMatcher first. In the first case the order is arbitrary; in the second, that chain matches everything and cancels out the ones that follow.
Tip: write the access table first, the code afterwards. Section 6 was decided in a table the council could review, and translating it into code was mechanical; the other way round, the configuration ends up a pile of rules that nobody can justify. And test every rule with curl -w '%{http_code}': four commands confirm that the policy is what you think it is. In 06-04 we will automate these checks.
Exercises
Exercise 1
This configuration has four security or functional problems. Find them, explain the impact of each and rewrite it correctly.
@Bean
SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf.disable())
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/**").permitAll()
.requestMatchers("/api/v1/users/**").hasRole("ROLE_ADMIN")
.requestMatchers("/api/v1/bikes/*").hasRole("OPERATOR"))
.formLogin(Customizer.withDefaults());
return http.build();
}Exercise 2
Write the CicloUrbana security chain that meets these requirements, and justify every decision:
/api/v1/stationsonGET: public. Any write on stations:ADMIN.- All of
/api/v1/rentals/**: authenticated. /api/v1/bikes/**onGET: authenticated; writes:OPERATORorADMIN./swagger-ui/**and/v3/api-docs/**: public only in thedevprofile.- Any other path under
/api/**: denied. - Stateless, no CSRF, with CORS and HTTP Basic.
Exercise 3
A colleague reports that POST /api/v1/stations answers 403 with the credentials of [email protected], who is an ADMIN. List five possible causes and describe how to tell them apart using the debug log.
Solutions
Solution 1
Problem 1 — inverted order: /api/** with permitAll() first. It matches everything, users and bikes included: the two following rules are unreachable and the entire API is left public. It is the most serious failure and the most silent.
Problem 2 — hasRole("ROLE_ADMIN") with the prefix. hasRole adds ROLE_, so the effective expression looks for the ROLE_ROLE_ADMIN authority, which nobody holds: the rule never grants access even if the order were correct. It must be hasRole("ADMIN") or hasAuthority("ROLE_ADMIN").
Problem 3 — anyRequest() is missing. Any path not listed —/actuator/**, the H2 console, static resources— is left without a rule. It must be closed with anyRequest().denyAll().
Problem 4 — csrf.disable() alongside formLogin. Form login uses a session cookie, and with the credential travelling on its own in the browser this is exactly the CSRF attack scenario. Disabling it here is a real vulnerability. On top of that, formLogin is no use to CicloUrbana's mobile app.
@Bean
SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.securityMatcher("/api/**")
.csrf(csrf -> csrf.disable()) // valid: no session and credential in a header
.cors(Customizer.withDefaults())
.sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/v1/users/**").hasRole("ADMIN")
.requestMatchers("/api/v1/bikes/**").hasAnyRole("OPERATOR", "ADMIN")
.requestMatchers(HttpMethod.GET, "/api/v1/stations/**").permitAll()
.anyRequest().denyAll())
.httpBasic(Customizer.withDefaults());
return http.build();
}Solution 2
@Configuration
@EnableWebSecurity
public class SecurityConfig {
private final Environment environment;
public SecurityConfig(Environment environment) {
this.environment = environment; // the Environment from 02-04
}
@Bean
SecurityFilterChain apiFilterChain(HttpSecurity http) throws Exception {
boolean development = environment.matchesProfiles("dev");
http
.securityMatcher("/api/**", "/swagger-ui/**", "/v3/api-docs/**")
.csrf(csrf -> csrf.disable())
.cors(Customizer.withDefaults())
.sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> {
// The documentation, development only
var docs = auth.requestMatchers("/swagger-ui/**", "/swagger-ui.html",
"/v3/api-docs/**");
if (development) { docs.permitAll(); } else { docs.denyAll(); }
auth
// Public reads of the network; any write, administration
.requestMatchers(HttpMethod.GET, "/api/v1/stations/**").permitAll()
.requestMatchers("/api/v1/stations/**").hasRole("ADMIN")
// Fleet
.requestMatchers(HttpMethod.GET, "/api/v1/bikes/**").authenticated()
.requestMatchers("/api/v1/bikes/**").hasAnyRole("OPERATOR", "ADMIN")
// Using the service
.requestMatchers("/api/v1/rentals/**").authenticated()
// Deny by default
.anyRequest().denyAll();
})
.httpBasic(Customizer.withDefaults());
return http.build();
}
}Rationale. The station write rules go before the public read one, because GET ... permitAll on /api/v1/stations/** does not capture POST, but the explicit order documents the intent and prevents accidents if somebody widens the pattern. The bikes GET precedes the general bikes rule, or citizens would not be able to look up the fleet. The documentation is closed with denyAll() outside dev rather than simply omitted, because anyRequest().denyAll() would already close it but an explicit rule reads as a decision, not as an oversight. And securityMatcher includes the Swagger paths because otherwise they would fall into another chain. A cleaner alternative to this if is to have two beans in different classes annotated with @Profile, which is what we will do in 07-02.
Solution 3
Cause 1 — CSRF enabled. If csrf.disable() is absent and the request carries no token, CsrfFilter answers 403 before authorisation is reached. Log: Invalid CSRF token found for .... It is the most likely cause with a POST from curl.
Cause 2 — rule ordering. An earlier, more general rule captures the request and demands another role. Log: the expression that appears in Failed to authorize will not be hasRole('ROLE_ADMIN'), but the one from the rule that was actually applied. That discrepancy is the diagnosis.
Cause 3 — duplicated prefix. If the rule is hasRole("ROLE_ADMIN") or the user was created with .roles("ROLE_ADMIN"), the authority sought and the one granted do not match. Log: granted=false, expression=hasRole('ROLE_ROLE_ADMIN'), or the user's authorities printed as [ROLE_ROLE_ADMIN].
Cause 4 — the chain applied is not the one you think. With several beans, securityMatcher may send the request to another chain; the FilterChainProxy log at TRACE states the index of the chosen chain. Cause 5 — CORS badly integrated: if only a browser sees the 403, the preflight OPTIONS is failing because .cors(Customizer.withDefaults()) is missing.
The general method, and the lesson of this solution: switch on DEBUG, fire the request and read the Failed to authorize line. It contains the expression evaluated and the result, and it rules out four of the five causes at a glance.
Conclusion
CicloUrbana now has a written, deliberate security policy. You know why WebSecurityConfigurerAdapter disappeared and why the current model —SecurityFilterChain beans in an ordinary @Configuration class— composes better than inheritance; you have created SecurityConfig in the com.ciclourbana.security package and you understand every piece: @EnableWebSecurity, the HttpSecurity injected as a prototype and the final http.build() which, once declared, completely replaces Spring Boot's defaults.
You have mastered the lambda DSL: authorizeHttpRequests with requestMatchers by pattern, by method or by both; the difference between * and ** that opens holes when confused; and the catalogue of rules, from permitAll to denyAll. Above all, you have internalised the golden rule: rules are evaluated in order and the first match wins, so they go from specific to general, anyRequest() always closes the list and the healthy policy is deny by default. You have seen it fail in an example that left the personal data of every citizen in Ribalta public without issuing a single warning.
The access map is decided and written down: stations public for reading, rentals for authenticated users, fleet and incidents for operators, stations and users for administrators, documentation in development only. You have started with InMemoryUserDetailsManager and three test users —Marta the citizen, Luis the operator, Ana the administrator— and checked with curl the four codes that prove the policy works: 200 public, 401 with no identity, 403 with insufficient identity and 200 with the right role. And you know why a recoverable password is never stored, why SHA-256 will not do, what BCrypt brings with its salt and its cost factor, and why the DelegatingPasswordEncoder and its {bcrypt} prefix are the right choice even today, when you have nothing to migrate.
You have also taken the four character-defining decisions on your own merits: CSRF disabled, but only after spelling out the five conditions that make it safe and with the warning that a token in a cookie breaks them; a STATELESS session, consistent with the REST constraint from 03-01; CORS integrated into the chain with cors(Customizer.withDefaults()) and a CorsConfigurationSource that already allows the Authorization header; and the security headers, with the warning about how irreversible a badly calibrated HSTS is. You know how to separate areas with @Order and securityMatcher, and how to debug with the org.springframework.security log without ever taking it to production.
There remains, however, the obvious problem: the users live in an in-memory map. Marta, Luis and Ana disappear on every restart, their passwords are written into the code, nobody can register and the User persisted in PostgreSQL's users table —with its email, its fare type and its signup date— has no relationship whatsoever with the user who authenticates. They are two separate worlds. In 05-03, User Authentication and Authorisation, we will join them: we will extend the User entity with credentials and roles through the V4 migration, we will implement a UserDetailsService of our own that loads by email, we will create an AuthenticatedUser that keeps the id, we will register the DaoAuthenticationProvider, we will open the citizen registration endpoint and we will make POST /api/v1/rentals stop trusting the userId the client sends. Ribalta's keys will no longer be written into the code.
Spring Boot Course
Module 1: Introduction to Spring Boot
- What Is Spring Boot?
- Setting Up Your Development Environment
- Building Your First Spring Boot Application
- Understanding the Project Structure
- Application Startup and Lifecycle
Module 2: Spring Boot Core Concepts
- Spring Boot Annotations
- Dependency Injection in Spring Boot
- Bean Scope and Lifecycle
- Spring Boot Configuration
- Spring Boot Properties
- Auto-Configuration and Starters from the Inside
Module 3: Building RESTful Web Services
- Introduction to RESTful Web Services
- Creating REST Controllers
- Handling HTTP Methods
- Validating Input Data
- DTOs and Mapping Between Layers
- Exception Handling in REST
- Documenting the API with OpenAPI
Module 4: Data Access with Spring Boot
- Introduction to Spring Data JPA
- Configuring Data Sources
- Creating JPA Entities
- Relationships Between Entities
- Using Spring Data Repositories
- Query Methods in Spring Data JPA
- Transactions and Persistence Management
- Schema Migrations with Flyway
Module 5: Security in Spring Boot
- Introduction to Spring Security
- Configuring Spring Security
- User Authentication and Authorization
- Implementing JWT Authentication
- Method-Level Security and API Hardening
Module 6: Testing in Spring Boot
- Introduction to Testing
- Unit Testing with JUnit
- Mocking with Mockito
- Integration Testing
- Testing with Testcontainers
Module 7: Advanced Spring Boot Features
- Spring Boot Actuator
- Spring Boot Profiles
- Scheduled Tasks and Asynchronous Execution
- Spring Boot with Docker
- Spring Boot and Microservices
- Service Communication and Fault Tolerance
Module 8: Deploying Spring Boot Applications
- Introduction to Deployment
- Deploying to Heroku
- Deploying to AWS
- Deploying to Kubernetes
- Continuous Integration and Delivery
Module 9: Performance and Monitoring
- Performance Tuning
- Caching with Spring Cache
- Monitoring with Spring Boot Actuator
- Using Prometheus and Grafana
- Logging and Log Management
- Distributed Tracing
