Method security, CORS and CSRF

@PreAuthorize with SpEL, role hierarchies, CORS configured on purpose, and the CSRF decision for a stateless API.

6 min read🔐 Spring Security

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:

java
@Configuration
@EnableGlobalMethodSecurity(prePostEnabled = true)   // Spring Security 5
public class SecurityConfig { ... }
 
@PreAuthorize("hasRole('ADMIN')")
@GetMapping("/method/hello")
public String guarded() { return "guarded by an annotation"; }
plaintext
/method/hello as ana  (ROLE_USER)  -> HTTP 403
/method/hello as root (ROLE_ADMIN) -> HTTP 200

Same 403 as a URL rule, from a different mechanism: a proxy around the bean, which is the same machinery as @Transactionaland 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:

java
@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:

  • @PostAuthorize checks the return valuereturnObject.ownerId == authentication.name. Useful, and remember the method already ran: any side effect has happened, and any lazy loading has happened too.
  • @PreFilter / @PostFilter remove elements from a collection. Convenient and quietly expensive: @PostFilter fetches 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 / @RolesAllowed are 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. curl ignores 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:

plaintext
OPTIONS /private/hello
Origin: https://app.example.com
Access-Control-Request-Method: GET

Configured correctly, the answer permits it:

plaintext
HTTP/1.1 200
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: GET,POST
Access-Control-Allow-Credentials: true

And an origin you did not allow is refused, with no CORS headers at all:

plaintext
HTTP/1.1 403

The 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:

java
@Bean CorsConfigurationSource corsSource() { ... }            // ignored
@Bean CorsConfigurationSource corsConfigurationSource() { ... } // used

With 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 with allowedOrigins("*"). The specification forbids it and the browser will refuse. Name the origins.
  • allowedOriginPatterns exists for wildcards within a domain and is the escape hatch when you genuinely need https://*.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 Authorization header → 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:

java
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.

Progress is saved on this device and to your account when signed in.