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

  1. Why URL rules are not enough
  2. @EnableMethodSecurity and its annotations
  3. SpEL in security expressions
  4. A permission evaluator of our own: RentalSecurity
  5. @PostAuthorize and @PostFilter: the cost of filtering late
  6. Where to put the annotations: proxies and self-invocation
  7. API hardening
  8. The OWASP risks and their mitigation in CicloUrbana
  9. Auditing security events
  10. Vulnerable dependencies
  11. Checklist before production
  12. Common Mistakes and Tips
  13. Exercises

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

  1. @EnableMethodSecurity and its annotations

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

  1. 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. #userId only works if the compiler preserves the argument names. The spring-boot-maven-plugin enables -parameters by 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 #p0 by 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.

  1. A permission evaluator of our own: RentalSecurity

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

  1. It is readable. "Is an operator or an administrator, or is the owner" reads straight through and can be shown to the council.
  2. It is testable. RentalSecurity is an ordinary bean: it is tested with JUnit and Mockito (06-02, 06-03) without starting the security context.
  3. It is reusable. The same expression serves in getById, in cancel and in any future method.
  4. It is efficient. existsByIdAndUserId is a derived query (04-06) that returns a boolean; it does not load the entity or its associations.
  5. 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 403 confirms that rental 9 exists. In a service where the mere existence is sensitive information, the correct answer is 404: "there is nothing here for you". For CicloUrbana the 403 is acceptable and clearer; in an application with delicate data, consider the 404.

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

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

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

max-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: PKCS12

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

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

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

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

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

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

  1. Any authenticated citizen can create an incident about a bike.
  2. Only an OPERATOR can close an incident.
  3. A citizen can look up the incidents they reported themselves; an operator, any of them.
  4. Only an ADMIN can delete an incident.
  5. 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

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