🇪🇸Enhancements of Authorization Lab
🎯 Objective
Learn about the latest innovations in Authorization features of Spring Security. Details on Spring Security Authorization can be found in the Spring Security Reference Documentation.

This implements a well-known security principle called Defense in depth
Step 1: Get to know the provided application
The provided application is a simple Spring Boot application that demonstrates the latest Authorization features of Spring Security. The use case is a simple online banking application with bank accounts owned by different users.
This is the BankAccount
entity class that represents a bank account in the application (see Spring Data JPA Reference if you are not familiar with JPA and/or Spring Data JPA):
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
import jakarta.persistence.Entity;
import org.springframework.data.jpa.domain.AbstractPersistable;
import java.math.BigDecimal;
import java.util.Objects;
@JsonSerialize(as = BankAccount.class)
@Entity
public class BankAccount extends AbstractPersistable<Long> {
private String owner;
private String accountNumber;
private BigDecimal balance;
public BankAccount() {
}
public BankAccount(long id, String owner, String accountNumber, BigDecimal balance) {
this.owner = owner;
this.accountNumber = accountNumber;
this.balance = balance;
}
public String getOwner() {
return owner;
}
public String getAccountNumber() {
return accountNumber;
}
public BigDecimal getBalance() {
return balance;
}
// further code omitted
}
The application uses a simple in-memory database (H2) to store the bank accounts.
The BankAccount
entity class extends the AbstractPersistable
class, which provides a simple way to manage the entity's ID and version.
💡 if Spring Security 6.3 is used: As we will use Jackson (for JSON), we need to set the annotation
@JsonSerialize(as = BankAccount.class)
on top of the class. This is due to how Jackson works with CGLIB proxies (Spring Security needs proxies to make enhanced authorizations work). Otherwise, this may result in a serialization error like the following:com.fasterxml.jackson.databind.exc.InvalidDefinitionException: Direct self-reference leading to cycle
The application provides a simple REST API that allows users to create, view and update their bank accounts.
GET /api/accounts
Administrative Endpoint to get all existing accounts
GET /api/accounts/{id}
Retrieve a single account by its unique identifier
POST /api/accounts
Create a new account for currently authenticated user
POST /api/accounts/{id}
Increase the account balance by given amount
Here is the code for the REST API:
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/api/accounts")
public class BankAccountApi {
private final BankAccountService bankAccountService;
public BankAccountApi(BankAccountService bankAccountService) {
this.bankAccountService = bankAccountService;
}
@GetMapping
List<BankAccount> findAll() {
return bankAccountService.findAll();
}
@GetMapping("/{id}")
ResponseEntity<BankAccount> findById(@PathVariable("id") long id) {
BankAccount bankAccount = bankAccountService.findById(id);
return bankAccount != null ? ResponseEntity.ok(bankAccount) : ResponseEntity.notFound().build();
}
@PostMapping
BankAccount save(@RequestBody BankAccount toSave) {
return bankAccountService.save(toSave);
}
@PostMapping("/{id}")
ResponseEntity<String> update(@PathVariable("id") long id, @RequestBody BankAccount toUpdate) {
boolean updated = bankAccountService.update(id, toUpdate);
if (updated) {
return ResponseEntity.ok().build();
} else {
return ResponseEntity.status(HttpStatus.FORBIDDEN.value()).build();
}
}
}
The Rest API delegates all calls to the BankAccountService
class, which is responsible for the business logic and bridge to data access.
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
@Transactional(readOnly = true)
@Service
public class BankAccountService {
private final BankAccountRepository bankAccountRepository;
public BankAccountService(BankAccountRepository bankAccountRepository) {
this.bankAccountRepository = bankAccountRepository;
}
List<BankAccount> findAll() {
return bankAccountRepository.findAll();
}
BankAccount findById(long id) {
return bankAccountRepository.findById(id).orElse(null);
}
@Transactional
BankAccount save(BankAccount toSave) {
return bankAccountRepository.save(toSave);
}
@Transactional
boolean update(long id, BankAccount toUpdate) {
return bankAccountRepository.updateBankAccount(id, toUpdate.getBalance()) == 1;
}
}
The BankAccountService
class is annotated with @Transactional(readOnly = true)
to indicate that all methods are read-only by default. The save
and update
methods are annotated with @Transactional
to indicate that they are write operations.
Finally, we approach the data access layer.
The BankAccountRepository
class is a simple Spring Data JPA repository that provides CRUD operations for the BankAccount
entity.
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import java.math.BigDecimal;
public interface BankAccountRepository extends JpaRepository<BankAccount, Long> {
@Modifying
@Query("UPDATE BankAccount a SET a.balance = a.balance + :amount WHERE a.id = :id AND a.owner = ?#{principal?.username}")
int updateBankAccount(Long id, BigDecimal amount);
}
Please notice the updateBankAccount
method. It contains a custom query that updates the balance of a bank account only if the owner of the account matches the currently authenticated user.
This is basically done by using the ?#{principal?.username}
expression in the query.
This expression extension is enabled through the corresponding Spring Data Security extension defined in class WebSecurityConfiguration
.
import org.springframework.security.data.repository.query.SecurityEvaluationContextExtension;
// further code omitted
@Bean
public SecurityEvaluationContextExtension securityEvaluationContextExtension() {
return new SecurityEvaluationContextExtension();
}
And it also requires an additional dependency in the pom.xml
that is already included in the provided application:
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-data</artifactId>
</dependency>
Let's start the application with:
./mvnw spring-boot:run
and test the API with the following credentials:
user
secret
USER
admin
secret
USER, ADMIN
accountant
secret
USER, ACCOUNTANT
To test the application, you can use one of the provided HTTP client files in the requests
folder of the module:
api-call.http
: The IntelliJ Http clientapi-call.httpie
: The command line Httpie clientapi-call.curl
: The command line Curl client
What do you think about the responses of the different API calls? It looks like there are issues with the authorization of the API calls. So there is work to do!
But first let's have a look at the (new and enhanced) method authorization features in Spring Security.
Step 2: Get to know the new and enhanced method Authorization Features
An application can use Spring Security to secure the REST API and restrict access to the method (service) layer.
This is basically done by using annotations like @PreAuthorize
, @PostAuthorize
and @AuthorizeReturnObject
on the service methods or domain objects.
Summary of Annotations
@PreAuthorize
Before method call
Guard method based on roles or parameters
@PostAuthorize
After method call
Guard method based on return value
@PreFilter
Before method call
Filter input collections
@PostFilter
After method call
Filter returned collections
@AuthorizeReturnObject
After return (6.3+)
Object-level security on returned object
@HandleAuthorizationDenied
Interception (6.3+)
Custom handling of authorization failures
Annotation Details
✅ @PreAuthorize
Runs before the method and prevents execution if the expression is false.
@PreAuthorize("hasRole('ADMIN')")
public void deleteUser(String userId) {
//...
}
🧠Common use cases:
Role-based access
Ownership checks: @PreAuthorize("#userId == authentication.name")
✅ @PostAuthorize
Runs after the method and prevents return if the expression is false. This allows checking the returned object.
@PostAuthorize("returnObject.owner == authentication.name")
public Document getDocument(String docId) {
//...
return document;
}
🧠Use this when you need to inspect the returned domain object to enforce access control.
✅ @PreFilter
Filters collection input parameters before the method executes.
@PreFilter("filterObject.owner == authentication.name")
public void updateDocuments(List<Document> docs) {
//...
}
🧠filterObject is a special variable that refers to each item in the collection.
✅ @PostFilter
Filters the collection result after method execution.
@PostFilter("filterObject.visibility == 'public' or filterObject.owner == authentication.name")
public List<Document> findAll() {
//...
}
🧠This is useful when returning a list of domain objects, and you want to hide some from the caller.
✅ @AuthorizeReturnObject (Spring Security 6.3+)
A new alternative to @PostAuthorize, more structured and composable
@AuthorizeReturnObject
public Document findById(String id) {
//...
}
This can be combined with @PreAuthorize on parts of the domain object to enforce a bit more fine-grained object security.
@PreAuthorize("this.owner == authentication?.name")
public String getSecretDocumentDetails() {
return secretDocumentDetails;
}
✅ @HandleAuthorizationDenied (Spring Security 6.3+)
This allows you to intercept and customize the behavior when an authorization check fails at a method level
(e.g., in @PreAuthorize
, @AuthorizeReturnObject
, etc.).
@PreAuthorize("this.owner == authentication?.name")
@HandleAuthorizationDenied(handlerClass = MaskMethodAuthorizationDeniedHandler.class)
public String getSecretDocumentDetails() {
return secretDocumentDetails;
}
🧠This is useful when you want to mask or log the failure instead of throwing an exception.
In the next step we will now implement the new and enhanced method authorization features in the provided application.
Step 3: Add the new and enhanced Authorization Features to the application
Now it is the time to add the missing authorization to the application.
Spring Security Annotation Parameters
At first let's create some custom Authorization annotations to show the Annotations Parameters feature introduced in Spring Security 6.3. Later we will add these to the BankAccountService
class.
To make method-level authorization generally work, we need to add the @EnableMethodSecurity
annotation to the WebSecurityConfiguration
class. If you want to use Annotation Parameters, you have to opt in Templating Meta-Annotation Expressions, a new feature introduced in Spring Security 6.3. For this we have to publish an AnnotationTemplateExpressionDefaults
bean.
@EnableMethodSecurity
@EnableWebSecurity
@Configuration
public class WebSecurityConfiguration {
// other beans omitted
@Bean
public static AnnotationTemplateExpressionDefaults annotationTemplateExpressionDefaults() {
return new AnnotationTemplateExpressionDefaults();
}
// more beans omitted
}
The PreGetBankAccounts
and PreWriteBankAccount
annotations are custom security annotations that are used to restrict access to the methods based on the user's role and ownership of the bank account. Please create these two classes in the security
package.
PreGetBankAccounts:
import org.springframework.security.access.prepost.PreAuthorize;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
@PreAuthorize("hasRole('{role}')")
public @interface PreGetBankAccounts {
String role();
}
PreWriteBankAccount:
import org.springframework.core.annotation.AliasFor;
import org.springframework.security.access.prepost.PreAuthorize;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
@Retention(RetentionPolicy.RUNTIME)
@PreAuthorize("{account}?.owner == authentication?.name")
public @interface PreWriteBankAccount {
String value() default "#account";
@AliasFor(attribute = "value")
String account() default "#account";
}
Let us add these annotations to the BankAccountService
class:
import org.example.features.security.PreGetBankAccounts;
import org.example.features.security.PreWriteBankAccount;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
@Transactional(readOnly = true)
@Service
public class BankAccountService {
private final BankAccountRepository bankAccountRepository;
public BankAccountService(BankAccountRepository bankAccountRepository) {
this.bankAccountRepository = bankAccountRepository;
}
@PreGetBankAccounts(role = "ADMIN")
List<BankAccount> findAll() {
return bankAccountRepository.findAll();
}
BankAccount findById(long id) {
return bankAccountRepository.findById(id).orElse(null);
}
@PreWriteBankAccount("#toSave")
@Transactional
BankAccount save(BankAccount toSave) {
return bankAccountRepository.save(toSave);
}
@PreWriteBankAccount("#toUpdate")
@Transactional
boolean update(long id, BankAccount toUpdate) {
return bankAccountRepository.updateBankAccount(id, toUpdate.getBalance()) == 1;
}
}
✅ Explanation:
@PreGetBankAccounts(role = "ADMIN") is a custom-composed parameterized annotation built on top of @PreAuthorize
When
findAll()
is called, Spring Security will:Resolve the annotation parameter role = "ADMIN"
Evaluate the SpEL expression hasRole() → becomes hasRole('ADMIN')
Check if the currently authenticated user has the authority ROLE_ADMIN
If the check passes, proceed to call findAll()
Otherwise, throw an AccessDeniedException
@PreWriteBankAccount is a custom-composed parameterized annotation built on top of @PreAuthorize
Spring sees @PreWriteBankAccount("#toSave")
The expression #toSave is passed to the account attribute of the annotation
Inside the annotation, {account} is replaced with #toSave
The resulting @PreAuthorize becomes:
@PreAuthorize("#toSave?.owner == authentication?.name")
At runtime, before executing
save()
, Spring evaluates whether:toSave.owner == currentlyAuthenticatedUser.name
Restart the application and test the API again (with the previously introduced credentials above)
Now most Authorizations should now work as expected.
Secure Return Values and Authorization Error Handling
Since Spring Security 6.3+ it's also supported to implement a handler to customize authorization errors.
Let's see how this works by defining a new class called MaskMethodAuthorizationDeniedHandler
, create this in the security
package:
import org.aopalliance.intercept.MethodInvocation;
import org.springframework.security.authorization.AuthorizationResult;
import org.springframework.security.authorization.method.MethodAuthorizationDeniedHandler;
import org.springframework.stereotype.Component;
@Component
public class MaskMethodAuthorizationDeniedHandler implements MethodAuthorizationDeniedHandler {
@Override
public Object handleDeniedInvocation(MethodInvocation methodInvocation, AuthorizationResult authorizationResult) {
return "*****";
}
}
This class implements the MethodAuthorizationDeniedHandler
interface and provides a custom implementation for handling authorization failures. In this case, it simply returns a masked value (*****). We will later add this to the BankAccount
entity class.
Since Spring Security 6.3+ also supports wrapping any object that is annotated its method security annotations. The simplest way to achieve this is to mark any method that returns the object you wish to authorize with the @AuthorizeReturnObject
annotation.
The @AuthorizeReturnObject
annotation instructs Spring Security to check the returned object against the security expression. This is a new feature introduced in Spring Security 6.3 as well and allows you to restrict access to the returned domain object based on the user's role and the returned object's properties.
We want to try this now on our domain entity.
Here is what the BankAccount
entity class looks like after adding the @PreAuthorize
and @HandleAuthorizationDenied
annotation:
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
import jakarta.persistence.Entity;
import org.example.features.security.MaskMethodAuthorizationDeniedHandler;
import org.springframework.data.jpa.domain.AbstractPersistable;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.authorization.method.HandleAuthorizationDenied;
import java.math.BigDecimal;
import java.util.Objects;
@JsonSerialize(as = BankAccount.class)
@Entity
public class BankAccount extends AbstractPersistable<Long> {
private String owner;
private String accountNumber;
private BigDecimal balance;
public BankAccount() {
}
public BankAccount(long id, String owner, String accountNumber, BigDecimal balance) {
this.owner = owner;
this.accountNumber = accountNumber;
this.balance = balance;
}
public String getOwner() {
return owner;
}
@PreAuthorize("this.owner == authentication?.name")
@HandleAuthorizationDenied(handlerClass = MaskMethodAuthorizationDeniedHandler.class)
public String getAccountNumber() {
return accountNumber;
}
public BigDecimal getBalance() {
return balance;
}
@JsonIgnore
@Override
public boolean isNew() {
return super.isNew();
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
if (!super.equals(o)) return false;
BankAccount that = (BankAccount) o;
return Objects.equals(owner, that.owner) && Objects.equals(accountNumber, that.accountNumber) && Objects.equals(balance, that.balance);
}
@Override
public int hashCode() {
return Objects.hash(super.hashCode(), owner, accountNumber, balance);
}
@Override
public String toString() {
return "BankAccount{" +
"owner='" + owner + '\'' +
", accountNumber='" + accountNumber + '\'' +
", balance=" + balance +
'}';
}
}
@HandleAuthorizationDenied
now also references the previously created MaskMethodAuthorizationDeniedHandler
class, which is a custom authorization-denied handler that is used to mask the account number if the user is not authorized to access it.
Finally, we need to activate the validation of authorization checks on our domain object. We achieve this by adding another customized annotation.
The PostReadBankAccount
annotation is a custom security annotation that is used to restrict access to the method based on the user's role and the returned object. Create a new class called PostReadBankAccount
in the security
package:
import org.springframework.security.access.prepost.PostAuthorize;
import org.springframework.security.authorization.method.AuthorizeReturnObject;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
@Retention(RetentionPolicy.RUNTIME)
@PostAuthorize("returnObject?.owner == authentication?.name or hasRole('ACCOUNTANT')")
@AuthorizeReturnObject
public @interface PostReadBankAccount {
}
We also need to add this annotation to the findById
method in the BankAccountService
class:
import org.example.features.security.PostReadBankAccount;
import org.example.features.security.PreGetBankAccounts;
import org.example.features.security.PreWriteBankAccount;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
@Transactional(readOnly = true)
@Service
public class BankAccountService {
private final BankAccountRepository bankAccountRepository;
public BankAccountService(BankAccountRepository bankAccountRepository) {
this.bankAccountRepository = bankAccountRepository;
}
@PreGetBankAccounts(role = "ADMIN")
List<BankAccount> findAll() {
return bankAccountRepository.findAll();
}
@PostReadBankAccount
BankAccount findById(long id) {
return bankAccountRepository.findById(id).orElse(null);
}
@PreWriteBankAccount("#toSave")
@Transactional
BankAccount save(BankAccount toSave) {
return bankAccountRepository.save(toSave);
}
@PreWriteBankAccount("#toUpdate")
@Transactional
boolean update(long id, BankAccount toUpdate) {
return bankAccountRepository.updateBankAccount(id, toUpdate.getBalance()) == 1;
}
}
Step 4: Start the application and test the API
Restart the application by running the
BankAccountApplication
class.You can do this by right-clicking on the class and selecting Run or by using the command line:
./mvnw spring-boot:run
Re-test the API.
All authorizations should work now as expected.
If the user has restricted access rights, the account number is being masked out.
✅ That's it! You have successfully implemented the really important authorization part in your Spring Boot application.
Let's now move on to the next section, where we will look into the OAuth2 Token Exchange.
Last updated