Article 33 put the catalogue behind a SecurityFilterChain, article 34 moved the users into a users table with a role, and article 35 replaced HTTP Basic with a JWT: POST /api/auth/login returns a signed token and every API call sends Authorization: Bearer …. All of that answered who is calling. This article answers what the caller may do: role-based rules in authorizeHttpRequests, @PreAuthorize and @PostAuthorize on service methods, and the two browser-facing controls that decide whether a request is even allowed to reach the rules, CORS and CSRF.
The examples use Spring Boot 4.1.1, which brings Spring Security 7.1.1, and Java 21, on an Initializr project with the web, validation, security, OAuth2 resource server, Spring Data JPA, H2 and Thymeleaf dependencies. The app runs on port 8136, a second origin serves a browser page on port 8146, and the browser output comes from headless Chrome driven over the DevTools protocol.
![]()
Two users from article 34 do the work: alice with role USER and admin with role ADMIN, each logged in for a real token. The security headers article 33 shows in full are left out of the curl -i outputs here.
Roles and authorities
Spring Security does not know about "roles" at the point where it checks a rule. An Authentication carries a collection of GrantedAuthority, each a plain string, and every rule is a test on those strings. A role is a convention: an authority whose name starts with ROLE_. hasRole("ADMIN") is sugar for "has the authority ROLE_ADMIN".
Article 35 signs each JWT with a roles claim holding the bare role name, and Boot's resource server maps it to an authority with two properties:
spring.security.oauth2.resourceserver.jwt.public-key-location=classpath:certs/public.pem
spring.security.oauth2.resourceserver.jwt.authorities-claim-name=roles
spring.security.oauth2.resourceserver.jwt.authority-prefix=ROLE_spring:
security:
oauth2:
resourceserver:
jwt:
public-key-location: classpath:certs/public.pem
authorities-claim-name: roles
authority-prefix: ROLE_authorities-claim-name=roles reads the claim, authority-prefix=ROLE_ puts the prefix back, and no JwtAuthenticationConverter bean is needed. alice logs in and the token's payload carries the bare name:
curl -s -H 'Content-Type: application/json' -d '{"username":"alice","password":"Wonderland-2026"}' http://localhost:8136/api/auth/login{"accessToken":"eyJ…","tokenType":"Bearer","expiresIn":900}The middle segment of that token, Base64url-decoded, is:
{"sub":"alice","exp":1789379998,"iat":1789379098,"roles":["USER"]}This lab's token carries no iss; article 35's TokenService adds one and validates it, which changes nothing below. A small /api/me endpoint returns the authorities Spring Security built from the token:
curl -s -H "Authorization: Bearer $ALICE" http://localhost:8136/api/me{"name":"alice","authorities":["ROLE_USER","FACTOR_BEARER"],"authenticationType":"JwtAuthenticationToken"}The bare USER became the authority ROLE_USER. FACTOR_BEARER is the counterpart of the FACTOR_PASSWORD from article 33: Spring Security 7 records that this request authenticated with a bearer token. admin's token carries "roles":["ADMIN"] and its /api/me lists ROLE_ADMIN.
hasRole("ADMIN") vs hasAuthority("ROLE_ADMIN")
The two say the same thing about ROLE_ADMIN; they differ only in whether they add the prefix. A probe bean carries five @PreAuthorize expressions and a controller reports which pass for the caller:
@Component
public class RoleProbe {
@PreAuthorize("hasRole('ADMIN')")
public void hasRoleAdmin() {
}
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
public void hasAuthorityRoleAdmin() {
}
@PreAuthorize("hasAuthority('ADMIN')")
public void hasAuthorityAdmin() {
}
}Called as admin:
{"hasRole('ADMIN')":"allowed","hasAuthority('ROLE_ADMIN')":"allowed","hasAuthority('ADMIN')":"denied (AuthorizationDeniedException)"}hasRole('ADMIN')prependsROLE_and checksROLE_ADMIN: allowed.hasAuthority('ROLE_ADMIN')checks the exact stringROLE_ADMIN: allowed.hasAuthority('ADMIN')checks the exact stringADMIN, which nobody has: denied. This is the mistake that silently locks out every admin, because the stored authority isROLE_ADMIN.
What hasRole("ROLE_ADMIN") does
Passing the prefix to hasRole is the other common slip, and Spring Security 7.1.1 treats it differently in the two places it appears. In a URL rule it fails at startup:
.requestMatchers("/api/admin/**").hasRole("ROLE_ADMIN")Caused by: java.lang.IllegalArgumentException: ROLE_ADMIN should not start with ROLE_ since ROLE_ is automatically prepended when using hasAnyRole. Consider using hasAnyAuthority instead.In a @PreAuthorize SpEL expression it does not throw and does not double the prefix: hasRole('ROLE_ADMIN') checks ROLE_ADMIN, exactly like hasRole('ADMIN'). The probe as admin returned "hasRole('ROLE_ADMIN')":"allowed", and as alice it returned "denied". So the URL rule catches the mistake loudly while the method rule quietly does the right thing anyway; either way, write hasRole('ADMIN').
URL rules in authorizeHttpRequests
The catalogue's rules are the same authorizeHttpRequests block from article 33, extended for the whole write surface. AuthorizationFilter reads them top to bottom and the first matcher that fits wins, so the method-specific rules come before anyRequest():
@Bean
@Order(1)
SecurityFilterChain apiSecurityFilterChain(HttpSecurity http, ProblemDetailSecurityHandler problemHandler) {
http
.securityMatcher("/api/**")
.authorizeHttpRequests(auth -> auth
.requestMatchers(HttpMethod.GET, "/api/products/**").permitAll()
.requestMatchers(HttpMethod.POST, "/api/auth/register", "/api/auth/login").permitAll()
.requestMatchers(HttpMethod.POST, "/api/products/**").hasRole("ADMIN")
.requestMatchers(HttpMethod.PUT, "/api/products/**").hasRole("ADMIN")
.requestMatchers(HttpMethod.DELETE, "/api/products/**").hasRole("ADMIN")
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.requestMatchers("/api/orders/**").authenticated()
.anyRequest().authenticated())
.oauth2ResourceServer(oauth2 -> oauth2
.jwt(Customizer.withDefaults())
.authenticationEntryPoint(problemHandler))
.exceptionHandling(exceptions -> exceptions
.authenticationEntryPoint(problemHandler)
.accessDeniedHandler(problemHandler))
.csrf(csrf -> csrf.disable())
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS));
return http.build();
}GET /api/products/**ispermitAll(): anyone reads the catalogue.POST,PUT,DELETE /api/products/**needROLE_ADMIN: only an admin changes it./api/admin/**is admin-only, for endpoints like the user list./api/orders/**needsauthenticated(): any logged-in user places an order, and finer rules on orders belong to method security below.
ProblemDetailSecurityHandler is article 33's component, wired here as both the authenticationEntryPoint and the accessDeniedHandler so a filter-level denial is written as a ProblemDetail; since article 35 its commence lets BearerTokenAuthenticationEntryPoint write the Bearer challenge before the body.
Anonymous, alice and admin on the same write
The same POST /api/products from the three callers shows authentication and authorization as two separate answers. No token:
curl -i -s -X POST -H 'Content-Type: application/json' -d '{"sku":"HB-001","name":"USB-C hub","price":350000}' http://localhost:8136/api/productsHTTP/1.1 401
WWW-Authenticate: Bearer realm="catalogue", resource_metadata="http://localhost:8136/.well-known/oauth-protected-resource"
Content-Type: application/problem+json
{"detail":"Valid credentials are required to access this resource.","instance":"/api/products","status":401,"title":"Unauthorized"}alice's token, which carries only ROLE_USER:
curl -i -s -X POST -H "Authorization: Bearer $ALICE" -H 'Content-Type: application/json' -d '{"sku":"HB-001","name":"USB-C hub","price":350000}' http://localhost:8136/api/productsHTTP/1.1 403
Content-Type: application/problem+json
{"detail":"You are not allowed to perform this operation.","instance":"/api/products","status":403,"title":"Forbidden"}admin's token:
HTTP/1.1 201
Location: http://localhost:8136/api/products/3
Content-Type: application/json
{"id":3,"sku":"HB-001","name":"USB-C hub","price":350000}No token is 401 with the Bearer challenge; a valid token without the role is 403; the right role is 201. GET /api/admin/users behaved the same way: 403 for alice, and for admin the JSON list of users. AuthorizationFilter decided all of this before DispatcherServlet ran, which is why the bodies come from the access denied handler and not from a controller.
Method security with @PreAuthorize and @PostAuthorize
URL rules match on the request: a method and a path. They cannot see an argument or a return value, so "a user may read only their own orders" cannot be a URL rule. Method security can: it wraps a bean in a proxy that checks an expression around each annotated call, against the same Authentication.
It is off until switched on. One annotation on a @Configuration class does it:
package com.example.demo.common;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
@Configuration
@EnableMethodSecurity
public class MethodSecurityConfig {
}The order service uses it on three methods:
@Service
public class OrderService {
@PreAuthorize("#username == authentication.name or hasRole('ADMIN')")
public List<Order> findByCustomer(String username) {
return orders.values().stream()
.filter(order -> order.customer().equals(username))
.toList();
}
@PostAuthorize("returnObject.customer == authentication.name or hasRole('ADMIN')")
public Order findById(long id) {
Order order = orders.get(id);
if (order == null) {
throw new OrderNotFoundException(id);
}
return order;
}
@PreAuthorize("hasRole('ADMIN')")
public void cancel(long id) {
if (orders.remove(id) == null) {
throw new OrderNotFoundException(id);
}
}
}@PreAuthorizeruns before the method.hasRole('ADMIN')oncancelis the same role check as a URL rule.#usernamein the expression is the method argument of that name;authentication.nameis the current user.findByCustomerlets a user read their own orders and an admin read anyone's.@PostAuthorizeruns after the method and can readreturnObject.findByIdloads the order, then checks that it belongs to the caller. Use it only when the object must be fetched to be judged, since the method has already run.
Is @EnableMethodSecurity needed in Spring Boot 4?
Yes. Spring Boot 4.1.1 does not enable method security on its own. With @EnableMethodSecurity removed and everything else unchanged, the annotations became inert: alice deleted an order through cancel, whose @PreAuthorize("hasRole('ADMIN')") should have stopped her.
curl -i -s -X DELETE -H "Authorization: Bearer $ALICE" http://localhost:8136/api/orders/1HTTP/1.1 204The role probe from earlier confirmed it: without the annotation, every expression, hasRole('ADMIN') included, answered allowed for alice. @EnableGlobalMethodSecurity, the Spring Security 5 annotation, is gone; @EnableMethodSecurity is the replacement and it defaults to prePostEnabled = true.
An ownership rule with @PreAuthorize
With method security on, findByCustomer enforces ownership. alice reads her own orders:
curl -i -s -H "Authorization: Bearer $ALICE" "http://localhost:8136/api/orders?customer=alice"HTTP/1.1 200
Content-Type: application/json
[{"id":1,"customer":"alice","sku":"KB-001","quantity":1}]alice asking for admin's orders is refused, while admin may ask for anyone's:
curl -i -s -H "Authorization: Bearer $ALICE" "http://localhost:8136/api/orders?customer=admin"HTTP/1.1 403
Content-Type: application/problem+json
{"detail":"You are not allowed to perform this operation.","instance":"/api/orders","status":403,"title":"Forbidden"}@PostAuthorize on the return value
findById fetches first and checks afterwards. alice reads her own order 1, but not admin's order 2:
curl -i -s -H "Authorization: Bearer $ALICE" http://localhost:8136/api/orders/2HTTP/1.1 403
Content-Type: application/problem+json
{"detail":"You are not allowed to perform this operation.","instance":"/api/orders/2","status":403,"title":"Forbidden"}The order was loaded from the store and then withheld. @PostAuthorize cannot un-run the method, so never put it on a method that writes: the write would happen and only the response would be blocked.

Self-invocation skips the check
Method security is a proxy, exactly like @Transactional in article 30, so it has the same blind spot: a call from inside the same bean does not cross the proxy. OrderService has a summary method with no annotation that calls findByCustomer on this:
public OrderSummary summary(String username) {
List<Order> customerOrders = findByCustomer(username);
return new OrderSummary(username, customerOrders.size(),
customerOrders.stream().mapToInt(Order::quantity).sum());
}findByCustomer is guarded, but alice reads a summary of admin's orders anyway:
curl -i -s -H "Authorization: Bearer $ALICE" "http://localhost:8136/api/orders/summary?customer=admin"HTTP/1.1 200
Content-Type: application/json
{"customer":"admin","orders":1,"items":3}The this.findByCustomer(username) call is a plain Java call on the target object; it never goes back out through the proxy, so the @PreAuthorize never runs. Reaching findByCustomer through the controller, which does cross the proxy, gave the 403 above. The fixes are article 30's: annotate the entry method, or move the guarded call to another bean.
When a catch-all advice turns a 403 into a 500
A URL-rule denial is thrown inside AuthorizationFilter, where ExceptionTranslationFilter catches it and calls the access denied handler. A method-security denial is different: the proxy runs inside DispatcherServlet, so its AuthorizationDeniedException travels out through Spring MVC like any controller exception, and article 20's catch-all sees it first:
@ExceptionHandler(Exception.class)
public ProblemDetail handleUnexpected(Exception ex, HttpServletRequest request) {
log.error("Unhandled exception on {} {}", request.getMethod(), request.getRequestURI(), ex);
ProblemDetail problem = ProblemDetail.forStatusAndDetail(
HttpStatus.INTERNAL_SERVER_ERROR, "An unexpected error occurred.");
problem.setTitle("Internal Server Error");
return problem;
}With that advice present and nothing else, alice asking for admin's orders got a 500, not a 403:
HTTP/1.1 500
Content-Type: application/problem+json
{"detail":"An unexpected error occurred.","instance":"/api/orders","status":500,"title":"Internal Server Error"}AuthorizationDeniedException is an AccessDeniedException, and the catch-all matched it as an Exception, logging it as a bug and hiding the real 403. The fix is one handler that hands the exception back to the security chain, where the access denied handler writes the 403 the URL rules already produce:
import org.springframework.security.access.AccessDeniedException;
@ExceptionHandler(AccessDeniedException.class)
public void rethrowAccessDenied(AccessDeniedException ex) {
throw ex;
} Rethrown, the exception leaves DispatcherServlet, ExceptionTranslationFilter catches it, and the same ProblemDetailSecurityHandler writes the 403. After the change every method-security denial answered with the 403 ProblemDetail shown above, and the log recorded no unhandled exception.
Role hierarchy: ADMIN implies USER
admin has ROLE_ADMIN and nothing else, so a rule written for USER refuses it. If placing an order needed hasRole('USER'), admin could not order. A RoleHierarchy bean says one role implies another:
import org.springframework.security.access.hierarchicalroles.RoleHierarchy;
import org.springframework.security.access.hierarchicalroles.RoleHierarchyImpl;
@Bean
static RoleHierarchy roleHierarchy() {
return RoleHierarchyImpl.withDefaultRolePrefix()
.role("ADMIN").implies("USER")
.build();
} With a /api/orders/** rule of hasRole('USER'), admin placed an order and got 201, though the token still carries only ROLE_ADMIN. The hierarchy is applied when a rule is checked, not by adding authorities: admin's /api/me still listed ["ROLE_ADMIN","FACTOR_BEARER"].
Does the hierarchy reach @PreAuthorize?
It reaches both. The role probe, run with the hierarchy bean, answered "hasRole('USER')":"allowed" for admin; without the bean the same call was "denied". So one bean governs URL rules and @PreAuthorize alike, and there is no need to list every implied role in a rule. alice, who is not an admin, gained nothing: ROLE_USER does not imply ROLE_ADMIN, and her admin-only calls stayed 403.
URL rules vs method security
Both check authorization, but at different points and with different information. The table decides which to reach for.
URL rule in authorizeHttpRequests | @PreAuthorize / @PostAuthorize | |
|---|---|---|
| Checked by | AuthorizationFilter, before DispatcherServlet | a method proxy, inside Spring MVC |
| Runs when | every request matching the path | every call to the annotated method (except self-invocation) |
| Can see | HTTP method and path | method arguments, and with @PostAuthorize the return value |
| Cannot see | arguments or return value | requests that never reach the method |
| A denial is written by | the accessDeniedHandler on ExceptionTranslationFilter | thrown into Spring MVC; needs to reach the access denied handler, not a catch-all |
RoleHierarchy | applies | applies |
| Best for | coarse rules by URL shape: public reads, admin writes | rules about the data: ownership, per-record checks |
Use URL rules for the broad shape of the API and method security for rules that depend on the data. They stack: a request passes the URL rule first, then the method rule.
CORS: the same-origin policy and preflight requests
A browser lets a page read a response from another origin only if that origin allows it. Origin is scheme, host and port together, so a page served from http://localhost:8146 calling the API on http://localhost:8136 is cross-origin. For a request that can change data or carries custom headers, the browser first sends a preflight: an OPTIONS with Origin, Access-Control-Request-Method and Access-Control-Request-Headers, asking whether the real request is allowed. The server answers with Access-Control-Allow-* headers or the browser blocks the real request.
The preflight without CORS configuration
The preflight is a real request through the security chain. With no CORS configuration, the catalogue's OPTIONS /api/products is unauthenticated and the API chain answers 401 before any CORS header is added:
curl -i -s -X OPTIONS -H 'Origin: http://localhost:8146' -H 'Access-Control-Request-Method: POST' -H 'Access-Control-Request-Headers: authorization,content-type' http://localhost:8136/api/productsHTTP/1.1 401
WWW-Authenticate: Bearer realm="catalogue", resource_metadata="http://localhost:8136/.well-known/oauth-protected-resource"
Content-Type: application/problem+json
{"detail":"Valid credentials are required to access this resource.","instance":"/api/products","status":401,"title":"Unauthorized"}A page proves what the browser makes of that. Served on port 8146, it fetches the API with admin's token:
<script>
const token = new URLSearchParams(location.hash.slice(1)).get('token');
fetch('http://localhost:8136/api/products', {
method: 'POST',
headers: {
'Authorization': 'Bearer ' + token,
'Content-Type': 'application/json'
},
body: JSON.stringify({ sku: 'HB-001', name: 'USB-C hub', price: 350000 })
})
.then(response => response.json().then(body => console.log(response.status, JSON.stringify(body))))
.catch(error => console.log('fetch failed:', error.message));
</script>Headless Chrome sent the preflight, got the 401 with no Access-Control-Allow-Origin, never sent the POST, and logged:
Access to fetch at 'http://localhost:8136/api/products' from origin 'http://localhost:8146' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource.
fetch failed: Failed to fetchConfiguring CORS on Spring MVC alone, with @CrossOrigin or addCorsMappings, does not fix this. Spring Security runs before DispatcherServlet, and the preflight is rejected in the filter chain before MVC's CORS handling is reached: with only addCorsMappings("/api/**"), the same preflight still answered 401, even though a simple GET that did reach a controller came back with Access-Control-Allow-Origin. CORS has to be handled inside the security chain.
Configuring CORS in Spring Security
http.cors(...) adds a CorsFilter to the chain, ahead of authentication, and it reads a CorsConfigurationSource bean:
.oauth2ResourceServer(oauth2 -> oauth2
.jwt(Customizer.withDefaults())
.authenticationEntryPoint(problemHandler))
.cors(Customizer.withDefaults())
.exceptionHandling(exceptions -> exceptions @Bean
CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration config = new CorsConfiguration();
config.setAllowedOrigins(List.of("http://localhost:8146"));
config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE"));
config.setAllowedHeaders(List.of("Authorization", "Content-Type"));
config.setMaxAge(3600L);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/api/**", config);
return source;
}The preflight now short-circuits with the allow headers, before authentication:
HTTP/1.1 200
Access-Control-Allow-Origin: http://localhost:8146
Access-Control-Allow-Methods: GET,POST,PUT,DELETE
Access-Control-Allow-Headers: authorization, content-type
Access-Control-Max-Age: 3600The browser then sent the real POST with admin's bearer token, and its console logged 201 with the created product; the response carried Access-Control-Allow-Origin: http://localhost:8146. A preflight from http://evil.example, an origin not in the list, was answered 403 with the body Invalid CORS request. Two details worth knowing: the CorsFilter only decides whether the browser may read the response, it is not authorization, so the real request still passed through the JWT rules; and a UrlBasedCorsConfigurationSource bean is picked up even without the explicit http.cors(...) line, because HttpSecurityConfiguration calls applyCorsIfAvailable when such a bean exists. Writing http.cors(Customizer.withDefaults()) states the intent and works with any CorsConfigurationSource type.
allowedOrigins("*") with allowCredentials(true)
The * wildcard for origins looks convenient and breaks the moment credentials are involved. With setAllowedOrigins(List.of("*")) and setAllowCredentials(true), the preflight failed at request time:
java.lang.IllegalArgumentException: When allowCredentials is true, allowedOrigins cannot contain the special value "*" since that cannot be set on the "Access-Control-Allow-Origin" response header. To allow credentials to a set of origins, list them explicitly or consider using "allowedOriginPatterns" instead.Access-Control-Allow-Origin: * and Access-Control-Allow-Credentials: true are forbidden together by the CORS spec, and Spring refuses to emit them. When origins must be matched by pattern, allowedOriginPatterns is the credentialed alternative:
config.setAllowedOrigins(List.of("*"));
config.setAllowedOriginPatterns(List.of("http://localhost:[*]"));
config.setAllowCredentials(true); With a pattern, the preflight echoed the caller's own origin and added the credentials header:
HTTP/1.1 200
Access-Control-Allow-Origin: http://localhost:8146
Access-Control-Allow-Methods: GET,POST,PUT,DELETE
Access-Control-Allow-Headers: authorization, content-type
Access-Control-Allow-Credentials: true
Access-Control-Max-Age: 3600The bearer API does not need allowCredentials, since the token rides in a header rather than a cookie. It matters for the session chain of the CSRF section, where the browser must be allowed to send the cookie.
CSRF: cross-site request forgery
A browser attaches a site's cookies to every request to that site, whoever caused the request. So if a user is logged in with a session cookie, another site can put a form or a fetch on its own page that posts to your application, and the browser sends the user's cookie with it: the request is authenticated although the user never meant to make it. That is CSRF. The defence is a token the other site cannot read, required on every state-changing request and checked against one stored for the session; the attacker's page has the cookie but not the token.

The attack on a session-based chain
The catalogue's second chain, the web chain from article 33, serves a small Thymeleaf account page behind form login, with a session cookie and CSRF protection on by default. Logged in as alice in the browser, an attacker page on port 8146 auto-submits a form to the account's email endpoint:
<form id="prize" action="http://localhost:8136/account/email" method="post">
<input type="hidden" name="email" value="mallory@evil.example">
</form>
<script>
document.getElementById('prize').submit();
</script>The browser attached alice's JSESSIONID to the cross-site POST, but the form had no CSRF token, and Spring Security refused it. The server logged Invalid CSRF token found for http://localhost:8136/account/email and answered 403; the email was unchanged afterwards. The real status is worth pinning down, because article 33 showed the ERROR dispatch can rewrite a security status: here it did not. AccessDeniedHandlerImpl called sendError(403), the ERROR dispatch to /error carried alice's session cookie, so /error rendered as an authenticated request and kept the 403. A curl walk-through of the legitimate flow shows the token that was missing: GET /login and then GET /account each carry a hidden _csrf field of 96 characters, and a POST /account/email without it is 403 while the same post with it is 302 to /account?updated.
⚠️ Disabling CSRF on a chain that authenticates from a cookie reopens exactly this hole. With
csrf.disable()on the web chain, the attacker page above changedalice's email tomallory@evil.examplewith no token at all. Keep CSRF on for anything that logs in with a cookie.
A JavaScript client with csrf.spa()
A hidden form field suits server-rendered pages; a JavaScript client on a session needs the token in a place it can read and send. Spring Security 7.1.1 packages this as csrf.spa():
@Bean
@Order(2)
SecurityFilterChain webSecurityFilterChain(HttpSecurity http) {
http
.authorizeHttpRequests(auth -> auth.anyRequest().authenticated())
.formLogin(Customizer.withDefaults())
.csrf(csrf -> csrf.spa());
return http.build();
}csrf.spa() stores the token in a cookie named XSRF-TOKEN that JavaScript can read (it is not HttpOnly) and expects it back in the X-XSRF-TOKEN header. After form login, the cookie was set, and a script PUT that echoed it into the header succeeded:
curl -i -s -b cookies.txt -X PUT -H 'Content-Type: application/json' -H "X-XSRF-TOKEN: $XSRF" -d '{"email":"alice@wonderland.example"}' http://localhost:8136/account/emailHTTP/1.1 200
Content-Type: application/json
{"id":1,"username":"alice","email":"alice@wonderland.example","role":"USER"}The same PUT without the X-XSRF-TOKEN header was 403. The attacker's page cannot read the XSRF-TOKEN cookie, because the same-origin policy stops it reading a response or a cookie from another origin, so it cannot supply the header.
Why the JWT API can disable CSRF
The API chain sets csrf.disable(), and that is safe precisely because nothing about the API is attached automatically. The JWT travels in the Authorization: Bearer … header, and a browser never adds that header to a cross-site request on its own; the attacker's page would have to know the token and set it, and if it knew the token the game is already lost. The attacker page posting to POST /api/products/1 from another origin arrived with no Authorization header and got the API's 401. There is no ambient credential to forge, so there is nothing for a CSRF token to protect.
This stops being true the moment the token is moved into a cookie the browser sends by itself. A JWT stored in a cookie is attached to every cross-site request exactly like a session id, and the API is back in the first column of the diagram: CSRF applies again, and the chain must protect against it. Keep the token in the Authorization header, or turn CSRF protection back on.
What the API now enforces
Chapter 5 turned an open catalogue into an authenticated, authorized API. Where each control lives:
- Who is calling — a JWT validated by the resource server; a missing or bad token is 401 with a
Bearerchallenge, written as aProblemDetail. - Coarse rules by URL —
authorizeHttpRequests: public product reads,ROLE_ADMINfor product writes and/api/admin/**, authentication for orders. Checked inAuthorizationFilter. - Rules about the data —
@PreAuthorizeand@PostAuthorizeon the service, behind@EnableMethodSecurity: ownership on orders, admin-only cancellation. Denials must reach the access denied handler, not a catch-all advice. - Role relationships — one
RoleHierarchybean, applied to both URL rules and method security. - Cross-origin access —
http.cors(...)with aCorsConfigurationSource, so the browser preflight is answered inside the security chain. - CSRF — disabled on the stateless bearer API because there is no ambient credential; kept on, with
csrf.spa(), for the cookie-session web chain.
FAQ
What is the difference between hasRole and hasAuthority in Spring Security?
hasRole("ADMIN") prepends ROLE_ and checks the authority ROLE_ADMIN; hasAuthority("ROLE_ADMIN") checks that exact string. They are equivalent for a role. hasAuthority("ADMIN") checks ADMIN, which no role holder has, so it silently denies every admin. Because Spring Security stores roles as ROLE_-prefixed authorities, use hasRole for roles and keep hasAuthority for non-role authorities.
Do I need @EnableMethodSecurity in Spring Boot 4?
Yes. Neither Spring Boot 4.1.1 nor Spring Security 7.1.1 turns method security on by default, so @PreAuthorize and @PostAuthorize are ignored until a @Configuration class carries @EnableMethodSecurity. In a run without it, a @PreAuthorize("hasRole('ADMIN')") method let a ROLE_USER caller through. The Spring Security 5 annotation @EnableGlobalMethodSecurity was removed.
Why does my @PreAuthorize method not block anything when called from the same class?
Method security is a proxy, like @Transactional. A call to an annotated method from another method of the same bean is this.method(...), a plain Java call that does not pass through the proxy, so the check never runs. A summary method calling this.findByCustomer(...) returned another user's data. Annotate the method that is called from outside, or move the guarded call to a separate bean.
Why does an @PreAuthorize denial return 500 instead of 403?
Because a catch-all @ExceptionHandler(Exception.class) caught it. A method-security denial throws AuthorizationDeniedException, an AccessDeniedException, inside Spring MVC, and a catch-all matches it as an Exception and returns 500. Add an @ExceptionHandler(AccessDeniedException.class) that rethrows the exception; it then leaves DispatcherServlet, ExceptionTranslationFilter catches it, and the access denied handler writes the 403.
Why is my CORS configuration ignored when Spring Security is enabled?
Because Spring Security runs before Spring MVC. A preflight OPTIONS is rejected in the filter chain before MVC's @CrossOrigin or addCorsMappings is reached, so a page saw blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present. Add http.cors(...) and a CorsConfigurationSource bean so the CorsFilter handles the preflight inside the chain.
Is it safe to disable CSRF for a REST API?
For a stateless API that authenticates with a bearer token in the Authorization header, yes: the browser never attaches that header to a cross-site request, so there is no ambient credential to forge. It stops being safe if the token is stored in a cookie the browser sends by itself, or if the same application authenticates anything from a session cookie; those need CSRF protection, with csrf.spa() for a JavaScript client.
Conclusion
Authorization in Spring Security is authorities checked in two places. URL rules in authorizeHttpRequests run in AuthorizationFilter and see only the method and path; @PreAuthorize and @PostAuthorize, once @EnableMethodSecurity is on, run in a proxy and see arguments and return values, which is what an ownership rule needs. Roles are ROLE_-prefixed authorities, hasRole adds the prefix, and a RoleHierarchy bean lets one role imply another for both kinds of rule. The traps repeat what earlier chapters taught: a proxy is bypassed by self-invocation, and a catch-all advice can turn a filter-shaped 403 into a 500 unless it hands the exception back to the security chain.
CORS and CSRF are about the browser, not the API's own logic. The same-origin policy makes a cross-origin call send a preflight, which Spring Security must answer inside its chain with http.cors(...) and a CorsConfigurationSource, since an MVC-only configuration is rejected before MVC runs. CSRF exists because the browser attaches cookies by itself, so a cookie-session chain needs a token, delivered to a JavaScript client through the XSRF-TOKEN cookie and the X-XSRF-TOKEN header, while a bearer-token API can disable it because nothing is attached automatically.
That closes Chapter 5 and the security arc. Chapter 6 opens with testing: the next article writes unit tests for the service layer with JUnit 5, AssertJ and Mockito.