Scenario Questions

An admin removes a user's ADMIN role, but the user can still call admin APIs. Why, and how do you fix it?

The user still holds a valid JWT that says role: ADMIN; JWTs are not re-checked against the DB until they expire. Fixes: short access-token expiry plus refresh (the new token gets the new roles), a tokenVersion claim compared with the DB value on sensitive calls, or a Redis denylist. For very sensitive actions, load current permissions from the DB instead of trusting the token.

Your API returns 403 for every POST, but GET works. What is the likely cause?

CSRF protection is on (the default) and the client sends no CSRF token, so CsrfFilter rejects every state-changing request. For a stateless Bearer-token API, disable CSRF; for a cookie-based SPA, use CookieCsrfTokenRepository and send the header. Also check that the role check is not failing because of the ROLE_ prefix.

A user is logged in, but inside an @Async method SecurityContextHolder returns null. Why?

The context is stored in a ThreadLocal, and @Async runs on another thread. Wrap the executor so the context is copied:

@Bean
Executor taskExecutor() {
    ThreadPoolTaskExecutor pool = new ThreadPoolTaskExecutor();
    pool.initialize();
    return new DelegatingSecurityContextAsyncTaskExecutor(pool);
}

Or pass the user id to the async method explicitly, which is clearer and works for message queues too.

ShopEase: anyone can browse products, customers can order, only admins can manage products. Write the rules.

http.authorizeHttpRequests(auth -> auth
    .requestMatchers("/api/auth/**", "/actuator/health").permitAll()
    .requestMatchers(HttpMethod.GET, "/api/products/**", "/api/categories/**").permitAll()
    .requestMatchers("/api/products/**", "/api/categories/**").hasRole("ADMIN")
    .requestMatchers("/api/orders/**", "/api/cart/**").hasAnyRole("CUSTOMER", "ADMIN")
    .requestMatchers("/actuator/**").hasRole("OPS")
    .anyRequest().denyAll());

Order matters: the GET rule comes before the general products rule. denyAll() at the end means a new endpoint is closed until someone adds a rule. Ownership ("only your own order") is checked with @PreAuthorize in the service.

A customer can see another customer's order by changing the id in /api/orders/42. What is this bug and how do you fix it?

It is IDOR (Insecure Direct Object Reference), part of OWASP "Broken Access Control". Being authenticated is not enough; check ownership on every object access:

@PreAuthorize("hasRole('ADMIN') or @orderSecurity.isOwner(#id, authentication.name)")
public OrderDto getOrder(Long id) { ... }

// or query by owner: orderRepository.findByIdAndUserEmail(id, currentUserEmail)

Return 404 (not 403) for orders that are not yours, so attackers cannot discover which ids exist.