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

  1. The Spring Security 6 component model
  2. SecurityConfig: the first class of .security
  3. The lambda DSL, section by section
  4. authorizeHttpRequests and requestMatchers
  5. The golden rule of rule ordering
  6. CicloUrbana's access map
  7. In-memory users with InMemoryUserDetailsManager
  8. Password encoding
  9. HTTP Basic and form login
  10. CSRF: what it is and when it can be disabled
  11. Session management: STATELESS
  12. Integrating the CORS configuration from 03-02
  13. Security headers on the response
  14. Several chains with @Order and securityMatcher
  15. Debugging security
  16. Common Mistakes and Tips
  17. Exercises

  1. 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).

  1. SecurityConfig: the first class of .security

package 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.

  1. 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 filters

Three 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.

  1. authorizeHttpRequests and requestMatchers

This 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.

  1. 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:

  1. Order from specific to general, always. Concrete paths first, broad wildcards afterwards.
  2. 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.
  3. Always finish with anyRequest().denyAll() or anyRequest().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).

  1. 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.

  1. In-memory users with InMemoryUserDetailsManager

To 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 credentials

Those 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.

  1. 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).

  1. 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.

  1. 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())).

  1. Session management: STATELESS

.sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.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:

  1. It scales horizontally with no effort: any instance serves any request, with no sticky sessions and no replication. It will matter in module 7.
  2. CSRF stops being necessary (under the conditions from section 10), and HTTP Basic keeps working, because it sends credentials on every request.
  3. There is no "sign out" on the server: there is nothing to invalidate. The problem is dealt with in 05-04.

  1. 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:

.cors(Customizer.withDefaults())

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.

  1. 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.

  1. Several chains with @Order and securityMatcher

A 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:

  1. securityMatcher decides which requests the chain applies to; requestMatchers, inside authorizeHttpRequests, decides which permission is needed. Confusing them is constant.
  2. Only the first chain that matches applies (05-01). The others are not consulted, even if they have more specific rules.
  3. The lowest @Order wins, and the chain without a securityMatcher matches 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.

  1. 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: TRACE

With 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 debug mode also headers that may contain credentials. They must never be switched on in production. Their place is application-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/stations on GET: public. Any write on stations: ADMIN.
  • All of /api/v1/rentals/**: authenticated.
  • /api/v1/bikes/** on GET: authenticated; writes: OPERATOR or ADMIN.
  • /swagger-ui/** and /v3/api-docs/**: public only in the dev profile.
  • 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

Module 2: Spring Boot Core Concepts

Module 3: Building RESTful Web Services

Module 4: Data Access with Spring Boot

Module 5: Security in Spring Boot

Module 6: Testing in Spring Boot

Module 7: Advanced Spring Boot Features

Module 8: Deploying Spring Boot Applications

Module 9: Performance and Monitoring

Module 10: Best Practices and Tips

© Copyright 2026. All rights reserved