The previous lesson left CicloUrbana with a hole we have been carrying since the start of the module. Marta has a valid token with the ROLE_CITIZEN role, so her POST /api/v1/rentals/9/finish request crosses the filter chain without a single problem: the path is allowed to authenticated users and the role is correct. But rental number 9 belongs to another citizen of Ribalta. Marta has just closed a stranger's rental, and the amount has been charged to him.
No authorizeHttpRequests rule can prevent it, because the answer depends neither on the path nor on the role: it depends on the data. This lesson takes security down to the layer where the business logic lives, with @EnableMethodSecurity, @PreAuthorize and SpEL expressions, and then closes the module by hardening the whole API: CORS per environment, rate limiting, mandatory HTTPS, hiding the documentation in production, event auditing, reviewing vulnerable dependencies and a checklist before exposing Ribalta's network to the internet.
A warning that will be repeated at the end, because it is the most important one in the module. Everything built across these five lessons is an educational starting point. Before exposing a real service to the internet, the security configuration must be reviewed by a security professional and subjected to an independent audit. Secrets are never committed, and no checklist replaces a penetration test carried out by somebody who did not write the code.
Contents
- Why URL rules are not enough
@EnableMethodSecurityand its annotations- SpEL in security expressions
- A permission evaluator of our own:
RentalSecurity @PostAuthorizeand@PostFilter: the cost of filtering late- Where to put the annotations: proxies and self-invocation
- API hardening
- The OWASP risks and their mitigation in CicloUrbana
- Auditing security events
- Vulnerable dependencies
- Checklist before production
- Common Mistakes and Tips
- Exercises
- Why URL rules are not enough
The rules from 05-02 are necessary and they are the first line of defence, but they have three structural limits:
They cannot express rules that depend on the data. "Only your own rentals" is not a property of the path /api/v1/rentals/9/finish: it is a relationship between the authenticated user and row 9 of the table. The URL is identical whether the rental is your own or somebody else's.
The same operation is reached from several places. RentalService.finish is called today by RentalController; tomorrow it will be called by a scheduled task (07-03), a message consumer or a new endpoint, and every entry point is an opportunity to forget the check. Putting the rule on the service applies it once and for everybody.
The protection is far from the rule. Somebody reading RentalService sees no check at all and has to go looking for it in another file, in another package, written as a path pattern. Security declared next to the method it protects is the kind that stays up to date.
| URL security (05-02) | Method security (this lesson) | |
|---|---|---|
| Where it is declared | SecurityFilterChain |
An annotation on the method |
| When it is evaluated | Before the DispatcherServlet |
On invoking the method, via a proxy |
| What it can consult | Path, verb, roles | The arguments and the result |
| Rules by data | No | Yes |
| Cost | Very low | Low, but real |
| Role | Perimeter barrier | Fine-grained rule |
They are not alternatives, they are layers. The URL rule discards early what should never even arrive; the method rule applies what can only be decided with the data in hand:
flowchart LR
C["POST /rentals/9/finish<br/>Marta's Bearer"] --> F["AuthorizationFilter<br/>authenticated?"]
F -- "No" --> E1["401"]
F -- "Yes, and the path<br/>allows it" --> P["RentalService proxy<br/>@PreAuthorize"]
P -- "Rental 9 is<br/>not hers" --> E2["403"]
P -- "It is hers, or<br/>OPERATOR/ADMIN" --> M["finish(): business logic"]
@EnableMethodSecurity and its annotations
@EnableMethodSecurity and its annotationsIt is switched on with an annotation on a configuration class:
@Configuration
@EnableMethodSecurity // prePostEnabled = true by default
public class MethodSecurityConfig { }In Spring Security 6, @EnableMethodSecurity replaces the old @EnableGlobalMethodSecurity and enables @PreAuthorize and @PostAuthorize by default. Its attributes enable the others: @EnableMethodSecurity(securedEnabled = true, jsr250Enabled = true) adds @Secured and @RolesAllowed.
| Annotation | When it is evaluated | Can it use SpEL | Recommended use |
|---|---|---|---|
@PreAuthorize |
Before invoking | Yes | The default option in CicloUrbana |
@PostAuthorize |
After, on the result | Yes, with returnObject |
Only if the permission depends on the result |
@PreFilter / @PostFilter |
Filter a collection, before or after | Yes, with filterObject |
@PostFilter: avoid, it filters in memory |
@Secured |
Before | No: a list of roles only | Legacy code |
@RolesAllowed |
Before | No | The same, but a Jakarta standard |
The criterion: use @PreAuthorize unless you have a concrete reason not to. It is the only one that combines early evaluation with full expressions; @Secured and @RolesAllowed accept only a list of roles, which the URL rules already cover; and everything evaluated afterwards implies that the method has already run, with its cost and its side effects.
- SpEL in security expressions
The expressions are written in SpEL (Spring Expression Language) and have a vocabulary of their own:
| Expression | What it evaluates |
|---|---|
hasRole('OPERATOR') |
The ROLE_OPERATOR authority, with the 05-03 hierarchy applied |
hasAnyRole('OPERATOR','ADMIN') |
Any of them |
hasAuthority('stations:write') |
The exact authority, with no prefix |
authentication |
The complete Authentication object |
principal |
The principal: our AuthenticatedUser |
isAuthenticated(), isAnonymous() |
Authentication state |
permitAll, denyAll |
Constants |
#parameterName |
A method argument by its name |
returnObject |
The returned value (in @PostAuthorize only) |
filterObject |
Each element of a collection (in the *Filter ones) |
@securityBean.method(...) |
Calls a bean from the context |
Real examples from the project:
@Service
public class BikeService {
/** Retiring a bike is maintenance: the hierarchy includes ADMIN. */
@PreAuthorize("hasRole('OPERATOR')")
@Transactional
public void markAsBroken(String plate, String reason) { ... }
/** Looking up the detail: any identified user. */
@PreAuthorize("isAuthenticated()")
@Transactional(readOnly = true)
public BikeResponse getByPlate(String plate) { ... }
}
@Service
public class UserService {
/** Everyone sees their own record; an administrator, anyone's. */
@PreAuthorize("#userId == principal.userId or hasRole('ADMIN')")
@Transactional(readOnly = true)
public UserResponse get(Long userId) { ... }
}That last expression is the canonical example and deserves picking apart. #userId refers to the method parameter by its name; principal.userId invokes getUserId() on the AuthenticatedUser from 05-03 —here the decision to create a UserDetails of our own pays off for the second time: with the standard User there would be no id to compare—; and or hasRole('ADMIN') adds the administrative exception.
Careful with parameter names.
#userIdonly works if the compiler preserves the argument names. Thespring-boot-maven-pluginenables-parametersby default, but if the project loses it, the expression fails at runtime with a confusing message. The robust alternative is@P("userId")on the parameter, or#p0by position —less readable.
And an underlying warning: SpEL is evaluated at runtime and the compiler does not check it. A typo in hasRole('OPERATRO') or in principal.userIdd neither prevents compilation nor startup: it fails on the first real call, or worse, it always denies. That is why these expressions need automated tests, which is exactly what starts in module 6.
- A permission evaluator of our own:
RentalSecurity
RentalSecurityLet us go back to the problem we started with. The rule is: a citizen may only finish their own rentals; an operator or an administrator, any of them. It could be attempted in the annotation:
// ❌ Unreadable, untestable and with a query hidden inside a string
@PreAuthorize("hasAnyRole('OPERATOR','ADMIN') or "
+ "@rentalRepository.findById(#rentalId).orElse(null)?.user?.id "
+ "== principal.userId")That expression cannot be tested in isolation, whoever reads it does not understand it, it hides a database query inside a text string and nothing inside it is compiled. The idiomatic solution is a security bean:
package com.ciclourbana.security;
/** Ownership rules over rentals. Consultable from SpEL. */
@Component("rentalSecurity")
public class RentalSecurity {
private final RentalRepository rentalRepository; // constructor omitted
/** Is the authenticated user the holder of the given rental? */
@Transactional(readOnly = true)
public boolean isOwner(Long rentalId, AuthenticatedUser user) {
if (rentalId == null || user == null) return false;
return rentalRepository.existsByIdAndUserId(rentalId, user.getUserId());
}
}
@Service
public class RentalService {
@PreAuthorize("hasAnyRole('OPERATOR','ADMIN') or "
+ "@rentalSecurity.isOwner(#rentalId, principal)")
@Transactional
public RentalResponse finish(Long rentalId, FinishRentalRequest request) {
// The business logic from 04-07, untouched: not a single security if
}
}Five advantages of this form, and they are what make it the recommended one:
- It is readable. "Is an operator or an administrator, or is the owner" reads straight through and can be shown to the council.
- It is testable.
RentalSecurityis an ordinary bean: it is tested with JUnit and Mockito (06-02, 06-03) without starting the security context. - It is reusable. The same expression serves in
getById, incanceland in any future method. - It is efficient.
existsByIdAndUserIdis a derived query (04-06) that returns a boolean; it does not load the entity or its associations. - It concentrates change. If tomorrow a citizen can manage the rentals of an authorised family member, one Java method changes, not seven annotations.
Note the order of the expression: hasAnyRole comes first on purpose. SpEL evaluates or with short-circuiting, so for an operator the database query is not even run.
And with this the hole is closed. Marta's request against somebody else's rental no longer reaches the body of the method: AccessDeniedException bubbles up through the proxy, ExceptionTranslationFilter translates it and the AccessDeniedHandler from 05-03 returns a 403 in ProblemDetail format.
A design nuance. Returning
403confirms that rental 9 exists. In a service where the mere existence is sensitive information, the correct answer is404: "there is nothing here for you". For CicloUrbana the403is acceptable and clearer; in an application with delicate data, consider the404.
@PostAuthorize and @PostFilter: the cost of filtering late
@PostAuthorize and @PostFilter: the cost of filtering late@PostAuthorize evaluates the expression after running the method, with access to returnObject:
@PostAuthorize("returnObject.userId == principal.userId or hasRole('ADMIN')")
@Transactional(readOnly = true)
public RentalResponse getById(Long rentalId) { ... }It is convenient when the permission depends on a piece of data that is only known after querying, and it has two costs: the method has already run, with its queries and its time, and any side effect has already happened. Hence a hard rule: never use @PostAuthorize on a method that writes. If it makes a charge, sends an email or changes a bike's status, denying access after the fact undoes nothing; with @Transactional the exception does cause a database rollback (04-07), but not of the email sent or the call to the payment provider.
@PostFilter walks the returned collection and removes the elements that do not pass the expression:
// ❌ It works, and it is a bad idea
@PostFilter("filterObject.userId == principal.userId")
public List<RentalResponse> listAll() { ... }The problem is one of efficiency and scale. If Ribalta's network has 40,000 rentals, this method fetches all of them, builds 40,000 DTOs and discards 39,987 in memory. And with the pagination from 04-05 the result is simply wrong: page 0 is requested with 20 elements, the filter discards 18 and the citizen receives a page of 2 with a total that lies. The correct solution is to filter in the query, linking back to 04-06:
@PreAuthorize("isAuthenticated()")
@Transactional(readOnly = true)
public PageResponse<RentalResponse> listMine(AuthenticatedUser user,
Pageable pageable) {
return PageResponse.of(
rentalRepository.findByUserId(user.getUserId(), pageable)
.map(rentalMapper::toResponse));
}@PostFilter |
Filtering in the query | |
|---|---|---|
| Rows read | All of them | Only the user's |
| Compatible with pagination | No | Yes |
| Cost with 40,000 rentals | Unacceptable | Constant |
| Where the rule is visible | In the annotation | In the repository |
The practical rule: @PostFilter only for small, bounded collections —the four statuses of a station, the list of fares— and never over paginated results. For everything else, security is part of the query.
- Where to put the annotations: proxies and self-invocation
The annotations belong on the service, not on the controller, for three reasons: the service is the point every path goes through, future ones included; the controller deals with HTTP transport and not with business rules; and a rule on the controller gets duplicated as soon as a second entry point appears. And they work exactly like @Transactional (04-07): through a proxy that evaluates the expression before delegating to the real object. Hence three consequences we already know:
@Service
public class RentalService {
@Transactional
public void finishBatch(List<Long> ids) {
for (Long id : ids) {
finish(id, request); // ❌ internal call: does NOT go through the proxy
} // the @PreAuthorize check is skipped!
}
@PreAuthorize("@rentalSecurity.isOwner(#rentalId, principal)")
public RentalResponse finish(Long rentalId, FinishRentalRequest r) { ... }
}Self-invocation dodges security, just as it dodges the transaction. It is the same trap as in 04-07 and here it is worse, because the symptom is not a transaction that does not open: it is a permission check that does not run. The ways out are the same: extract the method to another bean —the clean option—, inject the proxy of itself with @Lazy, or rethink the design.
The other two consequences of the proxy model: private, static and final methods cannot be intercepted —an annotation on a private method is silently ignored, with no warning at all— and the bean must be managed by Spring; an object created with new has no proxy and no security.
- API hardening
Application security does not end at authentication and authorisation. These are the points that are missing, each with its concrete configuration.
7.1. Restrictive, per-environment CORS
CorsConfig (03-02) allows http://localhost:5173 so that the team's frontend works in development. That origin must not exist in production.
@Bean
@Profile("prod")
CorsConfigurationSource productionCors() {
var config = new CorsConfiguration();
config.setAllowedOrigins(List.of("https://panel.ribalta.example")); // no localhost
config.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE"));
config.setAllowedHeaders(List.of("Authorization", "Content-Type"));
config.setAllowCredentials(false); // we use Bearer, not cookies
config.setMaxAge(3600L);
var source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/api/**", config);
return source;
}Two reminders: allowedOrigins("*") together with allowCredentials(true) is forbidden by the specification and Spring rejects it at startup; and CORS is not a server-side security mechanism, but a policy applied by the browser —curl ignores it. Restricting CORS protects your users from malicious pages; it does not protect your API.
7.2. Rate limiting
With no request limit, a single client can saturate the API or try passwords without let-up. The options, from the outside in:
| Where | Tool | Advantage | Drawback |
|---|---|---|---|
| Gateway or CDN | API Gateway, Cloudflare, Nginx | Consumes no application resources | Less business context |
| Application | Bucket4j, Resilience4j | Knows the user and the endpoint | Consumes CPU and memory |
| Database | Your own counters | Total control | Slow and complex |
The recommendation is to limit at the gateway and leave in the application only the rules that need context, such as "five login attempts per minute and email". With Bucket4j:
@Component
public class RateLimitFilter extends OncePerRequestFilter {
private final Map<String, Bucket> buckets = new ConcurrentHashMap<>();
private Bucket newBucket() { // 100 requests per minute, continuous refill
return Bucket.builder().addLimit(
l -> l.capacity(100).refillGreedy(100, Duration.ofMinutes(1))).build();
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain chain) throws ServletException, IOException {
Bucket bucket = buckets.computeIfAbsent(keyFor(request), k -> newBucket());
if (bucket.tryConsume(1)) {
chain.doFilter(request, response);
} else {
response.setStatus(429); // Too Many Requests
response.setHeader("Retry-After", "60");
}
}
}Three warnings. The ConcurrentHashMap is no use with several instances —each replica would have its own counter— and it does not stop unbounded growth either: in production, Redis (09-02). The key must be chosen carefully: by IP it punishes every user behind the same NAT; by authenticated user it is fairer, but it does not protect the login, where there is no user yet. And the correct status is 429 with Retry-After, so that the client knows when to retry.
7.3. Maximum request size
An enormous body or a monstrous header is a cheap form of denial of service.
server:
max-http-request-header-size: 16KB # 8KB by default; JWTs take room
tomcat:
max-swallow-size: 2MB
connection-timeout: 5s
spring:
servlet:
multipart:
max-file-size: 5MB
max-request-size: 10MBmax-http-request-header-size deserves a note: the default value of 8 KB is more than enough for a CicloUrbana JWT, but with large tokens from an external provider —with many claims— it can fall short and produce a 431 Request Header Fields Too Large that is hard to diagnose. Raise it on merit, not "just in case".
7.4. Mandatory HTTPS and HSTS
Without TLS, the whole module is worthless: the token travels readable across the network and anybody on the same wifi captures it. TLS is usually terminated at the load balancer or the ingress, but if the application does it directly:
server:
ssl:
enabled: true
key-store: ${KEYSTORE_PATH} # never inside the repository
key-store-password: ${KEYSTORE_PASSWORD}
key-store-type: PKCS12And to reject any request that arrives unencrypted, plus HSTS (05-02):
.requiresChannel(channel -> channel.anyRequest().requiresSecure())
.headers(h -> h.httpStrictTransportSecurity(hsts -> hsts
.includeSubDomains(true).maxAgeInSeconds(31_536_000)))When TLS terminates at a proxy, the application sees HTTP requests and requiresSecure() would produce a redirect loop. The solution is for the proxy to send the X-Forwarded-* headers and to enable server.forward-headers-strategy: framework.
7.5. Hiding the server version and what should not be in production
Every fact the API reveals about itself makes it easier to look for a known exploit.
server:
error:
include-stacktrace: never # already since 03-06
include-message: never
whitelabel: { enabled: false }
tomcat:
remoteip: { protocol-header: x-forwarded-proto }And the list of what must not be reachable in production:
| Item | How it is closed |
|---|---|
Swagger UI and /v3/api-docs |
springdoc.api-docs.enabled: false in application-prod.yml |
| H2 console | It does not exist: PostgreSQL since 04-02. Check that the dependency is test |
| Actuator endpoints | Expose only health and info; the rest, with ADMIN (07-01) |
Security log at DEBUG |
Never outside development (05-02) |
| Flyway test data | locations separated per environment (04-08) |
All of this is governed with profiles, the mechanism studied in 07-02. The rule is that the prod profile must not inherit anything dangerous from the development one, and the practical check is to launch the application with --spring.profiles.active=prod and verify that /swagger-ui.html answers 404.
7.6. Not leaking details in errors
Reviewing 03-06 with security eyes, an error can give away extremely valuable information: table names, filesystem paths, library versions, the internal structure of the code.
| Leak | Example | Solution |
|---|---|---|
| Stack trace in the response | at org.hibernate... |
include-stacktrace: never |
| Message from a third-party exception | ERROR: relation "users"... |
Never return e.getMessage() of unknown origin |
| Distinguishing "does not exist" from "no permission" | 404 versus 403 |
Consider a uniform 404 |
| Different login messages | "email not registered" | A single message (05-03) |
| Different response times | Fast login if it does not exist | DaoAuthenticationProvider solves it (05-03) |
The GlobalExceptionHandler from 03-06 already does this well: controlled messages, an error code of its own and a trace identifier that lets support find the detail in the server log, not in the response.
- The OWASP risks and their mitigation in CicloUrbana
We close the circle opened in 05-01, now with concrete names from the project:
| Risk | What it would be in CicloUrbana | Mitigation applied |
|---|---|---|
| SQL injection | ... WHERE email = ' + input + ' |
JPA and @Query always parameterise (04-06): the value travels separately from the statement and is never interpreted as SQL. The risk only reappears if somebody concatenates in a native query |
| IDOR / BOLA | Marta finishes rental 9, which belongs to somebody else | @PreAuthorize with @rentalSecurity.isOwner (section 4) |
| Excessive data exposure | The response includes passwordHash or the national ID |
Response DTOs as inclusion lists (03-05) |
| Mass assignment | {"roles":["ADMIN"]} at registration |
Request DTOs: what is not in the DTO does not get through (05-03) |
| Broken authentication | Weak passwords, no attempt limit | BCrypt, failure events, rate limiting (05-02, 05-03) |
| Security misconfiguration | Swagger left open in production | Profiles (section 7.5, 07-02) |
| Insufficient logging | Nobody sees 10,000 failed logins | Authentication events (section 9) |
| Vulnerable components | A library with a CVE | Dependency-Check (section 10) |
Two observations that sum up the module. The first: half of these mitigations are not Spring Security's. The DTOs from 03-05, the parameterised queries from 04-06 and the constraints from 04-08 protect as much as, or more than, the filter chain. Good architecture and security are to a large extent the same thing.
The second: SQL injection deserves a concrete note, because it is the risk whose protection is lost most easily. These two cases look equivalent and are not:
// ✅ SAFE: the parameter travels separately from the statement
@Query("SELECT r FROM Rental r WHERE r.user.email = :email")
List<Rental> findByEmail(@Param("email") String email);
// ❌ VULNERABLE: concatenation in a native query
@Query(value = "SELECT * FROM rentals WHERE status = '" + "..." + "'",
nativeQuery = true)And there is one detail that comes as a surprise: a column name in a dynamic ORDER BY cannot be parameterised. If somebody builds the ordering by concatenating a request parameter, there is an injection right there. The defence is an allowlist of sortable columns, not an attempt at escaping.
- Auditing security events
Spring Security publishes application events on every authentication attempt, and listening to them costs very little:
@Component
public class SecurityAudit {
private static final Logger log = LoggerFactory.getLogger("AUDIT");
@EventListener
public void onLogin(AuthenticationSuccessEvent e) {
log.info("LOGIN_OK user={} trace={}",
e.getAuthentication().getName(), MDC.get(TraceFilter.MDC_KEY));
}
/** Parent class: covers bad credentials, deactivated, locked and expired accounts. */
@EventListener
public void onFailure(AbstractAuthenticationFailureEvent e) {
log.warn("LOGIN_FAILED user={} reason={} trace={}", e.getAuthentication().getName(),
e.getException().getClass().getSimpleName(), MDC.get(TraceFilter.MDC_KEY));
}
@EventListener
public void onDenied(AuthorizationDeniedEvent<?> e) {
log.warn("ACCESS_DENIED user={} trace={}",
e.getAuthentication().get().getName(), MDC.get(TraceFilter.MDC_KEY));
}
}| What to log always | What to never log |
|---|---|
| Successful and failed logins | Passwords, not even partial ones |
Denied accesses (403) |
JWT tokens, not even truncated |
| Role and permission changes | Password hashes |
| User registration and deactivation | Complete Authorization headers |
| Password changes | Unnecessary personal data (GDPR) |
| The trace identifier and the IP | Session cookies |
Warning. A token or a password in a log is a credential in a log, and logs get copied to aggregation systems, sent to third parties and kept for years. By the time somebody spots the leak, that credential has been circulating for months. Check the libraries too: some HTTP clients log complete headers at
DEBUG.
Handling logs in depth —structured format, aggregation, retention— is covered in 09-05, and automatic alerting on a spike of failures, in 09-04.
- Vulnerable dependencies
An application drags along dozens of transitive dependencies, and a critical vulnerability in any of them is a CicloUrbana vulnerability. OWASP Dependency-Check compares the dependency tree against the public vulnerability database:
<plugin>
<groupId>org.owasp</groupId>
<artifactId>dependency-check-maven</artifactId>
<version>10.0.4</version>
<configuration>
<failBuildOnCVSS>7</failBuildOnCVSS> <!-- fails on high severity -->
<nvdApiKey>${env.NVD_API_KEY}</nvdApiKey>
</configuration>
</plugin>mvn org.owasp:dependency-check-maven:check # report in target/
mvn versions:display-dependency-updates # which new versions exist
mvn dependency:tree # where each dependency comes fromFour practical tips. Run it in continuous integration, not by hand, with failBuildOnCVSS so that a high-severity vulnerability breaks the build (module 8). There will be false positives: manage them with a documented suppressions file, one by one and with a justification, never by lowering the threshold. Updating the Spring Boot version is the most effective route, because the starter-parent drags along dozens of coherent versions at once, with special attention to what it does not manage, such as the JJWT from 05-04. And subscribe to Spring's security advisories: a known vulnerability is exploited on a massive scale within hours of being published.
- Checklist before production
| # | Check | Lesson |
|---|---|---|
| 1 | No secrets in the repository; all through environment variables or a secrets manager | 02-04, 05-04 |
| 2 | Mandatory HTTPS, with HSTS and a valid certificate | 7.4 |
| 3 | Passwords with BCrypt (or Argon2) and a reviewed cost | 05-02 |
| 4 | JWT secret of at least 256 bits, randomly generated and rotatable | 05-04 |
| 5 | Short access token (≤15 min) with a rotating, revocable refresh | 05-04 |
| 6 | anyRequest().denyAll() at the end of every chain |
05-02 |
| 7 | Rules ordered from specific to general, reviewed one by one | 05-02 |
| 8 | Data-level rules with @PreAuthorize on every resource belonging to a user |
This lesson |
| 9 | STATELESS session and CSRF consistent with where the credential lives |
05-02 |
| 10 | CORS with no localhost and no * in production |
7.1 |
| 11 | Rate limiting active, at least on login and registration | 7.2 |
| 12 | Maximum request and header sizes configured | 7.3 |
| 13 | Swagger UI, H2 console and Actuator closed or protected | 7.5 |
| 14 | Errors with no stack trace, no internal messages and with a trace identifier | 03-06, 7.6 |
| 15 | Input and output DTOs on every endpoint | 03-05 |
| 16 | No query built by concatenation | 04-06 |
| 17 | Security event auditing active and with no credentials in the logs | Section 9 |
| 18 | Dependency-Check in continuous integration, with no high vulnerabilities | Section 10 |
| 19 | Automated tests of the security rules | Module 6 |
| 20 | Review by a security professional and an external audit | — |
Final warning, and the most important one in the module. This list is necessary and it is not sufficient. Everything built across these five lessons is an educational starting point: it covers the most common mistakes, but it does not replace a professional review. Before exposing a real service —all the more so if it handles citizens' personal data, as CicloUrbana does— the configuration must be reviewed by a security specialist and subjected to a penetration test by somebody who did not write the code. Security is not a state you reach: it is a process you maintain, with periodic reviews, updates and attention to advisories.
Common Mistakes and Tips
Putting @PreAuthorize on the controller. It leaves the service unprotected against any other entry point: scheduled tasks, message consumers or a new endpoint.
Annotating a private method or calling it from within the same class. The proxy does not intervene and the check does not run, with no warning at all. It is the trap from 04-07 with worse consequences.
Using @PostFilter on paginated results. It breaks pagination and brings the whole table into memory. Filter in the query.
@PostAuthorize on a method that writes. The effect has already happened; the transaction rollback does not undo an email sent or a charge made.
Writing complex SpEL inside the annotation. It is not compiled, it is not tested and nobody understands it: extract a security bean. And remember that SpEL is not checked by the compiler, so hasRole('OPERATRO') starts up without complaint and always denies; these expressions need tests (module 6).
Trusting CORS as a security mechanism. It is applied by the browser; curl ignores it. And never log tokens or passwords "only in DEBUG": logs get copied and kept for years.
Tip: write the security rule and its test at the same time. A rule without a test is a hypothesis, and module 6 starts precisely there.
Tip: review security the way you review code. A change in SecurityConfig, in a @PreAuthorize or in a DTO deserves the same attention as a change in the billing logic.
Exercises
Exercise 1
IncidentService manages breakdowns in Ribalta's fleet. Apply method security with these requirements, justifying each annotation:
- Any authenticated citizen can create an incident about a bike.
- Only an
OPERATORcan close an incident. - A citizen can look up the incidents they reported themselves; an operator, any of them.
- Only an
ADMINcan delete an incident. - The paginated listing must return to each citizen only their own, and to an operator all of them.
Exercise 2
This class has five security problems. Find them, explain the impact of each and rewrite it.
@RestController
@RequestMapping("/api/v1/users")
public class UserController {
@GetMapping("/{id}")
public User get(@PathVariable Long id) {
return userRepository.findById(id).orElseThrow();
}
@GetMapping("/search")
public List<User> search(@RequestParam String name) {
return entityManager.createNativeQuery(
"SELECT * FROM users WHERE name LIKE '%" + name + "%'",
User.class).getResultList();
}
@PutMapping("/{id}")
@PreAuthorize("hasRole('ADMIN')")
private User update(@PathVariable Long id, @RequestBody User user) {
return userRepository.save(user);
}
}Exercise 3
The council is going to deploy CicloUrbana to production next week. Write the report of the ten checks you would carry out, ordered by criticality, stating for each one how you would verify it objectively.
Solutions
Solution 1
@Service
public class IncidentService {
/** 1. Anybody identified reports a breakdown. The reporter does NOT come from
* the client: it is taken from the principal (rule from 05-03). */
@PreAuthorize("isAuthenticated()")
@Transactional
public IncidentResponse create(CreateIncidentRequest request,
AuthenticatedUser reporter) { ... }
/** 2. Closing is maintenance. The hierarchy from 05-03 includes ADMIN. */
@PreAuthorize("hasRole('OPERATOR')")
@Transactional
public IncidentResponse close(Long incidentId, String resolution) { ... }
/** 3. A rule by data: a security bean, not complex SpEL. */
@PreAuthorize("hasRole('OPERATOR') or "
+ "@incidentSecurity.isReporter(#incidentId, principal)")
@Transactional(readOnly = true)
public IncidentResponse get(Long incidentId) { ... }
/** 4. Deleting destroys maintenance history: administration only. */
@PreAuthorize("hasRole('ADMIN')")
@Transactional
public void delete(Long incidentId) { ... }
/** 5. The filtering goes in the QUERY, never in @PostFilter. */
@PreAuthorize("isAuthenticated()")
@Transactional(readOnly = true)
public PageResponse<IncidentResponse> list(AuthenticatedUser user, Pageable p) {
Page<Incident> page = user.isOperator()
? incidentRepository.findAll(p)
: incidentRepository.findByReporterId(user.getUserId(), p);
return PageResponse.of(page.map(mapper::toResponse));
}
}Rationale. Point 1 uses isAuthenticated() and not hasRole('CITIZEN'), because an operator must also be able to report; and the reporter is taken from the principal so that nobody can create incidents in somebody else's name. Point 3 is the only rule that depends on the data and that is why it needs a bean, IncidentSecurity, in the same shape as RentalSecurity. Point 5 is the most instructive: the decision about which query to run is a security decision, and making it in the query —instead of with @PostFilter— is the only thing compatible with pagination. The isOperator() method on AuthenticatedUser encapsulates the check for the ROLE_OPERATOR authority, which would otherwise be repeated all over the project.
Solution 2
Problem 1 — it returns the User entity. The response includes passwordHash, the roles and any field added in the future: it is excessive data exposure, the failure that motivated the DTOs from 03-05.
Problem 2 — IDOR in get. There is no permission check at all: anybody authenticated —or anybody, if the URL rule failed— reads any citizen's record by changing the id. It is the A01/BOLA risk.
Problem 3 — SQL injection in search. The name parameter is concatenated into a native query. With name = ' OR '1'='1 every user is returned; with a more elaborate payload, other statements can be executed.
Problem 4 — @PreAuthorize on a private method. The proxy cannot intercept it: the annotation is silently ignored and the method is left unprotected. On top of that, a private method cannot be a Spring MVC handler.
Problem 5 — mass assignment in update. It receives the complete entity, so the request can change roles, passwordHash, active or even the id. A compromised ADMIN —or a mistake— can rewrite any field.
The controller is reduced to transport: three public methods that receive @PathVariable, validated @RequestParam and @Valid @RequestBody UpdateUserRequest, return UserResponse or PageResponse<UserResponse> and delegate to the service, where all the rules now live:
@Service
public class UserService {
@PreAuthorize("#id == principal.userId or hasRole('ADMIN')")
@Transactional(readOnly = true)
public UserResponse get(Long id) { ... }
@PreAuthorize("hasRole('ADMIN')")
@Transactional(readOnly = true)
public PageResponse<UserResponse> findByName(String name, Pageable p) {
// Derived query: parameterised by Spring Data, immune to injection
return PageResponse.of(userRepository
.findByNameContainingIgnoreCase(name, p).map(mapper::toResponse));
}
/** public, not private: otherwise the proxy does not intercept it and the rule is not applied. */
@PreAuthorize("hasRole('ADMIN')")
@Transactional
public UserResponse update(Long id, UpdateUserRequest request) {
User user = userRepository.findById(id)
.orElseThrow(() -> new ResourceNotFoundException("User", id));
user.setName(request.name()); // only what the DTO allows
user.setFareType(request.fareType());
return mapper.toResponse(user);
}
}UpdateUserRequest contains only name and fareType: no roles, no password, no active, no id. That is the point: what is not in the DTO cannot be modified, and the role change lives in its own endpoint with its own rules (exercise 3 of 05-03).
Solution 3
| # | Check | Objective verification |
|---|---|---|
| 1 | No secrets in the repository | git log -p filtered with a secret-detection tool; review the complete history, not just the latest version |
| 2 | Mandatory HTTPS and a valid certificate | curl -I http://api... must redirect or refuse; check the certificate chain and expiry |
| 3 | Deny by default | Fire requests at non-existent, unclassified paths: all must give 401 or 403, never 200 |
| 4 | Each user only sees their own | With two real citizens, try crossing identifiers over rentals, incidents and records: everything 403 |
| 5 | Documentation and consoles closed | /swagger-ui.html, /v3/api-docs, /h2-console and /actuator/env must answer 404 or 401 |
| 6 | Errors with no internal information | Provoke a 500 and check that the response contains no stack trace, SQL or paths, and that it does carry a trace identifier |
| 7 | Rate limiting active | Fire 200 requests in a row at /api/v1/auth/login and check that the 429 appears |
| 8 | No high vulnerabilities | mvn dependency-check:check with failBuildOnCVSS=7 green, and the report reviewed |
| 9 | Auditing with no credentials | Search the logs of a complete session for strings such as eyJ, Bearer or password: zero results |
| 10 | Professional review | A signed report of the security review and of the penetration test |
Ordering criterion: first what compromises the whole system at once (a leaked secret, unencrypted traffic), then what compromises third parties' data (deny by default, cross access), then what makes an attack easier (open documentation, leaks in errors) and last what is preventive. And one observation about number 1 that is always forgotten: a secret that was in a commit and was then deleted is still in the Git history, so deleting it is not enough: it has to be rotated.
Conclusion
Module 5 closes with Ribalta's network protected from top to bottom. You have understood why the URL rules from 05-02, necessary as they are, are not enough: they cannot express rules that depend on the data, they do not cover the entry points that do not yet exist and they leave the protection far from the code it protects. Method security is the layer that completes the perimeter one, and you now know how to choose between its annotations on merit: @PreAuthorize as the default option, @PostAuthorize only when the permission depends on the result and never on methods that write, @PostFilter practically never —because it brings the whole table into memory and breaks the pagination from 04-05— and @Secured or @RolesAllowed only in legacy code. You have mastered the SpEL vocabulary —hasRole, principal, #parameter, @bean.method(...)— and its great warning: the compiler does not check it, so a badly written rule starts up without complaint and always denies.
You have closed the hole this module began with through RentalSecurity.isOwner, a bean that is readable, testable, reusable and efficient, and you know that the annotations live on the service and work with proxies, with the same self-invocation trap as @Transactional and one worse consequence: the check that does not run gives no warning. And you have hardened the API point by point: CORS with no localhost in production and with no illusion that CORS protects the server, rate limiting with 429 and Retry-After, maximum request sizes, mandatory HTTPS with HSTS and X-Forwarded-*, Swagger and Actuator closed outside development, errors that leak nothing, the OWASP table with the concrete mitigation of each risk —including the note about the dynamic ORDER BY that no parameter can protect—, event auditing with its list of what is never logged, dependency review with Dependency-Check and a list of twenty checks whose last point is the most important: this is an educational starting point and it needs review by a security professional and an independent audit before being exposed to the internet.
CicloUrbana finally has doors, locks and a record of who comes in through each of them. But there is an uncomfortable question running through everything we have built and which so far we have answered with curl and good intentions: how do we know it works? Nobody has checked automatically that a citizen cannot finish somebody else's rental, that anyRequest().denyAll() really closes what we think it does, that the token expires after fifteen minutes or that the V4 migration leaves the schema as we expect. Every future change —a tweaked @PreAuthorize, a reordered rule, an updated dependency— can silently break any of those guarantees, and we would find out in production. Module 6, Testing in Spring Boot, solves that: we will look at the testing pyramid and what is worth testing, we will write unit tests with JUnit 5 and doubles with Mockito, integration tests with @SpringBootTest, @WebMvcTest and @DataJpaTest —including security ones with @WithMockUser, which will turn every rule in this module into an automated assertion— and we will start up a real, ephemeral PostgreSQL with Testcontainers to check that Flyway, the entities and the queries behave just as they do in Ribalta. Ribalta's network is protected; now it has to be proven.
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
