Spring Study Guide – Security

August 16, 20268 min readUpdated 8/18/2026

Spring Security is a chain of servlet filters in front of your application. Every question below is ultimately about that chain: what runs in it, in what order, and what it puts where.

Authentication and authorisation

What are they, and which comes first?

  • Authentication — establishing who the caller is. Are these credentials valid?
  • Authorisation — deciding whether that caller may do this.

Authentication first, necessarily: you cannot decide what someone is allowed to do until you know who they are. In HTTP terms, failing the first is 401 and failing the second is 403.

Is security a cross-cutting concern? How is it implemented?

Yes, and Spring implements it two ways at once. At the web layer it is a filter chain in front of the DispatcherServlet. At the method layer it is AOP@PreAuthorize is a proxy, with all the proxy rules that implies.

The filter chain

What is the DelegatingFilterProxy?

A servlet filter registered with the container that does nothing but delegate to a Spring bean. It exists because filters are created by the servlet container, which knows nothing about the Spring context — so the container gets a thin proxy and the real filter gets to be a fully injected bean.

What is the security filter chain?

The ordered list of filters each request passes through. Each has one job, and the order is the design. Abbreviated, in order:

  1. SecurityContextHolderFilter — loads any existing context.
  2. CsrfFilter.
  3. LogoutFilter.
  4. Authentication filtersUsernamePasswordAuthenticationFilter, and custom ones like a JWT filter.
  5. ExceptionTranslationFilter — turns security exceptions into 401 or 403.
  6. AuthorizationFilter — applies the rules; last, because by now the caller is known.

Inspect the real chain at startup with logging.level.org.springframework.security=DEBUG. It prints every filter in order, which beats guessing.

What is the SecurityContext?

The holder of the current Authentication — the principal, its credentials and its granted authorities. It lives in SecurityContextHolder, which by default is backed by a ThreadLocal.

Authentication auth = SecurityContextHolder.getContext().getAuthentication();
String email = auth.getName();

Two consequences of that ThreadLocal worth being able to state:

  • The context is cleared at the end of the request, because the thread goes back to a pool and the next request must not inherit it.
  • It does not propagate to a new thread. An @Async method sees an empty context unless you set MODE_INHERITABLETHREADLOCAL or use DelegatingSecurityContextExecutor.

Configuration

How do you configure it today?

By publishing a SecurityFilterChain bean and configuring it with the lambda DSL. WebSecurityConfigurerAdapter was deprecated in Spring Security 5.7 and removed in 6, and Spring Security 7 removed the older chained-setter style as well — so examples written against Spring Security 5 do not compile.

@Configuration
@EnableWebSecurity
@EnableMethodSecurity
@RequiredArgsConstructor
public class SecurityConfig {

    private final JwtAuthenticationFilter jwtAuthenticationFilter;

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http.cors(Customizer.withDefaults())
                // Safe to disable ONLY because this API is stateless and token-based:
                // there is no session cookie for a cross-site request to ride on.
                // Never do this for a cookie-authenticated app.
                .csrf(csrf -> csrf.disable())
                .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
                .authorizeHttpRequests(auth -> auth
                        .requestMatchers("/api/auth/register", "/api/auth/login").permitAll()
                        .requestMatchers(HttpMethod.GET, "/api/products/**").permitAll()

                        // SPECIFIC BEFORE GENERAL. Order history needs an account...
                        .requestMatchers(HttpMethod.GET, "/api/orders/mine").authenticated()
                        // ...but placing an order must not - this is guest checkout.
                        .requestMatchers(HttpMethod.POST, "/api/orders").permitAll()

                        .requestMatchers("/api/admin/**").hasRole("ADMIN")

                        // Default deny: anything not listed needs authentication, so a
                        // new endpoint is closed until someone deliberately opens it.
                        .anyRequest().authenticated())
                .addFilterBefore(jwtAuthenticationFilter,
                        UsernamePasswordAuthenticationFilter.class);

        return http.build();
    }
}

Why do you need URL rules at all, and in what order must they be written?

First match wins, so specific patterns must come before general ones. Put /api/orders/** above /api/orders/mine and the specific rule is unreachable — and nothing warns you. This is the single most common Spring Security misconfiguration.

Always finish with anyRequest().authenticated(). Default-deny means a newly added controller is closed until someone opens it deliberately; without it, a forgotten rule is a public endpoint.

What does ** match? Any number of path segments, including none. A single * matches within one segment only. So /api/orders/* matches /api/orders/42 but not /api/orders/42/items, while /api/orders/** matches both.

Why is an MVC matcher safer than an Ant matcher? Because it matches the way Spring MVC actually routes, so the two cannot disagree. An Ant matcher works on the raw path, and the classic exploit is a request that MVC maps to a protected handler but whose literal path does not match the pattern — /admin/users.json, a trailing slash, an encoded character. Under Spring Security 6+, requestMatchers(...) chooses an MVC matcher automatically when Spring MVC is present, which is why the modern answer is "use requestMatchers and do not hand-pick".

Passwords

Does Spring Security support hashing? What is salting?

Yes. Register a PasswordEncoder bean — BCryptPasswordEncoder is the usual choice.

A salt is random data mixed into the password before hashing, so that two users with the same password get different hashes and a precomputed rainbow table is useless. BCrypt generates the salt itself and stores it inside the hash string, which is why matches() needs nothing but the raw password and the stored hash.

/**
 * BCrypt is deliberately slow, and that 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();
}

The interface is encode() and matches() — note there is no decode(), because a hash is one-way. If a system can email you your existing password, it is not hashing them.

Method security

Why do you need it? What is typically secured that way?

Because URL rules protect one entry point. A service is a bean that any future controller, scheduled job or message listener can inject — and the annotation travels with the method, so the guard cannot be left behind when the caller changes. It is the service layer that gets secured this way: the layer where a business operation lives, not the layer that happens to be reachable over HTTP.

@Service
public class AdminUserServiceImpl implements AdminUserService {

    @Override
    @PreAuthorize("hasRole('ADMIN')")
    @Transactional
    public void deleteUser(String actingAdminEmail, UUID userId) { ... }
}

Switch it on with @EnableMethodSecurity.

@Secured versus @RolesAllowed versus @PreAuthorize?

AnnotationFromTakes
@SecuredSpring Securitya list of role strings, OR-ed
@RolesAllowedJakarta (JSR-250)the same, but portable
@PreAuthorizeSpring Securitya SpEL expression

Which one takes SpEL? @PreAuthorize and @PostAuthorize — and that is why they are the ones to use. Only they can express a rule about the arguments or the result:

@PreAuthorize("hasRole('ADMIN')")
@PreAuthorize("hasAnyRole('ADMIN','STAFF')")
@PreAuthorize("#email == authentication.name")            // only your own record
@PostAuthorize("returnObject.ownerEmail == authentication.name")  // checked AFTER the call

@PostAuthorize runs after the method and can inspect the return value — useful, but note the method has already executed, so it is no defence against a method with side effects.

⚠️ hasRole("ADMIN") checks for the authority ROLE_ADMIN. The prefix is added by the framework. Storing "ROLE_ADMIN" in the database and then calling hasRole("ROLE_ADMIN") looks for ROLE_ROLE_ADMIN and silently denies everything. Use hasAuthority() if you want no prefix at all.

// Where the prefix is added in the pizza API - once, in the filter, not in the database.
var authorities = List.of(new SimpleGrantedAuthority("ROLE_" + role));

⚠️ And it is proxy-based, so it has the same blind spot as @Transactional: a call from another method inside the same class skips the check entirely.

Stateless authentication with JWT

A JWT is signed, not encrypted — anyone holding one can read its payload, so never put anything secret in the claims. The signature only guarantees that you issued it and nobody edited it.

public String generateToken(User user) {
    Instant now = Instant.now();

    return Jwts.builder()
            .subject(user.getEmail())
            .claim("role", user.getRole().name())
            .issuedAt(Date.from(now))
            .expiration(Date.from(now.plusSeconds(expirationMinutes * 60)))
            .signWith(key)
            .compact();
}

/** Returns the claims if valid, or null if malformed, forged or expired. */
public Claims parse(String token) {
    try {
        return Jwts.parser().verifyWith(key).build().parseSignedClaims(token).getPayload();
    } catch (JwtException | IllegalArgumentException ex) {
        // Any failure means "not authenticated". Distinguishing them would only help an
        // attacker probe the implementation.
        return null;
    }
}

The filter that consumes it:

/**
 * Extends OncePerRequestFilter because a plain Filter can run more than once per request
 * (forwards, error dispatches), and re-authenticating each time is wasted work.
 *
 * Note what it does NOT do: it never rejects a request. A missing or bad token simply
 * leaves the context anonymous, and the authorization rules then decide whether that is
 * acceptable. Keeping authentication and authorization separate is what lets guest
 * checkout share an endpoint with authenticated ordering.
 */
@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) {
                var authorities =
                        List.of(new SimpleGrantedAuthority("ROLE_" + claims.get("role", String.class)));
                var authentication = new UsernamePasswordAuthenticationToken(
                        claims.getSubject(), null, authorities);

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

        filterChain.doFilter(request, response);   // ALWAYS continue the chain
    }
}

The trade-off to be able to state: a stateless token cannot be revoked before it expires. Real systems pair a short-lived access token with a refresh token, or keep a deny-list — which reintroduces exactly the state the design removed.

CSRF and CORS — different problems

  • CSRF exploits credentials the browser sends automatically — cookies. If your API authenticates with a Bearer header, there is nothing to ride on, and disabling CSRF is correct. If it authenticates with a session cookie, disabling CSRF is a vulnerability.
  • CORS is the browser asking whether another origin may read your response. It is a browser policy, not a security control — it does not stop a request being made, only a script from reading the reply.

Is hiding UI enough?

Is it enough to hide sections of a page from users who may not use them?

No, and this is the question to answer emphatically. Hiding a button is a usability improvement, not a security control — the endpoint is still there and anyone can call it with curl. Every rule must be enforced on the server. The UI may hide, the server must refuse.

Enforcing it twice is fine and is what the pizza API does: the URL rules guard the HTTP entry points and @PreAuthorize guards the service methods, so a new controller that forgets its URL rule is still refused.

What to remember

  • Authentication is who; authorisation is what. 401 versus 403.
  • It is a filter chain; AuthorizationFilter runs last because by then the caller is known.
  • The SecurityContext is a ThreadLocal — cleared per request, not inherited by new threads.
  • SecurityFilterChain bean plus the lambda DSL. No WebSecurityConfigurerAdapter.
  • Matchers are first-match-wins: specific before general, and always end with anyRequest().authenticated().
  • hasRole("ADMIN") means the authority ROLE_ADMIN.
  • Hiding UI is not security.