Method security, CORS and CSRF
@PreAuthorize with SpEL, role hierarchies, CORS configured on purpose, and the CSRF decision for a stateless API.
URL rules protect paths. A lot of what you want to protect is not a path — it is a method, called from several places, operating on an object whose owner decides the answer. That is what method security is for, and this lesson also covers the two browser mechanisms that confuse people most.
@PreAuthorize, and where the check belongs
Turn it on, then annotate:
@Configuration
@EnableGlobalMethodSecurity(prePostEnabled = true) // Spring Security 5
public class SecurityConfig { ... }
@PreAuthorize("hasRole('ADMIN')")
@GetMapping("/method/hello")
public String guarded() { return "guarded by an annotation"; }/method/hello as ana (ROLE_USER) -> HTTP 403
/method/hello as root (ROLE_ADMIN) -> HTTP 200Same 403 as a URL rule, from a different mechanism: a proxy around the bean, which is the same machinery as @Transactional — and it has the same blind spot. A call from inside the same class does not go through the proxy, so the annotation does nothing. Private and final methods likewise.
The reason to use it is that it goes where a filter cannot:
@PreAuthorize("hasRole('ADMIN') or #order.customerId == authentication.principal.customerId")
public void cancel(Order order) { ... }That expression names the argument. No URL rule can express it, because a filter does not know which order. #order is the parameter, authentication is the current Authentication, and @beanName.method(...) lets you call your own code for anything more complicated.
Three relatives worth knowing:
@PostAuthorizechecks the return value —returnObject.ownerId == authentication.name. Useful, and remember the method already ran: any side effect has happened, and any lazy loading has happened too.@PreFilter/@PostFilterremove elements from a collection. Convenient and quietly expensive:@PostFilterfetches everything and then discards, so filtering a thousand rows down to three loads a thousand. Filter in the query, as the authorisation lesson argued.@Secured/@RolesAllowedare the older, simpler forms — roles only, no expressions.
CORS is the browser's rule, not your security
A browser stops JavaScript on https://app.example.com reading a response from https://api.example.com unless the API says it is allowed. That is the same-origin policy, and CORS is how you make an exception to it.
Two things follow that people get backwards:
- CORS is enforced by the browser, so it protects your users, not your API.
curlignores all of it, which is why an API still needs authentication and authorisation exactly as before. - A blocked request usually was sent and did run. For simple requests the browser makes the call and then refuses to let the script read the answer. Blocking before the request is what the preflight is for.
For anything that is not simple — a custom header, a PUT, JSON with Content-Type: application/json — the browser first sends a preflight:
OPTIONS /private/hello
Origin: https://app.example.com
Access-Control-Request-Method: GETConfigured correctly, the answer permits it:
HTTP/1.1 200
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: GET,POST
Access-Control-Allow-Credentials: trueAnd an origin you did not allow is refused, with no CORS headers at all:
HTTP/1.1 403The CORS configuration that silently does nothing
This is worth its own section because the failure is invisible. In Spring Security, http.cors() looks for a CorsConfigurationSource bean by name. Name the bean anything else and it is never used:
@Bean CorsConfigurationSource corsSource() { ... } // ignored
@Bean CorsConfigurationSource corsConfigurationSource() { ... } // usedWith the wrong name, the preflight above returned 403, the real request returned 200 with no Access-Control-Allow-Origin header, and nothing in any log said why. The browser then reports a generic CORS error and the search begins in the wrong place.
When CORS appears not to work, check the bean name before anything else.
Two more settings that matter:
allowCredentials(true)cannot be combined withallowedOrigins("*"). The specification forbids it and the browser will refuse. Name the origins.allowedOriginPatternsexists for wildcards within a domain and is the escape hatch when you genuinely needhttps://*.example.com. Use it deliberately;*with credentials is the configuration that turns a CORS policy into no policy.
CSRF, and the decision for an API
CSRF is: a user is logged into your site; another site causes their browser to send a request to yours; the browser attaches the cookie automatically; your server sees a valid session and acts.
The whole attack depends on credentials the browser sends without being asked — cookies, and HTTP basic. That gives you the rule:
- Session cookies → CSRF protection on. A browser form app needs it.
- A token in an
Authorizationheader → not needed. The browser does not attach that header on its own; the attacking page would have to read the token and add it, and if it can do that you have XSS, not CSRF.
Which is why csrf().disable() next to SessionCreationPolicy.STATELESS is coherent rather than lazy: one says no cookie carries authentication here, the other says so there is nothing for CSRF to exploit. Disabling CSRF while still using session cookies is the version that is actually dangerous.
When you do need it, Spring's default is a synchroniser token that the server issues and the client returns in a header. For a JavaScript front end the cookie-based repository is the usual arrangement:
http.csrf().csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse());withHttpOnlyFalse() is deliberate and often misread: the CSRF token cookie is meant to be readable by your own JavaScript, because the client's job is to copy it into a header. That is safe — an attacker's page cannot read a cookie from your domain — and it is the difference between the CSRF cookie and the session cookie, which must stay HttpOnly.
SameSite is the modern half of this. SameSite=Lax on the session cookie stops it being sent on most cross-site requests, which removes much of the attack surface at the browser. It is a good defence and not a complete one — older browsers, and GET navigations under Lax — so it complements a CSRF token rather than replacing it.