CicloUrbana finished the previous lesson with a correct access policy and a ridiculous set of keys: Marta, Luis and Ana live in an InMemoryUserDetailsManager, their passwords are written into the source code, they disappear on every restart and nobody can sign up. Meanwhile, in PostgreSQL there is a users table with the email, name, fare type and signup date of Ribalta's citizens that has no relationship whatsoever with the user who authenticates. They are two separate worlds, and this lesson joins them.

We will extend the User entity from 04-03 with credentials and roles through the V4 migration, we will write a UserDetailsService of our own that loads by email from UserRepository, we will create an AuthenticatedUser that keeps the id —a small decision with enormous consequences in 05-05—, we will open the citizen registration endpoint, we will clear up once and for all the confusion between roles and authorities, and we will make POST /api/v1/rentals stop trusting the userId the client sends it. By the end, CicloUrbana's identity will be real and persistent.

Warning. All the personal data and passwords in this lesson are fictional. Real identity management demands more than fits here: email verification, a password policy checked against lists of leaked passwords, secure recovery, a second factor and GDPR compliance. Nothing we build should be exposed to the internet without review by a security professional. And secrets are never committed to the repository.

Contents

  1. From in-memory users to real users
  2. Extending the User entity with credentials and roles
  3. The V4__add_user_credentials.sql migration
  4. AuthenticatedUser: a UserDetails of our own
  5. AppUserDetailsService: CicloUrbana's UserDetailsService
  6. DaoAuthenticationProvider: exactly what it does
  7. The complete flow with AuthenticationManager
  8. Citizen registration
  9. Roles and authorities: the ROLE_ prefix and fine-grained permissions
  10. Role hierarchy with RoleHierarchy
  11. Reaching the authenticated user from the controller
  12. 401 and 403 responses in ProblemDetail format
  13. Failed attempts and account lockout
  14. Common Mistakes and Tips
  15. Exercises

  1. From in-memory users to real users

The change consists of replacing a single piece. Everything else —the filter chain, the authorizeHttpRequests rules, the PasswordEncoder— stays the same:

Piece In 05-02 From now on
UserDetailsService InMemoryUserDetailsManager AppUserDetailsService against UserRepository
UserDetails Spring Security's User Our own AuthenticatedUser, with the id
Source of the data An in-memory map PostgreSQL's users table
Registering users Recompile and restart POST /api/v1/auth/register
Roles Fixed in the code The user_roles table

That swapping one implementation is enough is exactly the goal of the Spring Security design we saw in 05-01: UserDetailsService is a single-method interface, and everything above it is oblivious to where the data comes from.

  1. Extending the User entity with credentials and roles

The entity from 04-03 has identity and personal data, but nothing to authenticate with. It is missing three things: something to prove who it is (the password hash), what it may do (the roles) and whether it is still active —we already had that last one. The email does not need adding either: the email column has existed since V1 and is already UNIQUE, so it is the natural login identifier.

package com.ciclourbana.users;

public enum UserRole {
    CITIZEN,     // rents bikes
    OPERATOR,    // maintains the fleet
    ADMIN        // manages the network and the users
}
@Entity
@Table(name = "users")
public class User extends AuditableEntity {

    @Id @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "users_seq")
    @SequenceGenerator(name = "users_seq", sequenceName = "users_id_seq",
                       allocationSize = 50)
    private Long id;

    @Column(nullable = false, unique = true, length = 120)
    private String email;                   // login identifier

    /** BCrypt hash with prefix, never the password. Excluded from toString and equals. */
    @Column(name = "password_hash", nullable = false, length = 100)
    private String passwordHash;

    @Column(nullable = false)
    private boolean active = true;

    @ElementCollection(fetch = FetchType.EAGER)
    @CollectionTable(name = "user_roles", joinColumns = @JoinColumn(name = "user_id"))
    @Column(name = "role", nullable = false, length = 20)
    @Enumerated(EnumType.STRING)
    private Set<UserRole> roles = new HashSet<>();

    // name, fareType, signupDate, version and auditing stay the same (04-03)
}

Four decisions that deserve a justification:

@ElementCollection and not @ManyToMany with a Role entity. CicloUrbana's roles are a fixed, closed set of three values with no attributes of their own and no lifecycle: there is nothing to administer about "the OPERATOR role". If one day they were configurable from a panel, with a description and associated permissions, then an entity would indeed be needed.

@Enumerated(EnumType.STRING), never ORDINAL. It is the rule from 04-03, and here it is critical: with ORDINAL the values 0, 1, 2 are stored, so inserting a new role in the middle of the enum would silently reassign every user's permissions.

fetch = EAGER, against the general rule from 04-04. It is the project's deliberate exception: the roles are needed always and immediately when authenticating, and with LAZY the loading would happen outside the UserDetailsService transaction, causing a LazyInitializationException —remember that open-in-view has been false since 04-02. There are at most three values per user. If the collection grew, the refined alternative would be @EntityGraph.

passwordHash with length = 100. A BCrypt hash occupies 60 characters, and with the {bcrypt} prefix that is 68; the 100 leaves room to migrate to {argon2}. The name of the field matters too: it is called passwordHash, not password, so that nobody is in any doubt about what it holds. And it must stay out of toString(), equals() and hashCode(), and never appear in a response DTO: the DTOs from 03-05 guarantee that by construction, because they are inclusion lists.

  1. The V4__add_user_credentials.sql migration

With ddl-auto: validate since 04-08, the schema only changes through migrations. As V1 already created users with email and active, this migration only adds what is missing.

-- V4__add_user_credentials.sql — credentials and roles for Ribalta's network.

-- 1) Password hash. Nullable for now: step 3 fills it and step 4 pins it down.
ALTER TABLE users ADD COLUMN password_hash VARCHAR(100);

-- 2) Roles. Composite primary key: a user cannot hold the same role twice.
CREATE TABLE user_roles (
    user_id BIGINT      NOT NULL,
    role    VARCHAR(20) NOT NULL,

    CONSTRAINT pk_user_roles      PRIMARY KEY (user_id, role),
    CONSTRAINT fk_user_roles_user FOREIGN KEY (user_id)
        REFERENCES users (id) ON DELETE CASCADE,
    CONSTRAINT ck_user_roles_role CHECK (role IN ('CITIZEN', 'OPERATOR', 'ADMIN'))
);

CREATE INDEX idx_user_roles_user ON user_roles (user_id);

-- 3) Pre-existing users are left with no usable credential and deactivated.
UPDATE users SET password_hash = '{noop}NO-CREDENTIAL', active = FALSE
 WHERE password_hash IS NULL;

INSERT INTO user_roles (user_id, role)
SELECT id, 'CITIZEN' FROM users;

-- 4) Now, at last, mandatory.
ALTER TABLE users ALTER COLUMN password_hash SET NOT NULL;

Three points this migration teaches:

The three-step "nullable → fill → NOT NULL" pattern is the only way to add a mandatory column to a table with rows in it: adding it directly as NOT NULL fails, and giving it a DEFAULT would be worse, because it would leave every user with the same password.

ON DELETE CASCADE on the roles foreign key is one of the few correct cascades at the database level: a role makes no sense without its user, the domain criterion from 04-04. And the CHECK constraint replicates the enum in the engine: if somebody inserts 'SUPERADMIN' with raw SQL, the database rejects it instead of letting Hibernate blow up when reading it back.

Pre-existing users are deactivated on purpose. It is a security decision, not an oversight: no password exists that they could have chosen, so leaving them active with some arbitrary hash would be worse. The literal {noop}NO-CREDENTIAL corresponds to no real password, but on top of that active = FALSE makes Spring Security reject them even before comparing (section 6).

Marta, Luis and Ana also need credentials in order to keep testing. That goes in a separate migration, V4.1__ribalta_demo_users.sql, placed in db/migration/dev with the per-environment locations from 04-08: an UPDATE that gives them a fictional BCrypt hash and active = TRUE, plus two INSERTs into user_roles that grant OPERATOR to Luis and ADMIN to Ana. It must never reach production, which is why it lives outside db/migration.

  1. AuthenticatedUser: a UserDetails of our own

The quickest route would be for the UserDetailsService to return Spring Security's User. It works, and it is what half the tutorials do. CicloUrbana does not do it, for one very specific reason.

User keeps only the username, the hash and the authorities. When later, in a service, we need to know which database user is making the request in order to check that a rental belongs to them, all we will have is the email, and we will have to go and fetch the id from the database on every request. With a UserDetails of our own, the id travels inside the SecurityContext:

package com.ciclourbana.security;

public class AuthenticatedUser implements UserDetails {

    private final Long userId;
    private final String email;
    private final String passwordHash;
    private final boolean active;
    private final Collection<? extends GrantedAuthority> authorities;

    public AuthenticatedUser(User user) {          // immutable copy of the entity
        this.userId = user.getId();
        this.email = user.getEmail();
        this.passwordHash = user.getPasswordHash();
        this.active = user.isActive();
        this.authorities = user.getRoles().stream()
                .map(role -> new SimpleGrantedAuthority("ROLE_" + role.name()))
                .toList();
    }

    /** The whole point of this class: the id without querying the database. */
    public Long getUserId() { return userId; }

    @Override public String getUsername() { return email; }
    @Override public String getPassword() { return passwordHash; }
    @Override public boolean isEnabled()  { return active; }
    @Override public Collection<? extends GrantedAuthority> getAuthorities() { return authorities; }

    @Override public boolean isAccountNonExpired()     { return true; }
    @Override public boolean isAccountNonLocked()      { return true; }  // section 13
    @Override public boolean isCredentialsNonExpired() { return true; }

    @Override public String toString() {      // never the hash
        return "AuthenticatedUser{id=%d, email='%s'}".formatted(userId, email);
    }
}

Four observations:

The ROLE_ prefix is added here, when turning the enum into a GrantedAuthority. The database stores ADMIN; the security context holds ROLE_ADMIN. Concentrating that conversion in a single place stops the prefix from turning up half-applied across the rest of the code, which is the cause of half of all mysterious 403s.

It is an immutable copy, not the entity. Making User implement UserDetails is a tempting shortcut and a bad idea: it would force the JPA entity to drag around Spring Security methods, couple it to the framework and —the important part— put a managed entity inside the SecurityContext, with its EntityManager closed and its lazy collections ready to throw LazyInitializationException at any point. The domain must not know that Spring Security exists.

toString() does not expose the hash, because an innocent log.debug("user={}", user) would be a leak. And isAccountNonLocked() returns true today, but it is exactly the point through which section 13's lockout after failed attempts will enter.

  1. AppUserDetailsService: CicloUrbana's UserDetailsService

package com.ciclourbana.security;

@Service
public class AppUserDetailsService implements UserDetailsService {

    private static final Logger log = LoggerFactory.getLogger(AppUserDetailsService.class);

    private final UserRepository userRepository;

    public AppUserDetailsService(UserRepository repo) { this.userRepository = repo; }

    @Override
    @Transactional(readOnly = true)
    public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {
        return userRepository.findByEmailIgnoreCase(email)
                .map(AuthenticatedUser::new)
                .orElseThrow(() -> {
                    // DEBUG level: at INFO, an enumeration attack would flood the log
                    log.debug("Access attempt with an unregistered email");
                    return new UsernameNotFoundException("Invalid credentials");
                });
    }
}

In UserRepository (04-05) two derived queries are enough: Optional<User> findByEmailIgnoreCase(String email) and boolean existsByEmailIgnoreCase(String email).

Four details:

@Transactional(readOnly = true) is essential: without an active transaction, the roles collection could not be loaded even being EAGER in some scenarios, and readOnly lets Hibernate skip dirty checking (04-07).

IgnoreCase. Email addresses are case-insensitive in practice. Without this, [email protected] would be a different user from [email protected] and the citizen would not understand why she cannot get in. We will complement it by normalising to lower case at registration.

The exception message does not say "this email does not exist". That is deliberate, and the next section explains why.

Whether the user is active is not checked here. That is the AuthenticationProvider's job, which knows how to tell "does not exist", "is deactivated" and "the password does not match" apart. The UserDetailsService only looks up and returns.

  1. DaoAuthenticationProvider: exactly what it does

DaoAuthenticationProvider is the AuthenticationProvider that combines a UserDetailsService with a PasswordEncoder. With Spring Boot it configures itself: it is enough for both beans to exist in the context. Declaring it explicitly, however, makes what happens visible:

@Bean
AuthenticationProvider authenticationProvider(AppUserDetailsService details,
                                              PasswordEncoder encoder) {
    var provider = new DaoAuthenticationProvider();
    provider.setUserDetailsService(details);
    provider.setPasswordEncoder(encoder);
    provider.setHideUserNotFoundExceptions(true);   // hides the real reason: on purpose
    return provider;
}

/** Needed in 05-04 to authenticate from the login endpoint. */
@Bean
AuthenticationManager authenticationManager(AuthenticationConfiguration configuration)
        throws Exception {
    return configuration.getAuthenticationManager();
}

Its job, step by step:

  1. It calls loadUserByUsername with the name presented.
  2. If it does not find it, it runs a password comparison against a dummy hash anyway. This is not an oversight: it is a defence against timing attacks. If it responded immediately on not finding the user, the attacker would measure that the answer arrives in 2 ms for non-existent emails and in 90 ms for real ones —BCrypt's 90 ms— and could enumerate every registered email address in Ribalta's network without guessing a single password.
  3. It checks the account state with the four UserDetails booleans, throwing DisabledException, LockedException, AccountExpiredException or CredentialsExpiredException as appropriate.
  4. It compares with passwordEncoder.matches(presented, stored), which applies the algorithm indicated by the {bcrypt} prefix and compares in constant time.
  5. If the hash uses obsolete parameters and the encoder implements upgradeEncoding, it can re-encode the password on the fly. And finally it returns an authenticated UsernamePasswordAuthenticationToken, with the AuthenticatedUser as the principal, the authorities and the credentials erased.

Why the reasons are hidden from the client. With hideUserNotFoundExceptions set to true, both "this email does not exist" and "the password does not match" come out as a single BadCredentialsException, and the API always answers the same thing: 401 with the text "Invalid credentials". Telling them apart would be a gift to the attacker: it would let him confirm which emails are registered with the municipal service, which is also personal data. It is inconvenient for the legitimate user and it is the correct practice.

The same criterion applies to registration: it should not publicly answer "that email is already registered" if that allows somebody to discover who uses the service. In CicloUrbana we will return 409 because registration is open and the user needs to know that they already have an account, but it is a decision that in a sensitive service would be taken the other way round (a generic response plus a notice by email).

  1. The complete flow with AuthenticationManager

sequenceDiagram
    participant C as Client
    participant F as Authentication filter
    participant PM as ProviderManager
    participant DAP as DaoAuthenticationProvider
    participant SDU as AppUserDetailsService
    participant R as UserRepository
    participant PE as PasswordEncoder

    C->>F: [email protected] + password
    F->>PM: authenticate(unauthenticated token)
    PM->>DAP: supports(UsernamePasswordAuthenticationToken)? yes
    DAP->>SDU: loadUserByUsername("[email protected]")
    SDU->>R: findByEmailIgnoreCase(...)
    R-->>SDU: User + roles
    SDU-->>DAP: AuthenticatedUser
    DAP->>DAP: active? not locked?
    DAP->>PE: matches(presented, "{bcrypt}$2a$10$...")
    PE-->>DAP: true
    DAP-->>PM: Authenticated Authentication
    PM-->>F: Authentication with AuthenticatedUser
    F->>F: SecurityContextHolder.setContext(...)

If something fails —non-existent user, wrong password, deactivated account— the ProviderManager picks up the AuthenticationException, publishes a failure event (section 13) and propagates it. The ExceptionTranslationFilter from 05-01 translates it into a 401 through the AuthenticationEntryPoint.

  1. Citizen registration

With the entity and the service ready, public signup can now be opened. It is the only write endpoint in the whole API without authentication, so it deserves care.

package com.ciclourbana.security.dto;

public record RegisterRequest(
        @NotBlank @Email @Size(max = 120) String email,
        @NotBlank @Size(max = 120) String name,
        @NotBlank @Size(min = 12, max = 72) String password) {}

public record RegisteredUserResponse(Long id, String email, String name) {}

The controller is the usual one since 03-03: a @RestController over /api/v1/auth with a @PostMapping("/register") that receives @Valid @RequestBody RegisterRequest, delegates to the service and answers 201 Created with the Location header. All the substance is in the service:

@Service
public class RegistrationService {

    private final UserRepository userRepository;
    private final PasswordEncoder encoder;
    private final Clock clock;
    // constructor omitted

    @Transactional
    public RegisteredUserResponse registerCitizen(RegisterRequest request) {
        String email = request.email().trim().toLowerCase(Locale.ROOT);

        if (userRepository.existsByEmailIgnoreCase(email)) {
            throw new ResourceConflictException(
                    "An account with that email already exists", "EMAIL_ALREADY_REGISTERED");
        }

        User user = new User();
        user.setEmail(email);
        user.setName(request.name().trim());
        user.setPasswordHash(encoder.encode(request.password()));
        user.setActive(true);
        user.setSignupDate(LocalDate.now(clock));
        user.setRoles(Set.of(UserRole.CITIZEN));      // never from the request

        User saved = userRepository.save(user);
        return new RegisteredUserResponse(saved.getId(), saved.getEmail(),
                                          saved.getName());
    }
}

Five security decisions in twenty lines:

The role does not come from the request: it is set on the server. RegisterRequest has no roles field. If it had one, anybody could register as an ADMIN by sending {"roles":["ADMIN"]}. This is the mass assignment attack, and the request DTOs from 03-05 prevent it by construction: what is not in the DTO cannot get through. We will return to it in 05-05.

The response includes neither the hash nor the roles. RegisteredUserResponse is an inclusion list with three fields.

The email is normalised to lower case before checking and before saving, consistently with the repository's IgnoreCase. The duplicate check has a race condition —two simultaneous registrations with the same email can both pass the existsBy— and that is why the database's UNIQUE constraint is the real guarantee. If it fires, Hibernate throws DataIntegrityViolationException, which is worth translating into a 409 too in GlobalExceptionHandler. It is the same reasoning as the partial unique index from 04-08: validation in Java is there to give a good message; the engine is what guarantees.

The minimum of 12 characters is a minimum, not a policy. A serious password policy also checks the password against lists of leaked passwords —the Pwned Passwords service allows this without sending the password— and avoids obsolete composition rules (demanding a symbol and a number produces Password1! over and over). The maximum of 72 is not arbitrary: BCrypt silently truncates at 72 bytes, so accepting more would give a false sense of security.

Something important is missing and it must be said: this registration does not verify the email. Anybody can sign up with somebody else's email address. A real service sends a confirmation link with a single-use token and keeps the account inactive until it is confirmed. It is out of scope for the course, but not out of scope for a production system.

  1. Roles and authorities: the ROLE_ prefix and fine-grained permissions

It is the most widespread confusion in Spring Security, and it clears up with a single idea: only authorities exist. A role is an authority whose name starts with ROLE_.

Expression Authority it looks for Equivalent
hasRole("ADMIN") ROLE_ADMIN hasAuthority("ROLE_ADMIN")
hasAuthority("ADMIN") ADMIN Not the same as the above
hasRole("ROLE_ADMIN") ROLE_ROLE_ADMIN Error: throws an exception at startup
hasAuthority("stations:write") stations:write Fine-grained permission

The mechanical rule: hasRole adds the prefix, hasAuthority does not. The same happens when creating users (.roles(...) versus .authorities(...)) and in the next section's RoleHierarchy, where the prefix is written because you are working with authorities.

Roles versus permissions. Roles group people; permissions describe actions. A system with roles only ends up full of rules like hasAnyRole("OPERATOR","ADMIN","SUPERVISOR","MAINTENANCE"), which has to be reviewed in full every time a new profile appears:

Roles Fine-grained permissions
Example ROLE_OPERATOR bikes:write, stations:read
Readability of the rule High Medium
Adding a new profile Touch every rule Just assign permissions
Auditing "who can do X?" Hard Direct
Initial complexity Low High

CicloUrbana uses roles, and it is the right decision today: three stable, well-delimited profiles do not justify the complexity of a permission model. But it is worth knowing that the change is not traumatic if AuthenticatedUser is the one translating the domain model into authorities: it would be enough for each UserRole to contribute its set of permissions and to return both. The sign that the moment has arrived is concrete: when the fourth role appears, or when two profiles need partially overlapping permissions.

  1. Role hierarchy with RoleHierarchy

In 05-02 we wrote hasAnyRole("OPERATOR", "ADMIN") twice, and noted it was a symptom. The cause is that a CicloUrbana ADMIN should be able to do everything an OPERATOR does, and that is stated nowhere: it has to be repeated rule by rule, and forgetting it once is enough for an administrator to run into an inexplicable 403.

@Bean
static RoleHierarchy roleHierarchy() {
    return RoleHierarchyImpl.withDefaultRolePrefix()
            .role("ADMIN").implies("OPERATOR")
            .role("OPERATOR").implies("CITIZEN")
            .build();
}

/** Needed so that the hierarchy also applies to method security (05-05). */
@Bean
static MethodSecurityExpressionHandler methodSecurityExpressionHandler(RoleHierarchy hierarchy) {
    var handler = new DefaultMethodSecurityExpressionHandler();
    handler.setRoleHierarchy(hierarchy);
    return handler;
}

ADMIN > OPERATOR > CITIZEN. From here on, a hasRole("OPERATOR") rule is also satisfied by an ADMIN, and the rules from 05-02 simplify:

.requestMatchers("/api/v1/incidents/**").hasRole("OPERATOR")   // ADMIN included
.requestMatchers("/api/v1/bikes/**").hasRole("OPERATOR")       // ADMIN included

Two warnings. The beans are static because they must be created very early in the lifecycle, before the security infrastructure that consumes them; otherwise Spring warns about premature bean initialisation. And the hierarchy is defined with the default prefix, that is, it works on ROLE_ADMIN and ROLE_OPERATOR: withDefaultRolePrefix() adds it for you.

When not to use a hierarchy. It only makes sense when the profiles really are cumulative. If in the future CicloUrbana had an AUDITOR role that can read everything but write nothing, it would not fit into the chain: it is neither "more" nor "less" than the others, it is different. Forcing a hierarchy onto roles that do not have one produces permissions granted by accident, which is the opposite of what we want.

  1. Reaching the authenticated user from the controller

Three ways, and they are not equivalent:

Way How it is obtained Advantages Drawbacks
SecurityContextHolder Statically, from anywhere Works in services and utilities Couples the code to Spring Security; hard to test; fails in @Async threads (07-03)
Authentication parameter Spring injects it into the method Explicit, easy to mock in tests You have to cast the principal
@AuthenticationPrincipal Injects the typed principal directly Readable, typed and with no cast Requires a UserDetails of your own to be useful
// 1. Authentication parameter: correct, but it forces a cast
public List<RentalResponse> mine(Authentication authentication) {
    var user = (AuthenticatedUser) authentication.getPrincipal();
    return rentalService.findByUser(user.getUserId());
}

// 2. @AuthenticationPrincipal: the preferred form in CicloUrbana
@GetMapping("/mine")
public List<RentalResponse> mine(@AuthenticationPrincipal AuthenticatedUser user) {
    return rentalService.findByUser(user.getUserId());
}

This is where section 4's decision pays off. With the standard User, the third option would give an object with no id and the database would have to be queried by email on every request. With AuthenticatedUser, the id is right there.

The important change: POST /api/v1/rentals

Since 03-03, starting a rental received the userId in the body:

public record StartRentalRequest(Long userId, Long bikeId) {}

That is now a textbook security hole. Marta can start rentals in the name of any citizen in Ribalta just by changing a number: the charges would go to somebody else's account. The server has no business asking who the client is when it already knows.

public record StartRentalRequest(@NotNull @Positive Long bikeId) {}   // no userId

@PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<RentalResponse> start(
        @Valid @RequestBody StartRentalRequest request,
        @AuthenticationPrincipal AuthenticatedUser user) {

    Rental rental = rentalService.start(user.getUserId(), request.bikeId());
    URI location = ServletUriComponentsBuilder.fromCurrentRequest()
            .path("/{id}").buildAndExpand(rental.getId()).toUri();
    return ResponseEntity.created(location).body(rentalMapper.toResponse(rental));
}

The general rule, and it is one of the most valuable in the module: the user's identity is never accepted from the client. It is derived from the credential. Every request field that says "who I am" is an impersonation vector. The same applies to POST /api/v1/rentals/{id}/finish, although there we also need to check that that rental belongs to that user: it is authorisation over the specific piece of data, and no URL rule can express it. That is the subject of 05-05.

  1. 401 and 403 responses in ProblemDetail format

In 05-01 we saw the problem: security failures happen in the filters, before the DispatcherServlet, so the @RestControllerAdvice from 03-06 does not see them and the API returns two different error formats. A client that knows how to read ProblemDetail is suddenly faced with something else.

The solution is two objects that plug into the ExceptionTranslationFilter:

package com.ciclourbana.security;

@Component
public class UnauthenticatedEntryPoint implements AuthenticationEntryPoint {

    private final ObjectMapper objectMapper;   // constructor omitted

    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response,
                         AuthenticationException exception) throws IOException {
        ProblemDetail problem = ProblemDetail.forStatusAndDetail(
                HttpStatus.UNAUTHORIZED, "Authentication is required for this operation");
        problem.setType(URI.create("https://api.ciclourbana.example/errors/not-authenticated"));
        problem.setTitle("Not authenticated");
        problem.setProperty("code", "NOT_AUTHENTICATED");
        problem.setProperty("trace", MDC.get(TraceFilter.MDC_KEY));   // trace from 03-06
        response.setStatus(HttpStatus.UNAUTHORIZED.value());
        response.setContentType(MediaType.APPLICATION_PROBLEM_JSON_VALUE);
        objectMapper.writeValue(response.getOutputStream(), problem);
    }
}

The AccessDeniedHandler is identical apart from the 403 status, the title "Access denied" and the code FORBIDDEN. Both are registered in the 05-02 chain:

.exceptionHandling(ex -> ex
    .authenticationEntryPoint(unauthenticatedEntryPoint)
    .accessDeniedHandler(apiAccessDeniedHandler))

Three important things. It never says why it failed: not "wrong password", not "deactivated user", not "you are missing the ADMIN role". The generic message avoids giving information away, and the real reason goes to the server log. MDC.get(TraceFilter.MDC_KEY) from 03-06 is reused, so that the citizen who calls support with the identifier a3f5c9e1 makes it possible to locate the exact request —provided that TraceFilter is registered before security in the filter chain, which is achieved by giving it a low @Order. And the content type is application/problem+json, the same as in RFC 7807, so that the client applies the same handling to every error.

The result, now consistent with the rest of the API:

{ "type": "https://api.ciclourbana.example/errors/not-authenticated",
  "title": "Not authenticated", "status": 401,
  "detail": "Authentication is required for this operation",
  "code": "NOT_AUTHENTICATED", "trace": "a3f5c9e1" }

  1. Failed attempts and account lockout

With no attempt limit, an attacker can try passwords indefinitely. BCrypt makes it slow —around 90 ms per attempt at cost 10— which already rules out mass brute force, but it does not prevent trying the hundred most common passwords against thousands of emails, which is how most real accounts are compromised.

Spring Security publishes authentication events that let you react without touching the flow:

@Component
public class AuthenticationEventListener {

    private static final Logger log =
            LoggerFactory.getLogger(AuthenticationEventListener.class);

    private final FailedAttemptsService attempts;   // constructor omitted

    @EventListener
    public void onFailure(AuthenticationFailureBadCredentialsEvent event) {
        String email = event.getAuthentication().getName();
        int failures = attempts.recordFailure(email);
        log.warn("Failed authentication for '{}' (attempt {})", email, failures);
        // NEVER: log.warn("password={}", event.getAuthentication().getCredentials())
    }

    @EventListener
    public void onSuccess(AuthenticationSuccessEvent e) { attempts.clear(e.getAuthentication().getName()); }
}

AuthenticationFailureBadCredentialsEvent is one of the subclasses of AbstractAuthenticationFailureEvent; there are others for deactivated, locked or expired-credential accounts, and they can all be listened to at once using the parent class.

With the counter available, AuthenticatedUser's isAccountNonLocked() stops returning a fixed true and consults the state. Four design decisions:

  • Temporary lockout, not permanent. Locking for ever turns the attack into a denial of service: failing five times against a citizen's email would be enough to shut them out. The usual span is 5 to 15 minutes, or better a progressive delay —1 s after the third failure, 2 s after the fourth, 4 s after the fifth— which slows the attacker down without punishing somebody who mistypes.
  • Count by source IP as well, not only by account, in order to detect the distributed attack against many emails. And the store must be shared if there are several instances: an in-memory ConcurrentHashMap stops being any use as soon as the application scales (distributed caching is covered in 09-02).

In a real system this is complemented with rate limiting at the gateway (05-05) and with alerting the security team on spikes of failures (09-05).

Common Mistakes and Tips

Making User implement UserDetails. It couples the JPA entity to the framework and puts a managed object, with lazy collections, inside the SecurityContext. Use an immutable copy.

Storing the roles with @Enumerated(ORDINAL). Inserting a new value in the middle of the enum silently reassigns every user's permissions.

Storing the role with the ROLE_ prefix in the database. It duplicates the prefix when building the authorities. Store ADMIN and add ROLE_ in a single place.

Leaving roles as LAZY without @EntityGraph. With open-in-view: false (04-02), loading outside the UserDetailsService transaction produces a LazyInitializationException at the most awkward possible point.

Distinguishing in the response between "user does not exist" and "wrong password". It allows the registered emails to be enumerated. Always answer the same thing.

Accepting the role or the user id in the registration or rental DTO. That is mass assignment and impersonation. Identity and privileges are set by the server.

Encoding the password in the controller, or in two different places. The encode belongs in the service, inside the transaction and in a single place; if it is duplicated, sooner or later one of the two will store the password in the clear. And never log the password or the hash, not even at DEBUG: logs get copied, sent to external systems and kept for years.

Tip: normalise the email at registration and at lookup. Lower case and trim at both ends prevent duplicate accounts that differ only in capitalisation. And remember that the real guarantee is the database's UNIQUE constraint, not the existsBy: the existsBy gives a good message, but the engine is what prevents the duplicate under concurrency.

Exercises

Exercise 1

Write CicloUrbana's UserDetailsService and the corresponding AuthenticatedUser, starting from a User entity with email, passwordHash, active and Set<UserRole> roles. Justify: where the ROLE_ prefix is added, why the entity is not returned, which transactional annotation the method carries and what error message is exposed to the client.

Exercise 2

A citizen reports that she changed her email to [email protected] from the administration panel and now cannot get in, whereas a colleague, with the same change, can. Analyse the possible causes and propose a complete solution including code, migration and prevention.

Exercise 3

Design the PATCH /api/v1/users/{id}/roles endpoint, which lets an ADMIN assign the OPERATOR role to a citizen. List all the necessary security checks and write the DTO, the controller and the service.

Solutions

Solution 1

The code is the one from sections 4 and 5. The four justifications:

Where ROLE_ is added: in the AuthenticatedUser constructor, when mapping UserRole to SimpleGrantedAuthority. The database stores ADMIN, with no prefix, because it is a domain fact and not a Spring Security convention. Concentrating it in one place is what avoids the ROLE_ROLE_s and the inexplicable 403s.

Why the entity is not returned: because it would couple the domain to the framework, force User to implement seven methods unrelated to its responsibility and, above all, put a managed entity into the SecurityContext. With open-in-view: false, any later access to a lazy association from the security context would throw LazyInitializationException far from the origin of the problem.

Transactional annotation: @Transactional(readOnly = true). It guarantees an active session to materialise the roles and lets Hibernate omit dirty checking (04-07).

Message to the client: always the same one —"Invalid credentials"— whether the email does not exist or the password fails, so as not to allow the network's users to be enumerated. The real reason goes into the server log at DEBUG level.

Solution 2

Root cause: the email was stored as it was, with capitals. If loadUserByUsername used findByEmail without IgnoreCase, Marta would have to type exactly [email protected] to get in. That it works for her colleague indicates that he typed it in lower case or that his client normalises it.

Possible secondary cause: a duplicate. If the change was made with an INSERT instead of an UPDATE, there may be two rows, [email protected] and [email protected]. The UNIQUE constraint from V1 does not prevent it, because in PostgreSQL it is case-sensitive.

Complete solution, on three fronts.

1. A case-insensitive query —already in section 5— with findByEmailIgnoreCase, which generates WHERE upper(email) = upper(?).

2. Normalisation on every write, in the service: email.trim().toLowerCase(Locale.ROOT) on registration and on modification. Locale.ROOT is not a minor detail: with the Turkish locale, toLowerCase() turns I into ı and would produce a different email address.

3. A migration that fixes the data and prevents a recurrence:

-- V5__normalise_user_emails.sql

-- Fails explicitly if there are already duplicates: they must be resolved by hand
DO $$
DECLARE duplicates INT;
BEGIN
    SELECT COUNT(*) INTO duplicates FROM (SELECT lower(email) FROM users
        GROUP BY lower(email) HAVING COUNT(*) > 1) d;
    IF duplicates > 0 THEN
        RAISE EXCEPTION 'There are % emails duplicated by capitalisation', duplicates;
    END IF;
END $$;

UPDATE users SET email = lower(trim(email)) WHERE email <> lower(trim(email));

-- Definitive prevention: the unique index operates on the normalised form
ALTER TABLE users DROP CONSTRAINT uk_users_email;
CREATE UNIQUE INDEX uk_users_email ON users (lower(email));

The functional unique index is the definitive solution, because it makes the duplicate impossible whatever code is written: the guarantee is back in the engine and not in the developers' discipline. And the DO block that aborts if there are already duplicates is good migration practice: it is preferable for the migration to fail than for an UPDATE to cause a half-way constraint violation.

Solution 3

Necessary checks, in order:

  1. Authentication: the caller must be authenticated → 401 if not.
  2. Authorisation by role: ADMIN only → 403. This is already covered by requestMatchers("/api/v1/users/**").hasRole("ADMIN") from 05-02.
  3. Existence of the target user → 404.
  4. Validation of the role received: it must be a value of the enum. Bean Validation and the UserRole type guarantee it; an unknown value produces 400.
  5. Business rule: nobody grants themselves privileges. An ADMIN must not be able to modify their own roles, because it removes the four-eyes control and makes escalation easier if their account is compromised.
  6. Business rule: do not leave the system with no administrators. Removing the last ADMIN leaves the network ungovernable.
  7. Mandatory auditing: who changed which roles for whom and when. It is a privilege change.
public record UpdateRolesRequest(@NotEmpty Set<UserRole> roles) {}

The controller is a @PatchMapping("/{id}/roles") that receives the @Valid @RequestBody UpdateRolesRequest, the @PathVariable and the @AuthenticationPrincipal AuthenticatedUser admin, and delegates to the service, where all the logic lives:

@Transactional
public UserResponse updateRoles(Long id, Set<UserRole> newRoles,
                                AuthenticatedUser admin) {

    if (id.equals(admin.getUserId())) {
        throw new BusinessRuleException(
                "An administrator cannot modify their own roles", "SELF_ASSIGNMENT");
    }

    User user = userRepository.findById(id)
            .orElseThrow(() -> new ResourceNotFoundException("User", id));

    if (user.getRoles().contains(UserRole.ADMIN)
            && !newRoles.contains(UserRole.ADMIN)
            && userRepository.countByRole(UserRole.ADMIN) <= 1) {
        throw new BusinessRuleException(
                "The last administrator cannot be removed", "LAST_ADMIN");
    }

    Set<UserRole> previous = Set.copyOf(user.getRoles());
    user.setRoles(newRoles);
    log.warn("ROLE CHANGE: admin={} affectedUser={} before={} after={}",
             admin.getUserId(), id, previous, newRoles);
    return userMapper.toResponse(user);
}

Three notes. The log is WARN on purpose: a privilege change is not routine and must stand out in a log review (09-05). The last-administrator check has a race condition —two simultaneous requests could remove the last two— which can be resolved with the pessimistic locking from 04-07 or with a database constraint. And the affected user will keep their old roles until they renew their credential: with a session, until they sign in again; with JWT, until the token expires. It is an important limitation of the stateless model and we will deal with it in 05-04.

Conclusion

CicloUrbana's identity is now real. You have extended the User entity from 04-03 with passwordHash, the active flag it already had and a Set<UserRole> mapped with @ElementCollection and @Enumerated(STRING), justifying every decision: why an enum and not a Role entity, why EAGER is here the correct exception to the rule from 04-04, and why the field is called passwordHash and stays out of toString. The V4__add_user_credentials.sql migration has taken it into the schema with the three-step "nullable → fill → NOT NULL" pattern, a user_roles table with a composite key, ON DELETE CASCADE and a CHECK constraint that replicates the enum in the engine, leaving pre-existing users deactivated as a security decision.

You have written AppUserDetailsService, which loads by email from UserRepository with findByEmailIgnoreCase inside a read-only transaction, and AuthenticatedUser, the immutable copy that implements UserDetails keeping the id —a three-line decision that saves one query per request and that in 05-05 will let you write principal.userId in a security expression. You understand exactly what the DaoAuthenticationProvider does: it looks up, compares with the PasswordEncoder, checks the four account states and computes a hash even when the user does not exist, because answering quickly would give away which emails are registered with the municipal network. And you know why every authentication failure is answered with a single generic message.

You have opened citizen registration with five security decisions in twenty lines: the role is set on the server and does not travel in the DTO —preventing mass assignment—, the response is an inclusion list with no hash and no roles, the email is normalised, the uniqueness guarantee comes from the database constraint and not from the existsBy, and the 72-character limit answers a real BCrypt detail; all of it with the express warning that email verification is missing, which no real service can omit. You have made clear that only authorities exist and that a role is an authority with a ROLE_ prefix, you have compared roles with fine-grained permissions knowing when the time to migrate will come, and you have simplified the 05-02 rules with RoleHierarchy and the ADMIN > OPERATOR > CITIZEN hierarchy, without forcing it onto roles that are not cumulative.

And you have made the most important change of the lesson: POST /api/v1/rentals no longer accepts the client's userId, but derives it from the @AuthenticationPrincipal. The rule that sums it all up: identity is never accepted from the client, it is derived from the credential. Security errors finally speak the same language as the rest of the API, with our own AuthenticationEntryPoint and AccessDeniedHandler returning ProblemDetail with its code and its trace from the 03-06 MDC; and you know the scheme for controlling failed attempts with authentication events and its four cautions.

One practical problem remains, and Ribalta's mobile app notices it as soon as it connects: with HTTP Basic, the client has to store the citizen's password and send it on every single request. Every lookup of the station map drags the user's most valuable credential across the network, and every one of them forces the server to compute a 90 ms BCrypt. There is no way to expire access without changing the password, nor to grant limited permissions to an integration. In 05-04, Implementing JWT Authentication, we will solve it: the password will be sent just once to POST /api/v1/auth/login and in exchange the server will issue a signed, expirable token that the client will present in the Authorization: Bearer header. We will look at the anatomy of a JWT field by field, we will implement it with JJWT and a JwtAuthenticationFilter, we will add refresh tokens with rotation and database revocation, and we will face honestly the two uncomfortable questions of the stateless model: where the client stores the token and what signing out really means.

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