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
- Why security is not improvised
- Authentication, authorisation and auditing
- What happens when you add
spring-boot-starter-security - The servlet filter chain
- The filters that matter, in order
- Spring Security's object model
- The generic authentication flow
SecurityContextHolderand theThreadLocal- Available authentication mechanisms
- The OWASP Top 10 applied to a REST API
- Common Mistakes and Tips
- Exercises
- 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 evenSHA-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
equalsstops 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=xand/api/v1/%73tationscan all reach the same controller and slip past a naive check based onstartsWith.
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.
- 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.
- What happens when you add
spring-boot-starter-security
spring-boot-starter-securityThe 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:
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 stationscurl'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
useruser is a temporary stand-in; connecting Ribalta'suserstable 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
Authorizationheader 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).
- 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.
- 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:
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.
- 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.
- 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:
- 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 sameAuthenticationManager. ProviderManageris the usual implementation ofAuthenticationManagerand it holds a list ofAuthenticationProvider. 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.- Password comparison happens in the
AuthenticationProvider, with thePasswordEncoder, not in theUserDetailsService. - The
Authenticationreturned is a new object, authenticated and with the credentials erased: the plain password must not survive in memory any longer than strictly necessary. - Storing the result in the
SecurityContextHolderis the filter's responsibility, not the manager's. It is exactly what ourJwtAuthenticationFilterwill do in 05-04.
SecurityContextHolder and the ThreadLocal
SecurityContextHolder and the ThreadLocalThe 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.
- 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
Beareris understood by any client: the mobile app, the web panel,curland 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.
- 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:
- What does
GET /api/v1/stationsreturn without credentials? And with the credentials from the log? - What does
POST /api/v1/stationsreturn with the right credentials and a valid body? Explain the result. - Is
/swagger-ui.htmlstill reachable? - What happens if you define
spring.security.user.nameandspring.security.user.passwordinapplication.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
DelegatingFilterProxyreceives the request from Tomcat and delegates to thespringSecurityFilterChainbean.FilterChainProxylooks for the firstSecurityFilterChainthat matches. There is only the default one, which applies to/**.SecurityContextHolderFiltertries to load aSecurityContextfrom the session. There is noJSESSIONIDcookie, so the context is left empty.HeaderWriterFilterprepares the security headers of the response.CsrfFilterkicks in becausePOSTdoes change state. It looks for the anti-CSRF token in the_csrfparameter or in theX-CSRF-TOKENheader. It does not find it and throwsInvalidCsrfTokenException(orMissingCsrfTokenException).ExceptionTranslationFilter, which wraps the filters that follow, never gets involved in this case:CsrfFiltersits before it in the chain and resolves the response with its ownAccessDeniedHandler.- The response is
403 Forbidden.BasicAuthenticationFilter,AuthorizationFilter, theDispatcherServlet,StationControllerandStationServicenever 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
- 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
