Spring Security – How Authentication Works

July 25, 20265 min readUpdated 8/18/2026

Spring Security has a reputation for being impenetrable, and most of that comes from trying to configure it before understanding what it is doing. There are really only four pieces, and everything else is arrangement.

The four pieces

PieceJob
Filter chaina list of servlet filters that runs before your controller
Authenticationwho the caller is, and what they may do
SecurityContextwhere that lives for the duration of the request
AuthenticationManagerthe thing that turns credentials into an Authentication

Note where the filter chain sits: before the DispatcherServlet (lesson 10). Spring Security can therefore answer a request entirely on its own, which is why you can get a 403 for a URL that has no controller.

Authentication is not authorization

This distinction is the key to reading any Spring Security configuration:

  • Authentication — who are you? Produces an Authentication, or nothing.
  • Authorization — may you do this? Consults that Authentication.

Keeping them separate is what makes the pizza API's guest checkout possible, and its JWT filter says so explicitly:

/**
 * <p>Note what this filter does NOT do: it never rejects a request. A missing or bad token simply
 * leaves the context anonymous, and the authorization rules in SecurityConfig then decide whether
 * that is acceptable. Keeping authentication and authorization separate is what allows guest
 * checkout to share an endpoint with authenticated ordering.
 */

One endpoint, POST /api/orders, serves both a signed-in customer and a guest. The filter attaches an identity if there is one; the endpoint works either way. A filter that rejected unauthenticated requests would need two endpoints.

Storing passwords

/**
 * BCrypt deliberately makes hashing slow, which is the point: it makes brute-forcing a stolen
 * password table expensive. Never store a password with a fast hash such as MD5 or SHA-256.
 */
@Bean
public PasswordEncoder passwordEncoder() {
    return new BCryptPasswordEncoder();
}

That comment contains the whole lesson. SHA-256 is designed to be fast, so an attacker with a stolen table can try billions of candidates per second. BCrypt is designed to be slow and has a work factor you can raise as hardware improves. It also salts each hash automatically, so two users with the same password get different hashes and one rainbow table cannot crack both.

Never write your own. Use BCryptPasswordEncoder, or Argon2PasswordEncoder if you want the current best practice.

Registration

@Override
@Transactional
public AuthenticationResponseDTO register(RegisterDTO dto) {
    if (userDAO.existsByEmail(dto.email())) {
        throw ApiException.badRequest("That email is already registered");
    }

    User user = User.builder()
            .email(dto.email().trim().toLowerCase())
            // Hash immediately. The plaintext password must never be stored, logged, or
            // returned — from here on only the hash exists.
            .passwordHash(passwordEncoder.encode(dto.password()))
            .fullName(dto.fullName())
            // Registration ALWAYS creates a CUSTOMER. If the role came from the request body,
            // anyone could sign up as an admin by adding one field to the JSON.
            .role(UserRole.CUSTOMER)
            .build();

    return buildAuthResponse(userDAO.save(user));
}

Three deliberate decisions:

  • Hash immediately. The plaintext exists only as a method parameter, and never reaches a field, a log or a response.
  • Normalise the email — trimmed and lower-cased — so Folau@Example.com and folau@example.com cannot become two accounts.
  • The role is hard-coded. This is the mass-assignment guard from lesson 11 in its sharpest form: RegisterDTO has no role field, so a role cannot arrive from a request body even if someone later adds mapping for it.

Login, and account enumeration

@Override
@Transactional(readOnly = true)
public AuthenticationResponseDTO login(LoginDTO dto) {
    User user = userDAO.findByEmail(dto.email())
            // Same exception whether the user is unknown or the password is wrong, so the
            // response cannot be used to discover which emails have accounts.
            .orElseThrow(() -> new BadCredentialsException("Invalid email or password"));

    if (!passwordEncoder.matches(dto.password(), user.getPasswordHash())) {
        throw new BadCredentialsException("Invalid email or password");
    }

    return buildAuthResponse(user);
}

Both failures produce the identical exception and message. "No account with that email" is friendlier and it is an oracle: an attacker submits a list of addresses and learns which ones are registered. That is valuable on its own, and doubly so combined with a password dump from somewhere else.

Note also passwordEncoder.matches(raw, hash) — you never decrypt a BCrypt hash. It hashes the candidate with the stored salt and compares, in constant time.

The same reasoning drives the exception handler (lesson 12), which flattens every authentication failure to one message.

Populating the SecurityContext

@Component
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    @Override
    protected void doFilterInternal(
            HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
            throws ServletException, IOException {

        String token = extractToken(request);

        if (token != null && SecurityContextHolder.getContext().getAuthentication() == null) {
            Claims claims = jwtService.parse(token);

            if (claims != null) {
                String email = claims.getSubject();
                String role = claims.get("role", String.class);

                // Spring Security matches hasRole("ADMIN") against the authority "ROLE_ADMIN".
                // The prefix is added here rather than stored in the database.
                var authorities = List.of(new SimpleGrantedAuthority("ROLE_" + role));

                var authentication =
                        new UsernamePasswordAuthenticationToken(email, null, authorities);
                authentication.setDetails(
                        new WebAuthenticationDetailsSource().buildDetails(request));

                SecurityContextHolder.getContext().setAuthentication(authentication);
            }
        }

        filterChain.doFilter(request, response);
    }
}

⚠️ The ROLE_ prefix. hasRole("ADMIN") checks for an authority literally called ROLE_ADMIN; hasAuthority("ADMIN") checks for ADMIN. Mixing them up gives you a 403 with everything apparently configured correctly. Adding the prefix in one place, as above, keeps the database clean and the confusion contained.

OncePerRequestFilter matters too: a plain Filter can run more than once per request on forwards and error dispatches, and re-authenticating each time is wasted work.

Reading the current user

@GetMapping("/me")
public ResponseEntity<UserDTO> me(Authentication authentication) {
    String email = authentication.getName();
    return ResponseEntity.ok(userService.getUserByEmail(email));
}

Spring injects the Authentication as a method parameter — no static lookup needed. @AuthenticationPrincipal works too. The static SecurityContextHolder.getContext().getAuthentication() is available anywhere, and is worth avoiding in controllers because it makes the method's dependencies invisible.

Note the pattern the pizza API builds on this: /api/me/** resolves the owner from the token, so there is no user id in the path that could be swapped for someone else's. That removes an entire class of authorization bug rather than defending against it.

Where the SecurityContext lives

It is stored in a ThreadLocal. Two consequences:

  • It is cleared at the end of each request, which is what makes a stateless API stateless.
  • It does not propagate to a new thread. An @Async method starts with an empty context, so a @PreAuthorize in there sees an anonymous caller. If you need the identity on another thread, pass it explicitly — which is another argument for events carrying values (lesson 9).

What to take from this

  • Four pieces: filter chain, Authentication, SecurityContext, AuthenticationManager.
  • Authenticate in the filter, authorize in the rules. That separation is what lets one endpoint serve guests and members.
  • BCrypt, hashed immediately, and never a fast hash.
  • Identical failure for unknown user and wrong password.
  • hasRole("ADMIN") means the authority ROLE_ADMIN.

Next: configuring the filter chain — the lambda DSL, and why rule order decides whether an endpoint is protected.