The previous lesson closed the data module with an uncomfortable sentence: the CicloUrbana API is completely open. Anyone who knows the URL can create stations, retire bikes, read the email address and the name of Ribalta's citizens or finish somebody else's rental. While everything lived in memory it was a demo; now there is a PostgreSQL database holding real personal data for a municipal network and not a single check on who is at the other end of the wire.

This lesson does not write any configuration yet: it builds the mental map without which configuring Spring Security is blind copy and paste. We will look at the problems the framework solves and why security is the last place to improvise; we will separate authentication, authorisation and auditing; we will observe what happens to the project the instant we add a dependency; and we will thoroughly understand the centrepiece of the whole architecture, the filter chain, which is inserted in front of the DispatcherServlet we met in 03-01. By the end you will be able to name every object that appears in a Spring Security stack trace and explain what it does, which is exactly what separates the developer who debugs a 403 in five minutes from the one who spends the afternoon trying annotations at random.

A warning that runs through the whole module. What we are about to build is an educational starting point, correct but minimal. No security configuration should reach production without review by a security professional and, if the service is sensitive, without an external audit. And one rule that admits no exceptions: secrets —passwords, signing keys, database credentials— are never committed to the repository. Every value in this module is fictional and exists only for the example.

Contents

  1. Why security is not improvised
  2. Authentication, authorisation and auditing
  3. What happens when you add spring-boot-starter-security
  4. The servlet filter chain
  5. The filters that matter, in order
  6. Spring Security's object model
  7. The generic authentication flow
  8. SecurityContextHolder and the ThreadLocal
  9. Available authentication mechanisms
  10. The OWASP Top 10 applied to a REST API
  11. Common Mistakes and Tips
  12. Exercises

  1. Why security is not improvised

A developer's natural reaction to CicloUrbana's problem is to write a filter of their own: read a header, compare it against a table, let the request through or return 401. In one afternoon it works. The problem is everything that filter does not consider and an attacker does:

  • Password storage. Storing MD5(password) or even SHA-256(password) is nowadays equivalent to storing them in plain text: a home GPU computes billions of SHA-256 hashes per second. What you need is a deliberately slow, salted function such as BCrypt, Argon2 or PBKDF2, and the knowledge to tune its cost factor.
  • Constant-time comparisons. Comparing two strings with equals stops as soon as it finds a difference. By measuring response time with enough precision, an attacker can deduce characters. Spring Security compares credentials in a way that resists this analysis.
  • Session fixation. If the session identifier is not regenerated on login, an attacker who manages to plant a known identifier in the victim's browser inherits their authenticated session.
  • CSRF. A form on a malicious site can make an authenticated user's browser send a legitimate request —with their cookies— to CicloUrbana.
  • User enumeration. Answering "that email does not exist" and "wrong password" with different messages hands the attacker the list of valid email addresses.
  • Rule order, equivalent paths, alternative encodings. /api/v1/Stations, /api/v1//stations, /api/v1/stations;jsessionid=x and /api/v1/%73tations can all reach the same controller and slip past a naive check based on startsWith.

Spring Security is the answer to twenty years of these mistakes made in public. It is a mature library, with responsible vulnerability disclosure, patched releases and a model that separates responsibilities clearly. The professional rule is simple: do not write your own cryptography or authentication mechanisms; configure the ones that have already been audited.

  1. Authentication, authorisation and auditing

Three concepts that everyday language confuses and that in code are three distinct layers.

Concept Question it answers When it happens In Spring Security Example in CicloUrbana
Authentication (AuthN) Who are you? At the start of the request AuthenticationManager, AuthenticationProvider Marta presents her email [email protected] and her password; the system confirms that it is her
Authorisation (AuthZ) Are you allowed to do this? Before executing the operation AuthorizationManager, AuthorizationFilter, @PreAuthorize Marta is a CITIZEN: she can rent, but she cannot create the "Main Square" station
Auditing Who did what, and when? Afterwards, and permanently Events (AuthenticationSuccessEvent), AuditableEntity from 04-03 It is on record that operator [email protected] marked RB-0142 as broken on 12 March at 09:14

They fail in different ways and with different HTTP codes, and confusing them is the most common mistake in the module:

Situation HTTP code Literal meaning
No credentials, or invalid ones 401 Unauthorized "I do not know who you are. Authenticate."
Valid credentials, insufficient permissions 403 Forbidden "I know who you are, and you cannot."
Resource that does not exist or exists but belongs to somebody else 404 Not Found "There is nothing here" (sometimes preferable to a 403 that confirms existence)

The name 401 Unauthorized is a historical mistake in the specification: it means not authenticated. The header that accompanies it, WWW-Authenticate, makes clear that it is talking about authentication.

A fourth piece appears constantly and deserves a name: identification. Marta identifies herself by saying that she is [email protected] —that is public data— and authenticates by proving it with something only she knows. The email identifies; the password authenticates.

  1. What happens when you add spring-boot-starter-security

The quickest way to understand the framework is to observe the effect of a single line in pom.xml. We start from the project as it stood at the end of 04-08.

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security</artifactId>
</dependency>

No version, because the spring-boot-starter-parent from 01-04 manages it: with Spring Boot 3.x it corresponds to Spring Security 6.x. On startup, the log shows something that was not there before:

Using generated security password: 8f2a4c31-5b7e-4d19-9c02-6ea3f1b0d47c

This generated password is for development use only.
Your security configuration must be updated before running your application in production.

That identifier is random on every startup and only exists in development (we will check it in exercise 1). Now let us try the API with curl, exactly as in 03-02:

curl -i http://localhost:8080/api/v1/stations
HTTP/1.1 401
WWW-Authenticate: Basic realm="Realm"
Set-Cookie: JSESSIONID=6C0F...; Path=/; HttpOnly
Content-Type: application/json
{"timestamp":"2026-03-12T09:14:22.881+00:00","status":401,
 "error":"Unauthorized","path":"/api/v1/stations"}

Everything has changed without writing a single line of code. The autoconfiguration from 02-06 has detected the dependency and applied its default decision, which is the only reasonable defence: everything protected. It is worth listing exactly what has been switched on, because every point can be modified and we will do so in 05-02:

Default behaviour Detail
Every path requires authentication Including /api/v1/**, /swagger-ui.html and /actuator/**
One in-memory user Name user, password generated in the log
HTTP Basic enabled Hence the WWW-Authenticate: Basic header
Login form enabled At /login, generated by the framework itself
HTTP session created The JSESSIONID cookie in the response
CSRF enabled POST, PUT, PATCH and DELETE require a token
Security headers added X-Content-Type-Options, X-Frame-Options, Cache-Control
H2 console and static resources Protected as well

With the default credentials the request works again:

curl -i -u user:8f2a4c31-5b7e-4d19-9c02-6ea3f1b0d47c \
     http://localhost:8080/api/v1/stations
# HTTP/1.1 200  →  Ribalta's four stations

curl's -u option builds the header Authorization: Basic dXNlcjo4ZjJhNGMzMS0..., which is simply user:password encoded in Base64. Base64 is not encryption: anyone can decode it with base64 -d. That is why HTTP Basic is only acceptable over HTTPS, a point we will come back to in 05-05.

And if you open http://localhost:8080/api/v1/stations in a browser, you will not see the 401: you will see a login form. The difference lies in the Accept header the browser sends (text/html), which Spring Security uses to choose between responding with the form or with the HTTP Basic challenge. That form is an HTML page generated by the framework itself; there is no such file in the project.

None of these defaults is any use to CicloUrbana: a user called user with a password that changes on every restart is not a user model, and an HTML form is useless to a mobile application that consumes JSON. But they are deliberate scaffolding: the project is left secure by default and visibly broken, which is infinitely better than being left silently open.

What that dependency does NOT do for you

It is worth drawing the line from the start, because the convenience of autoconfiguration tempts you into thinking the work is done:

  • It does not know who your users are. The user user is a temporary stand-in; connecting Ribalta's users table is your job (05-03).
  • It does not know your roles or your business rules. "A citizen only finishes their own rentals" is a statement about CicloUrbana's domain that no framework can guess.
  • It does not encrypt the transport. Without HTTPS, the Authorization header travels across the network in the clear (05-05).
  • It does not validate input data. That is still Bean Validation (03-04).
  • It does not protect you from your own queries. If you concatenate strings in a native query, SQL injection is still possible (04-06).

  1. The servlet filter chain

To understand where all of that happens we must return to the DispatcherServlet from 03-01. A Java web application rests on the Servlet API, which defines two pieces: servlets, which serve requests, and filters, which wrap them into a chain that the request passes through before reaching the servlet and that the response comes back through afterwards.

Spring Security is, in essence, a filter. It does not touch the DispatcherServlet, it does not modify your controllers and it does not depend on Spring MVC: it sits in front of everything and decides whether the request carries on.

flowchart LR
    C["Client<br/>mobile app / curl"] --> T["Servlet container<br/>Tomcat"]
    T --> DFP["DelegatingFilterProxy<br/>springSecurityFilterChain"]
    DFP --> FCP["FilterChainProxy<br/>Spring bean"]
    FCP --> SFC["SecurityFilterChain<br/>ordered list of filters"]
    SFC --> DS["DispatcherServlet<br/>lesson 03-01"]
    DS --> CTRL["StationController<br/>StationService"]

Three names that appear without fail in any error trace and that are worth telling apart:

DelegatingFilterProxy. A standard Servlet API filter, registered in the container (Tomcat). Its only job is to delegate to a Spring bean called springSecurityFilterChain. It exists because Tomcat knows nothing about the Spring container: it instantiates filters by class, with no dependency injection and no bean lifecycle. DelegatingFilterProxy is the bridge between the two worlds, and thanks to it the security filters are ordinary beans that can inject UserRepository like any other service.

FilterChainProxy. The bean it delegates to. It is Spring Security's single entry point, and its job is to choose which chain applies to this particular request. There can be several.

SecurityFilterChain. A pair made up of a matching criterion (RequestMatcher) and an ordered list of filters. FilterChainProxy walks the registered chains in order, keeps the first one whose criterion matches and runs its filters. The others are never even consulted. This detail causes a great deal of confusion when there are several chains, and we will deal with it in 05-02.

flowchart TD
    R["Request: GET /api/v1/stations"] --> FCP["FilterChainProxy"]
    FCP --> M1{"Matches<br/>/actuator/**?"}
    M1 -- "No" --> M2{"Matches<br/>/api/**?"}
    M1 -- "Yes" --> CH1["Chain 1 · @Order(1)"]
    M2 -- "Yes" --> CH2["Chain 2 · @Order(2)"]
    M2 -- "No" --> CH3["Default chain"]
    CH2 --> F["Filters: context → CORS → authentication → authorisation"]
    F --> DS["DispatcherServlet"]

One consequence that comes as a surprise: the security filters run before the @RestControllerAdvice from 03-06. When the failure is one of authentication or authorisation, GlobalExceptionHandler never finds out, because the exception is raised outside the reach of the DispatcherServlet. That is why the 401 response above does not have the ProblemDetail format we took such care over in 03-06, but Spring Boot's generic error instead. We will fix it in 05-03 with an AuthenticationEntryPoint of our own. In the same way, TraceFilter —also an OncePerRequestFilter— only contributes its trace identifier to security events if it is registered at the right position in the chain.

  1. The filters that matter, in order

A typical chain has around fifteen filters. Their order is fixed and is defined in FilterOrderRegistration; it is not freely chosen, although you can insert your own filters at relative positions (addFilterBefore, addFilterAfter), as we will do with the JWT filter in 05-04. These are the ones you need to know:

# Filter What it does
1 DisableEncodeUrlFilter Stops the container from appending the jsessionid to URLs, preventing it from leaking into logs and links
2 SecurityContextHolderFilter Loads the SecurityContext from the session at the start and clears it at the end. In Spring Security 6 it no longer saves it automatically
3 HeaderWriterFilter Writes the security headers of the response (X-Frame-Options, etc.)
4 CorsFilter Applies the CORS policy. It must come before authorisation so that OPTIONS preflight requests do not require credentials
5 CsrfFilter Verifies the anti-CSRF token on state-changing requests
6 LogoutFilter Intercepts /logout, clears the context and invalidates the session
7 UsernamePasswordAuthenticationFilter Processes the login form submission (POST /login)
8 BasicAuthenticationFilter Reads the Authorization: Basic header
9 BearerTokenAuthenticationFilter Reads Authorization: Bearer when OAuth2 Resource Server is used. In 05-04 we will write our equivalent, JwtAuthenticationFilter
10 RequestCacheAwareFilter Restores the original request saved before redirecting to the login page
11 AnonymousAuthenticationFilter If nobody authenticated, it places an anonymous Authentication. There is never a null in the context
12 ExceptionTranslationFilter Catches AuthenticationException and AccessDeniedException from the next filter and translates them into 401 or 403
13 AuthorizationFilter The last one. It consults the rules in authorizeHttpRequests and decides whether the request goes through

That list does not have to be memorised: it can be printed. With the right log level, Spring Security writes the complete chain at startup in the application's real order, which is the definitive reference when something does not add up:

logging:
  level:
    org.springframework.security.web.FilterChainProxy: DEBUG
Will secure any request with [
  org.springframework.security.web.session.DisableEncodeUrlFilter,
  org.springframework.security.web.context.SecurityContextHolderFilter,
  org.springframework.security.web.header.HeaderWriterFilter,
  org.springframework.web.filter.CorsFilter,
  org.springframework.security.web.csrf.CsrfFilter,
  ...
  org.springframework.security.web.access.ExceptionTranslationFilter,
  org.springframework.security.web.access.intercept.AuthorizationFilter ]

Three observations that prevent very expensive mistakes:

AnonymousAuthenticationFilter explains why SecurityContextHolder.getContext().getAuthentication() almost never returns null. It returns an AnonymousAuthenticationToken with the ROLE_ANONYMOUS authority. Checking if (auth != null) to find out whether there is a user is a classic bug: you have to check auth.isAuthenticated() && !(auth instanceof AnonymousAuthenticationToken), or use the configuration's authenticated expression directly.

ExceptionTranslationFilter sits immediately before AuthorizationFilter, and that order is deliberate. It wraps the last filter in a try/catch: when authorisation rejects the request, the exception bubbles up and this filter decides. If the user is anonymous, it invokes the AuthenticationEntryPoint → 401. If they were already authenticated, it invokes the AccessDeniedHandler → 403. That is the whole of the logic that distinguishes a 401 from a 403 in Spring Security, and that is why customising both objects (05-03) is what integrates security with our ProblemDetail.

SecurityContextHolderFilter clears the context in its finally block. We will see the reason in section 8 and it is essential to understand it.

  1. Spring Security's object model

Nine types that come up again and again. Learning them now saves hours later.

Type What it is Analogy in CicloUrbana
Authentication The central object: it represents an authentication request or the result. It holds principal, credentials, authorities and isAuthenticated() Marta's card, before and after validating it
Principal The identity. Before authenticating it is usually the email String; afterwards, a UserDetails "Marta Aguiló, [email protected]"
GrantedAuthority A specific permission, almost always a string. With the ROLE_ prefix it represents a role ROLE_CITIZEN, ROLE_OPERATOR, ROLE_ADMIN
SecurityContext A container with an Authentication inside The record card for the request in flight
SecurityContextHolder The static store that gives access to the current thread's SecurityContext The counter where that card is looked up
AuthenticationManager The front door: it receives an unvalidated Authentication and returns a validated one or throws an exception The head of the enquiries office
AuthenticationProvider Each concrete validation mechanism. ProviderManager tries them in order The clerk who knows how to validate cards with a password
UserDetails What the system knows about a user: name, password hash, authorities, whether they are active or locked Marta's record in the database
UserDetailsService The single function that loads a UserDetails from its name The archive where that record is looked up

It is worth seeing the real signatures, because they are surprisingly small:

public interface Authentication extends Principal, Serializable {
    Collection<? extends GrantedAuthority> getAuthorities();
    Object getCredentials();   // the password; erased after authenticating
    Object getDetails();       // IP, session identifier...
    Object getPrincipal();     // the user: String or UserDetails
    boolean isAuthenticated();
    void setAuthenticated(boolean authenticated) throws IllegalArgumentException;
}

@FunctionalInterface
public interface AuthenticationManager {
    Authentication authenticate(Authentication authentication) throws AuthenticationException;
}

public interface UserDetailsService {
    UserDetails loadUserByUsername(String username) throws UsernameNotFoundException;
}

And the user record, which we will implement with a class of our own in 05-03:

public interface UserDetails extends Serializable {
    Collection<? extends GrantedAuthority> getAuthorities();
    String getPassword();                 // the HASH, never the plain password
    String getUsername();                 // in CicloUrbana it will be the email
    boolean isAccountNonExpired();
    boolean isAccountNonLocked();         // locking after failed attempts
    boolean isCredentialsNonExpired();    // password expiry
    boolean isEnabled();                  // the `active` column of the users table
}

The four boolean methods are four distinct reasons why an existing user may be unable to get in, and Spring Security tells them apart with different exceptions (AccountExpiredException, LockedException, CredentialsExpiredException, DisabledException). If you do not need one of them, return true, but do it consciously: returning true from isEnabled() lets deactivated users in.

Three details reveal the design. AuthenticationManager receives and returns the same type: in goes an object saying "I claim to be Marta with this password" and out comes one saying "I am Marta and I hold these authorities". UserDetailsService validates nothing: it only looks up and returns; the one who compares the password is the AuthenticationProvider. And getCredentials() returns Object because sometimes it is a password, sometimes a token and sometimes a certificate.

UserDetailsService is also the extension point we will use in 05-03: implementing it against UserRepository is all it takes for Spring Security to authenticate against Ribalta's users table.

  1. The generic authentication flow

With the names clear, here is the complete flow of a username-and-password login:

sequenceDiagram
    participant C as Client
    participant F as Authentication filter
    participant AM as AuthenticationManager<br/>(ProviderManager)
    participant AP as DaoAuthenticationProvider
    participant UDS as UserDetailsService
    participant PE as PasswordEncoder
    participant SCH as SecurityContextHolder

    C->>F: Credentials (Basic, form or JSON)
    F->>F: Builds UsernamePasswordAuthenticationToken<br/>(not authenticated)
    F->>AM: authenticate(token)
    AM->>AP: Do you support this token type?
    AP->>UDS: loadUserByUsername("[email protected]")
    UDS-->>AP: UserDetails (hash + authorities + active)
    AP->>PE: matches(plainPassword, storedHash)
    PE-->>AP: true
    AP-->>AM: AUTHENTICATED Authentication<br/>credentials erased
    AM-->>F: Authenticated Authentication
    F->>SCH: setContext(context holding the Authentication)
    F->>C: Chain continues → controller

Five points worth remembering:

  1. The filter validates nothing. It only extracts credentials from the transport (header, form or JSON) and builds an unauthenticated Authentication. That is why there is a different filter per mechanism and all of them end up in the same AuthenticationManager.
  2. ProviderManager is the usual implementation of AuthenticationManager and it holds a list of AuthenticationProvider. It asks each of them whether it supports the token type, and the first one that says yes decides. This allows several mechanisms —database, LDAP, JWT— to coexist in the same application.
  3. Password comparison happens in the AuthenticationProvider, with the PasswordEncoder, not in the UserDetailsService.
  4. The Authentication returned is a new object, authenticated and with the credentials erased: the plain password must not survive in memory any longer than strictly necessary.
  5. Storing the result in the SecurityContextHolder is the filter's responsibility, not the manager's. It is exactly what our JwtAuthenticationFilter will do in 05-04.

  1. SecurityContextHolder and the ThreadLocal

The SecurityContextHolder is the point from which any application code —a service, an aspect, a @PreAuthorize— finds out who is making the request:

Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
String email = authentication.getName();                    // "[email protected]"
boolean isOperator = authentication.getAuthorities().stream()
        .anyMatch(a -> a.getAuthority().equals("ROLE_OPERATOR"));

Its default storage strategy is MODE_THREADLOCAL: the context is held in a variable bound to the thread serving the request. It is an elegant decision —it lets you look up the user at any point without dragging it around as a parameter— with three consequences you need to know about.

First: the context must always be cleared. Servlet containers reuse threads through a pool. If Marta's context were still there once her request finished, the next request served by that same thread —perhaps from an anonymous user— would inherit Marta's identity. It is a serious impersonation vulnerability. That is why SecurityContextHolderFilter clears in a finally, and why a filter of your own that calls SecurityContextHolder.setContext(...) must clear as well, or delegate to the standard filter. It is the same problem as TraceFilter's MDC.remove() in 03-06, with far worse consequences.

Second: the context is not propagated to other threads. If a service launches a task with @Async or with an ExecutorService, that new thread has an empty context, and any @PreAuthorize there will see an anonymous user:

@Service
public class NotificationService {

    @Async   // careful! different thread: the SecurityContext does NOT travel
    public void notifyLowBattery(Long bikeId) {
        var auth = SecurityContextHolder.getContext().getAuthentication();
        // auth is the anonymous token, not the operator who triggered the operation
    }
}

The standard solutions are DelegatingSecurityContextExecutor, DelegatingSecurityContextRunnable or the MODE_INHERITABLETHREADLOCAL strategy. As CicloUrbana has no asynchronous tasks yet, we leave the matter noted: async and its interaction with the security context are covered in 07-03.

Third: in Spring Security 6 the context is no longer saved on its own. In version 5, SecurityContextPersistenceFilter saved the context into the session automatically at the end. In version 6, SecurityContextHolderFilter only reads; saving is an explicit action through SecurityContextRepository. The change was meant to avoid sessions created by accident, and it is a common source of broken migrations. For CicloUrbana it makes no difference: the API will be stateless and there will be no session to save.

  1. Available authentication mechanisms

Spring Security does not impose a mechanism; it offers many on top of the same object model.

Mechanism How the credential travels Stateful Good fit for Limitations
Form login POST /login + session cookie Yes Web applications with server-side views Useless for a JSON API; drags in CSRF and sessions
HTTP Basic Authorization: Basic base64(u:p) No Testing, internal tools Sends the password on every request; requires HTTPS
JWT / OAuth2 Resource Server Authorization: Bearer <token> No REST APIs and mobile apps Revocation is hard; expiry has to be managed
OAuth2 / OIDC Client Redirect to an external provider Depends "Sign in with Google", corporate Requires an identity provider
SAML 2.0 Signed XML assertions Yes Integration with classic corporate identity Complex, browser-oriented
LDAP / Active Directory Username and password against the directory Either way Companies with a central directory Needs the directory up and running
Client certificates (mTLS) X.509 certificate in the TLS handshake No Machine-to-machine communication Costly certificate management
API keys Custom header No Server-to-server integrations Does not identify people; manual rotation

CicloUrbana's choice is JWT, and it is worth justifying it against the project's concrete requirements:

  • The main client is a mobile application, which has no browser and does not handle cookies naturally.
  • The API is REST and stateless (03-01): a session on the server contradicts that constraint and complicates scaling horizontally to several instances, something that will matter in module 7.
  • Ribalta City Council has no corporate identity provider, so OIDC and SAML would add a piece of infrastructure without delivering value today.
  • A Bearer is understood by any client: the mobile app, the web panel, curl and the Swagger UI itself from 03-07.

One important clarification: choosing a mechanism is not choosing a single line of defence. Security is built in layers —TLS on the transport, authentication at the door, authorisation by URL, authorisation by method, data validation and constraints in the database— so that the failure of one does not compromise the whole system. The partial unique index uk_rentals_user_in_progress from 04-08 is a good example: even if an authorisation failure allowed an improper rental to start, the engine would still prevent a user from having two rentals at once.

All of that is implemented in 05-04. And there we will also look at the mature alternative for production, spring-boot-starter-oauth2-resource-server with an external provider such as Keycloak, which spares you from writing token-issuing code.

  1. The OWASP Top 10 applied to a REST API

The OWASP Top 10 is the reference list of the most critical security risks in web applications, published by the Open Worldwide Application Security Project. Going through it with CicloUrbana in mind clarifies what the framework solves and what remains the programmer's responsibility. This distinction is the most important lesson in the module.

OWASP risk What it means in CicloUrbana What Spring Security contributes What is down to you
A01 Broken access control A citizen finishes somebody else's rental by changing the id in the URL Rules by URL, @PreAuthorize, AuthorizationFilter Writing the right rules and checking ownership of the resource (05-05)
A02 Cryptographic failures Passwords in plain text; API over unencrypted HTTP PasswordEncoder with BCrypt, HSTS, requiresChannel Terminating TLS properly, not inventing encryption, rotating keys
A03 Injection ... WHERE email = ' + input + ' Nothing directly Parameterised queries: JPA and @Query from 04-06 already do this
A04 Insecure design Not limiting concurrent rentals per user Nothing Modelling the business rules well (the partial unique index from 04-08 is an example)
A05 Security misconfiguration Swagger UI or the H2 console left open in production Secure defaults and headers Reviewing profiles (07-02) and closing whatever should not be exposed
A06 Vulnerable components An old version of a library with a CVE Its own patched releases Updating and auditing dependencies (05-05)
A07 Identification and authentication failures Weak passwords, no account lockout Robust mechanisms and session protection Password policy, attempt limits, second factor
A08 Integrity failures Accepting a JWT without verifying the signature Signature verification, CSRF protection Not deserialising untrusted data
A09 Logging and monitoring failures Nobody notices 10,000 failed login attempts Authentication events published Logging them, alerting and never logging passwords or tokens
A10 SSRF An endpoint that downloads a URL supplied by the client Nothing Validating and restricting outbound destinations

The conclusion is twofold and very practical. Spring Security does not make you secure: it gives you the right tools. Of the ten risks, the framework partially covers four, helps with three and does not intervene in three. And the two risks where it contributes most —A01 and A07— are precisely where it is easiest to get things wrong, because a badly written rule looks just like a well-written one: the application starts, answers 200 and nobody notices anything until somebody looks at what they should not.

There is also an OWASP API Security Top 10 specific to APIs, whose first two risks are BOLA (broken object level authorisation: reaching somebody else's resource by changing an identifier) and broken authentication. Both are exactly the problems we will solve in 05-03 and 05-05.

Common Mistakes and Tips

Believing that removing the link from the frontend protects an endpoint. If DELETE /api/v1/stations/1 works without credentials, it does not matter that no button invokes it: curl exists. Security is always applied on the server.

Writing your own authentication filter "because it is simpler". It is, until the first incident. Everything listed in section 1 —timing, session fixation, user enumeration, path normalisation— has to be solved, and it already is.

Confusing 401 with 403. Returning 403 to somebody who has presented no credentials stops a client from knowing that it should authenticate; returning 401 to somebody who is authenticated will make them retry in a loop.

Trusting obscurity. Base64 is not encryption, a long identifier in a URL is not a secret and an endpoint "nobody knows about" turns up in the logs, in the browser history and in automated scans within days of going live.

Checking authentication != null to find out whether there is a user. AnonymousAuthenticationFilter guarantees it is almost never null. Check for real authentication or, better still, delegate to the framework's rules.

Leaving the generated password in the log of a shared environment. It is meant exclusively for local development, and the message itself warns you. Anyone with access to the pre-production logs gets in.

Tip: switch on debug logging from day one. With logging.level.org.springframework.security: DEBUG you will see which filters run and which rule rejects the request. It is the difference between debugging and guessing. Details in 05-02.

Tip: draw your filter chain. When something does not work, the useful question is almost never "which annotation is missing?" but "at which filter did the request stop?".

Tip: security has an expiry date. A configuration that is correct today may not be in two years' time. Schedule periodic reviews and dependency updates as part of maintenance.

Exercises

Exercise 1

Add spring-boot-starter-security to the CicloUrbana project and, without writing any configuration, answer with curl and with the log:

  1. What does GET /api/v1/stations return without credentials? And with the credentials from the log?
  2. What does POST /api/v1/stations return with the right credentials and a valid body? Explain the result.
  3. Is /swagger-ui.html still reachable?
  4. What happens if you define spring.security.user.name and spring.security.user.password in application.yml? Does the log message disappear? Why should this not be done this way in a real project?

Exercise 2

Classify each CicloUrbana situation as an authentication (401) failure, an authorisation (403) failure or neither of the two, and state which Spring Security component is involved:

# Situation
a A client calls GET /api/v1/rentals/7 with no Authorization header
b The citizen Marta calls DELETE /api/v1/stations/1
c The operator Luis calls PATCH /api/v1/bikes/RB-0142 with a status that does not exist
d A client presents an expired token
e Marta calls POST /api/v1/rentals/9/finish, rental 9 belonging to another citizen
f A client calls GET /api/v1/stations/99, which does not exist

Exercise 3

Describe, filter by filter, the complete journey of this request in the current CicloUrbana (with the dependency added and no configuration of our own), stating what each relevant filter does and where and why it stops:

curl -i -X POST http://localhost:8080/api/v1/stations \
     -H 'Content-Type: application/json' \
     -d '{"name":"Old Market","capacity":20}'

Solutions

Solution 1

1. Without credentials, 401 with WWW-Authenticate: Basic realm="Realm" and a generic Spring Boot error body —not a ProblemDetail—, because the rejection happens in ExceptionTranslationFilter, before the request reaches the DispatcherServlet and therefore outside the reach of GlobalExceptionHandler (03-06). With -u user:<password from the log>, 200 and the listing of Ribalta's four stations.

2. It returns 403 Forbidden, and it is baffling because the credentials are correct. The culprit is CSRF: it is enabled by default and CsrfFilter rejects every POST, PUT, PATCH and DELETE that does not carry a valid token. It is not a permissions problem, even though the code makes it look like one. The confirmation is in the log with DEBUG enabled:

o.s.security.web.csrf.CsrfFilter : Invalid CSRF token found for
http://localhost:8080/api/v1/stations

In 05-02 we will disable CSRF justifying why it is safe to do so in a stateless API with tokens.

3. No. /swagger-ui.html and /v3/api-docs are protected like everything else: in the browser the login form appears. In 05-02 they will be opened up in development only.

4. By defining both properties, the in-memory user takes that name and that password, and the log message disappears: it is only generated when the password is not configured. It is no use for a real project for three reasons: it is a single user with no roles and no identity, so there can be no citizens, operators and administrators; the password is in plain text in a file committed to Git, exactly what 02-04 forbade when talking about secrets; and there is no way to register users without restarting. The solution arrives in 05-03 with a UserDetailsService against the users table.

Solution 2

# Type Code Component
a Authentication 401 ExceptionTranslationFilter detects an anonymous user and invokes the AuthenticationEntryPoint
b Authorisation 403 AuthorizationFilter: Marta is authenticated but does not hold ROLE_ADMIN
c Neither 400 It is input validation (03-04). Security already let it through; GlobalExceptionHandler responds
d Authentication 401 The token filter rejects the credential: it is invalid, not insufficient
e Authorisation 403 No URL-based filter can resolve it: the path is legitimate and the permission depends on the data. It requires method security (05-05)
f Neither 404 ResourceNotFoundException from 03-06

Cases c and f teach that not every error is a security error. Case e is the most important one in the module: it is the A01/BOLA risk from section 10 and it shows why URL rules are not enough.

Solution 3

  1. DelegatingFilterProxy receives the request from Tomcat and delegates to the springSecurityFilterChain bean.
  2. FilterChainProxy looks for the first SecurityFilterChain that matches. There is only the default one, which applies to /**.
  3. SecurityContextHolderFilter tries to load a SecurityContext from the session. There is no JSESSIONID cookie, so the context is left empty.
  4. HeaderWriterFilter prepares the security headers of the response.
  5. CsrfFilter kicks in because POST does change state. It looks for the anti-CSRF token in the _csrf parameter or in the X-CSRF-TOKEN header. It does not find it and throws InvalidCsrfTokenException (or MissingCsrfTokenException).
  6. ExceptionTranslationFilter, which wraps the filters that follow, never gets involved in this case: CsrfFilter sits before it in the chain and resolves the response with its own AccessDeniedHandler.
  7. The response is 403 Forbidden. BasicAuthenticationFilter, AuthorizationFilter, the DispatcherServlet, StationController and StationService never run.

The moral is the goal of this lesson: the request died five filters away from your code, and no annotation on the controller would have changed it. Without the map of the chain, this 403 looks like a permissions problem and an entire afternoon goes missing.

Conclusion

You now have the map. You know why security is not improvised —password storage, constant-time comparisons, session fixation, CSRF, user enumeration and path normalisation are solved problems that nobody should rewrite— and you tell apart with precision authentication (who you are, 401), authorisation (what you may do, 403) and auditing (what you did). You have seen the immediate effect of adding spring-boot-starter-security: everything protected, a user user with a password generated in the log, HTTP Basic and the login form enabled, a session, CSRF and security headers; and you have checked with curl that GET /api/v1/stations answers 401 and that a POST with correct credentials answers 403 because of CSRF, a bafflement you can now explain.

Above all, you understand the architecture. DelegatingFilterProxy bridges Tomcat and the Spring container; FilterChainProxy picks the first SecurityFilterChain that matches; and that chain runs its filters in a fixed order before the request reaches the DispatcherServlet from 03-01. You know the role of SecurityContextHolderFilter, CorsFilter, CsrfFilter, the authentication filters, AnonymousAuthenticationFilter, ExceptionTranslationFilter —the one that decides between 401 and 403— and AuthorizationFilter. And you know the most useful practical consequence: when security rejects a request, your @RestControllerAdvice from 03-06 never even hears about it, because the rejection happens outside the reach of the DispatcherServlet.

You also have the vocabulary: Authentication, Principal, GrantedAuthority, SecurityContext, SecurityContextHolder, AuthenticationManager, AuthenticationProvider, UserDetails and UserDetailsService, with the flow that joins them and the ThreadLocal detail that forces the context to be cleared on every request and that stops the identity from travelling on its own into an @Async thread —a thread we will pick up again in 07-03—. You have compared the available authentication mechanisms and you know why CicloUrbana chooses JWT: a mobile client, a stateless API and the absence of a corporate identity provider. And you have placed the OWASP Top 10 over the project, with the conclusion that governs the whole module: Spring Security gives you the right tools, not automatic security, and the risks where it contributes most are also the ones where it is easiest to get things wrong.

The next lesson leaves theory behind and writes the first real class of the com.ciclourbana.security package. In 05-02, Configuring Spring Security, we will create SecurityConfig with its SecurityFilterChain bean and Spring Security 6's lambda DSL; we will define with authorizeHttpRequests and requestMatchers Ribalta's complete access map —public stations, authenticated rentals, bikes for operators, stations and users for administrators— and we will learn the golden rule of rule ordering with an example that fails; we will replace the user user with in-memory users using InMemoryUserDetailsManager; we will study password encoding in depth with DelegatingPasswordEncoder and BCrypt; and we will decide, on merit rather than out of habit, what to do about CSRF, the session and the security headers. Ribalta's network is finally going to get its first locks.

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