Spring Method Security: @PreAuthorize and @PostAuthorize

Introduction

This tutorial moves security rules from URLs to the methods of your services, with Spring Security's method security. We will see when URL rules are not enough, switch method security on with @EnableMethodSecurity, and protect a document service with @PreAuthorize, using roles, method parameters and a bean of our own in the expressions. We will check results with @PostAuthorize, filter lists with @PostFilter, and compare the older @Secured. A program then calls the service as four different users and prints what each call returns or why it was refused.

Why method security

URL rules, from Configuring SecurityFilterChainClick to read tutorial: Configuring SecurityFilterChain in Spring Security, decide by address: "only editors may POST to /api/books". Many real rules depend on the data: "a user may read their own documents", "an editor may change any document except a confidential one". The URL /documents/3 does not say who owns document 3. Method security puts the rule on the service method, where the arguments and the result are known, and it protects the method however it is called - from a controller, a scheduled job or a message listener.

@EnableMethodSecurity on a configuration class switches it on. Like @Transactional in the transactions tutorialClick to read tutorial: Spring @Transactional: Propagation and Isolation, it works through a proxy around the bean, which checks the rules before and after each call - Spring method security - a proxy checks @PreAuthorize before the method runs and @PostAuthorize after it, using the arguments or the result, and throws AuthorizationDeniedException when a rule fails

The example

A document has an owner and may be confidential. Three documents are kept in memory: Ann's notes, Raj's confidential salary review, and Raj's team handbook -
Document.java - a document with an owner
//Document.java
package com.javabrahman.security.methods;

public record Document(long id, String owner, String title, boolean confidential) {
}
DocumentPolicy.java - a bean that security expressions can call
//DocumentPolicy.java
package com.javabrahman.security.methods;

import org.springframework.security.core.Authentication;
import org.springframework.stereotype.Component;

@Component("documentPolicy")
public class DocumentPolicy {
  private final DocumentStore store;

  public DocumentPolicy(DocumentStore store) {
    this.store = store;
  }

  // the owner may edit, and so may an editor - but nobody may edit a confidential document except its owner
  public boolean canEdit(long id, Authentication user) {
    Document doc = store.get(id);
    boolean editor = user.getAuthorities().stream().anyMatch(a -> a.getAuthority().equals("ROLE_EDITOR"));
    return doc.owner().equals(user.getName()) || (editor && !doc.confidential());
  }
}
DocumentStore.java - three documents in memory
//DocumentStore.java
package com.javabrahman.security.methods;

import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
import org.springframework.stereotype.Component;

@Component
public class DocumentStore {
  private final Map<Long, Document> docs = new TreeMap<>(Map.of(
      1L, new Document(1, "ann", "Ann's notes", false),
      2L, new Document(2, "raj", "Raj's salary review", true),
      3L, new Document(3, "raj", "Team handbook", false)));

  public Document get(long id) {
    return docs.get(id);
  }

  public List<Document> all() {
    return new ArrayList<>(docs.values());
  }

  public void put(Document doc) {
    docs.put(doc.id(), doc);
  }
}
The rules live on the service. Each annotation holds a Spring Expression Language expression (see SpEL and @ValueClick to read tutorial: Spring Expression Language (SpEL) and @Value Explained) evaluated with security-specific variables: authentication is the current user, #owner and #id are method parameters by name, returnObject is the result, filterObject each element of a returned collection, and @documentPolicy a bean by its name. Functions such as hasRole(), hasAuthority() and isAuthenticated() are the same as in URL rules -
DocumentService.java - the rules, on the methods themselves
//DocumentService.java
package com.javabrahman.security.methods;

import java.util.List;
import org.springframework.security.access.annotation.Secured;
import org.springframework.security.access.prepost.PostAuthorize;
import org.springframework.security.access.prepost.PostFilter;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.stereotype.Service;

@Service
public class DocumentService {
  private final DocumentStore store;

  public DocumentService(DocumentStore store) {
    this.store = store;
  }

  @PreAuthorize("isAuthenticated()")
  public int count() {
    return store.all().size();
  }

  @PreAuthorize("#owner == authentication.name")          // a method parameter in the expression
  public List<Document> byOwner(String owner) {
    return store.all().stream().filter(d -> d.owner().equals(owner)).toList();
  }

  @PostAuthorize("returnObject.owner == authentication.name or hasRole('ADMIN')")   // checks the result
  public Document find(long id) {
    System.out.println("   (find(" + id + ") ran)");
    return store.get(id);
  }

  @PostFilter("!filterObject.confidential or filterObject.owner == authentication.name")
  public List<Document> all() {                                                     // removes elements
    return store.all();
  }

  @PreAuthorize("@documentPolicy.canEdit(#id, authentication)")                    // calls a bean
  public Document rename(long id, String title) {
    Document old = store.get(id);
    Document renamed = new Document(id, old.owner(), title, old.confidential());
    store.put(renamed);
    return renamed;
  }

  @Secured("ROLE_ADMIN")                                                            // the older annotation
  public void purge() {
    System.out.println("   (purge ran)");
  }
}
The program has no web layer. @EnableMethodSecurity(securedEnabled = true) switches on the pre- and post- annotations, which are on by default, and @Secured, which is not. To act as a user, the program puts an authenticated token into the SecurityContextHolder itself - the same thing a login filter does in a web application, and what test support such as @WithMockUser does in tests -
MethodSecurityApp.java - calling the service as different users
//MethodSecurityApp.java
package com.javabrahman.security.methods;

import java.util.List;
import java.util.function.Supplier;
import org.springframework.boot.CommandLineRunner;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.core.authority.AuthorityUtils;
import org.springframework.security.core.context.SecurityContextHolder;

@SpringBootApplication
@EnableMethodSecurity(securedEnabled = true)
public class MethodSecurityApp {

  @Bean
  CommandLineRunner demo(DocumentService docs) {
    return args -> {
      System.out.println("== nobody logged in");
      call("count()", docs::count);

      as("ann", "ROLE_USER");
      System.out.println("== ann (USER)");
      call("count()", docs::count);
      call("byOwner(\"ann\")", () -> titles(docs.byOwner("ann")));
      call("byOwner(\"raj\")", () -> titles(docs.byOwner("raj")));
      call("find(1)", () -> docs.find(1).title());
      call("find(3)", () -> docs.find(3).title());
      call("all()", () -> titles(docs.all()));
      call("rename(3, ...)", () -> docs.rename(3, "Handbook v2").title());
      call("purge()", () -> { docs.purge(); return "done"; });

      as("eve", "ROLE_USER", "ROLE_EDITOR");
      System.out.println("== eve (USER, EDITOR)");
      call("rename(3, ...)", () -> docs.rename(3, "Handbook v2").title());
      call("rename(2, ...)", () -> docs.rename(2, "Salaries").title());

      as("max", "ROLE_USER", "ROLE_ADMIN");
      System.out.println("== max (USER, ADMIN)");
      call("find(3)", () -> docs.find(3).title());
      call("all()", () -> titles(docs.all()));
      call("purge()", () -> { docs.purge(); return "done"; });
    };
  }

  private static void as(String name, String... roles) {
    SecurityContextHolder.getContext().setAuthentication(
        UsernamePasswordAuthenticationToken.authenticated(name, null, AuthorityUtils.createAuthorityList(roles)));
  }

  private static void call(String what, Supplier<Object> work) {
    try {
      System.out.println(what + " -> " + work.get());
    } catch (RuntimeException e) {
      System.out.println(what + " -> " + e.getClass().getSimpleName() + ": " + e.getMessage());
    }
  }

  private static List<String> titles(List<Document> list) {
    return list.stream().map(Document::title).toList();
  }

  public static void main(String[] args) {
    SpringApplication.run(MethodSecurityApp.class, args);
  }
}
 OUTPUT of the above code
== nobody logged in
count() -> AuthenticationCredentialsNotFoundException: An Authentication object was not found in the SecurityContext
== ann (USER)
count() -> 3
byOwner("ann") -> [Ann's notes]
byOwner("raj") -> AuthorizationDeniedException: Access Denied
   (find(1) ran)
find(1) -> Ann's notes
   (find(3) ran)
find(3) -> AuthorizationDeniedException: Access Denied
all() -> [Ann's notes, Team handbook]
rename(3, ...) -> AuthorizationDeniedException: Access Denied
purge() -> AuthorizationDeniedException: Access Denied
== eve (USER, EDITOR)
rename(3, ...) -> Handbook v2
rename(2, ...) -> AuthorizationDeniedException: Access Denied
== max (USER, ADMIN)
   (find(3) ran)
find(3) -> Handbook v2
all() -> [Ann's notes, Handbook v2]
   (purge ran)
purge() -> done

Explanation of the output: @PreAuthorize

  • Nobody logged in: count() failed with AuthenticationCredentialsNotFoundException, "An Authentication object was not found in the SecurityContext". Outside a web request there is no anonymous user, unless something puts one there.
  • Ann may count (any authenticated user may), and byOwner("ann") returned her notes. byOwner("raj") was refused with AuthorizationDeniedException: Access Denied: the expression #owner == authentication.name compared the argument "raj" with her name. Parameter names in expressions need the class compiled with -parameters, which Spring Boot's Maven and Gradle plugins configure; @P("owner") on the parameter names it explicitly otherwise.
  • The custom bean: rename(3, ...) called documentPolicy.canEdit(3, authentication). Ann neither owns document 3 nor is an editor: refused. Eve is an editor, and document 3 is not confidential: allowed, the title became "Handbook v2". Document 2 is confidential, so even Eve was refused. A rule this specific is much clearer as Java code in a bean than as one long expression, and the bean can be unit-tested on its own.
  • @Secured("ROLE_ADMIN") on purge() refused Ann and allowed Max, the admin. It accepts only role and authority names, no expressions; @PreAuthorize("hasRole('ADMIN')") does the same and more, and is the usual choice. The JSR-250 annotations, @RolesAllowed and @PermitAll, are available too with jsr250Enabled = true.

Explanation of the output: @PostAuthorize and @PostFilter

  • find(1) as Ann: the method ran - "(find(1) ran)" - and the result, her own document, passed returnObject.owner == authentication.name.
  • find(3) as Ann: the method also ran, and only then was its result refused, because document 3 belongs to Raj. Who owns a document can only be known after loading it, which is what @PostAuthorize is for. But as the diagram warns, anything the method did - writing an audit row, sending a message - has already happened. Use it on read-only methods.
  • find(3) as Max passed through or hasRole('ADMIN').
  • all() does not refuse; @PostFilter removes the elements for which the expression is false. Ann saw "Ann's notes" and "Team handbook", not the confidential review. So did Max: the filter lets only the owner see a confidential document, and has no exception for admins. Rules mean exactly what they say, which is a good reason to test them. @PostFilter filters in memory after loading everything, so for large tables filter in the query instead.

Method security in practice

  • Use both layers: URL rules for broad areas ("/admin needs ADMIN"), method rules for decisions that depend on data. A forgotten URL rule is then still covered by the method rule.
  • In a web application, an AuthorizationDeniedException thrown from a controller's call is turned into a 403 by Spring Security's ExceptionTranslationFilter, like a refused URL rule.
  • The proxy rules apply: annotations on private methods, or on methods called through this from the same class, are not checked.
  • Meta-annotations keep expressions in one place: annotate your own @IsDocumentOwner annotation with the @PreAuthorize, and use it on the methods.
  • Test the rules, for example with @WithMockUser(roles = "EDITOR") from spring-security-test, covered in the testing module of this series.

Summary

In this tutorial we protected service methods instead of URLs. @EnableMethodSecurity wraps beans in a proxy that evaluates SpEL expressions before and after each call. @PreAuthorize refused calls before they ran - by authentication, by comparing a parameter with the user's name, and through a policy bean called from the expression. @PostAuthorize checked the result after the method had run, which the output made visible, and @PostFilter removed elements from a returned list. @Secured offered a simpler role check. Calls without any authentication failed with AuthenticationCredentialsNotFoundException, and refused calls with AuthorizationDeniedException. The next tutorial explains three protections that work in the browser: CSRF, CORS and security headersClick to read tutorial: Spring Security CSRF, CORS and Security Headers Explained.