Browse Source

Add Authorization Denied Handlers for Method Security

Closes gh-14601
Marcus Hert Da Coregio 1 year ago
parent
commit
d85857f905
36 changed files with 2461 additions and 61 deletions
  1. 1 1
      config/src/main/java/org/springframework/security/config/annotation/method/configuration/MethodSecuritySelector.java
  2. 2 0
      config/src/main/java/org/springframework/security/config/annotation/method/configuration/PrePostMethodSecurityConfiguration.java
  3. 5 2
      config/src/main/java/org/springframework/security/config/annotation/method/configuration/ReactiveAuthorizationManagerMethodSecurityConfiguration.java
  4. 212 1
      config/src/test/java/org/springframework/security/config/annotation/method/configuration/MethodSecurityService.java
  5. 72 1
      config/src/test/java/org/springframework/security/config/annotation/method/configuration/MethodSecurityServiceImpl.java
  6. 29 0
      config/src/test/java/org/springframework/security/config/annotation/method/configuration/MyMasker.java
  7. 192 0
      config/src/test/java/org/springframework/security/config/annotation/method/configuration/PrePostMethodSecurityConfigurationTests.java
  8. 220 0
      config/src/test/java/org/springframework/security/config/annotation/method/configuration/PrePostReactiveMethodSecurityConfigurationTests.java
  9. 222 0
      config/src/test/java/org/springframework/security/config/annotation/method/configuration/ReactiveMethodSecurityService.java
  10. 85 0
      config/src/test/java/org/springframework/security/config/annotation/method/configuration/ReactiveMethodSecurityServiceImpl.java
  11. 55 0
      config/src/test/java/org/springframework/security/config/annotation/method/configuration/UserRecordWithEmailProtected.java
  12. 10 1
      core/src/main/java/org/springframework/security/access/prepost/PostAuthorize.java
  13. 10 1
      core/src/main/java/org/springframework/security/access/prepost/PreAuthorize.java
  14. 43 0
      core/src/main/java/org/springframework/security/authorization/AuthorizationDeniedException.java
  15. 14 5
      core/src/main/java/org/springframework/security/authorization/method/AuthorizationManagerAfterMethodInterceptor.java
  16. 26 4
      core/src/main/java/org/springframework/security/authorization/method/AuthorizationManagerAfterReactiveMethodInterceptor.java
  17. 14 5
      core/src/main/java/org/springframework/security/authorization/method/AuthorizationManagerBeforeMethodInterceptor.java
  18. 47 10
      core/src/main/java/org/springframework/security/authorization/method/AuthorizationManagerBeforeReactiveMethodInterceptor.java
  19. 46 0
      core/src/main/java/org/springframework/security/authorization/method/MethodAuthorizationDeniedHandler.java
  20. 46 0
      core/src/main/java/org/springframework/security/authorization/method/MethodAuthorizationDeniedPostProcessor.java
  21. 41 0
      core/src/main/java/org/springframework/security/authorization/method/PostAuthorizeAuthorizationDecision.java
  22. 18 4
      core/src/main/java/org/springframework/security/authorization/method/PostAuthorizeAuthorizationManager.java
  23. 42 0
      core/src/main/java/org/springframework/security/authorization/method/PostAuthorizeExpressionAttribute.java
  24. 40 1
      core/src/main/java/org/springframework/security/authorization/method/PostAuthorizeExpressionAttributeRegistry.java
  25. 8 2
      core/src/main/java/org/springframework/security/authorization/method/PostAuthorizeReactiveAuthorizationManager.java
  26. 43 0
      core/src/main/java/org/springframework/security/authorization/method/PreAuthorizeAuthorizationDecision.java
  27. 10 4
      core/src/main/java/org/springframework/security/authorization/method/PreAuthorizeAuthorizationManager.java
  28. 42 0
      core/src/main/java/org/springframework/security/authorization/method/PreAuthorizeExpressionAttribute.java
  29. 39 1
      core/src/main/java/org/springframework/security/authorization/method/PreAuthorizeExpressionAttributeRegistry.java
  30. 8 2
      core/src/main/java/org/springframework/security/authorization/method/PreAuthorizeReactiveAuthorizationManager.java
  31. 38 0
      core/src/main/java/org/springframework/security/authorization/method/ThrowingMethodAuthorizationDeniedHandler.java
  32. 36 0
      core/src/main/java/org/springframework/security/authorization/method/ThrowingMethodAuthorizationDeniedPostProcessor.java
  33. 164 8
      core/src/test/java/org/springframework/security/authorization/method/AuthorizationManagerAfterReactiveMethodInterceptorTests.java
  34. 126 8
      core/src/test/java/org/springframework/security/authorization/method/AuthorizationManagerBeforeReactiveMethodInterceptorTests.java
  35. 454 0
      docs/modules/ROOT/pages/servlet/authorization/method-security.adoc
  36. 1 0
      docs/modules/ROOT/pages/whats-new.adoc

+ 1 - 1
config/src/main/java/org/springframework/security/config/annotation/method/configuration/MethodSecuritySelector.java

@@ -1,5 +1,5 @@
 /*
- * Copyright 2002-2023 the original author or authors.
+ * Copyright 2002-2024 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.

+ 2 - 0
config/src/main/java/org/springframework/security/config/annotation/method/configuration/PrePostMethodSecurityConfiguration.java

@@ -98,6 +98,7 @@ final class PrePostMethodSecurityConfiguration implements ImportAware, AopInfras
 			ObjectProvider<ObservationRegistry> registryProvider, ObjectProvider<RoleHierarchy> roleHierarchyProvider,
 			PrePostMethodSecurityConfiguration configuration, ApplicationContext context) {
 		PreAuthorizeAuthorizationManager manager = new PreAuthorizeAuthorizationManager();
+		manager.setApplicationContext(context);
 		AuthorizationManagerBeforeMethodInterceptor preAuthorize = AuthorizationManagerBeforeMethodInterceptor
 			.preAuthorize(manager(manager, registryProvider));
 		preAuthorize.setOrder(preAuthorize.getOrder() + configuration.interceptorOrderOffset);
@@ -121,6 +122,7 @@ final class PrePostMethodSecurityConfiguration implements ImportAware, AopInfras
 			ObjectProvider<ObservationRegistry> registryProvider, ObjectProvider<RoleHierarchy> roleHierarchyProvider,
 			PrePostMethodSecurityConfiguration configuration, ApplicationContext context) {
 		PostAuthorizeAuthorizationManager manager = new PostAuthorizeAuthorizationManager();
+		manager.setApplicationContext(context);
 		AuthorizationManagerAfterMethodInterceptor postAuthorize = AuthorizationManagerAfterMethodInterceptor
 			.postAuthorize(manager(manager, registryProvider));
 		postAuthorize.setOrder(postAuthorize.getOrder() + configuration.interceptorOrderOffset);

+ 5 - 2
config/src/main/java/org/springframework/security/config/annotation/method/configuration/ReactiveAuthorizationManagerMethodSecurityConfiguration.java

@@ -31,6 +31,7 @@ import org.springframework.aop.framework.AopInfrastructureBean;
 import org.springframework.beans.factory.ObjectProvider;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.beans.factory.config.BeanDefinition;
+import org.springframework.context.ApplicationContext;
 import org.springframework.context.annotation.Bean;
 import org.springframework.context.annotation.Configuration;
 import org.springframework.context.annotation.Role;
@@ -74,9 +75,10 @@ final class ReactiveAuthorizationManagerMethodSecurityConfiguration implements A
 	static MethodInterceptor preAuthorizeAuthorizationMethodInterceptor(
 			MethodSecurityExpressionHandler expressionHandler,
 			ObjectProvider<PrePostTemplateDefaults> defaultsObjectProvider,
-			ObjectProvider<ObservationRegistry> registryProvider) {
+			ObjectProvider<ObservationRegistry> registryProvider, ApplicationContext context) {
 		PreAuthorizeReactiveAuthorizationManager manager = new PreAuthorizeReactiveAuthorizationManager(
 				expressionHandler);
+		manager.setApplicationContext(context);
 		ReactiveAuthorizationManager<MethodInvocation> authorizationManager = manager(manager, registryProvider);
 		AuthorizationAdvisor interceptor = AuthorizationManagerBeforeReactiveMethodInterceptor
 			.preAuthorize(authorizationManager);
@@ -99,9 +101,10 @@ final class ReactiveAuthorizationManagerMethodSecurityConfiguration implements A
 	static MethodInterceptor postAuthorizeAuthorizationMethodInterceptor(
 			MethodSecurityExpressionHandler expressionHandler,
 			ObjectProvider<PrePostTemplateDefaults> defaultsObjectProvider,
-			ObjectProvider<ObservationRegistry> registryProvider) {
+			ObjectProvider<ObservationRegistry> registryProvider, ApplicationContext context) {
 		PostAuthorizeReactiveAuthorizationManager manager = new PostAuthorizeReactiveAuthorizationManager(
 				expressionHandler);
+		manager.setApplicationContext(context);
 		ReactiveAuthorizationManager<MethodInvocationResult> authorizationManager = manager(manager, registryProvider);
 		AuthorizationAdvisor interceptor = AuthorizationManagerAfterReactiveMethodInterceptor
 			.postAuthorize(authorizationManager);

+ 212 - 1
config/src/test/java/org/springframework/security/config/annotation/method/configuration/MethodSecurityService.java

@@ -1,5 +1,5 @@
 /*
- * Copyright 2002-2023 the original author or authors.
+ * Copyright 2002-2024 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -16,23 +16,42 @@
 
 package org.springframework.security.config.annotation.method.configuration;
 
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Inherited;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
 import java.util.List;
 
 import jakarta.annotation.security.DenyAll;
 import jakarta.annotation.security.PermitAll;
 import jakarta.annotation.security.RolesAllowed;
+import org.aopalliance.intercept.MethodInvocation;
 
+import org.springframework.context.ApplicationContext;
+import org.springframework.core.annotation.AnnotationUtils;
+import org.springframework.expression.EvaluationContext;
+import org.springframework.expression.Expression;
 import org.springframework.security.access.annotation.Secured;
+import org.springframework.security.access.expression.method.DefaultMethodSecurityExpressionHandler;
 import org.springframework.security.access.prepost.PostAuthorize;
 import org.springframework.security.access.prepost.PostFilter;
 import org.springframework.security.access.prepost.PreAuthorize;
 import org.springframework.security.access.prepost.PreFilter;
+import org.springframework.security.authorization.AuthorizationResult;
+import org.springframework.security.authorization.method.AuthorizeReturnObject;
+import org.springframework.security.authorization.method.MethodAuthorizationDeniedHandler;
+import org.springframework.security.authorization.method.MethodAuthorizationDeniedPostProcessor;
+import org.springframework.security.authorization.method.MethodInvocationResult;
 import org.springframework.security.core.Authentication;
+import org.springframework.security.core.context.SecurityContextHolder;
 import org.springframework.security.core.parameters.P;
+import org.springframework.util.StringUtils;
 
 /**
  * @author Rob Winch
  */
+@MethodSecurityService.Mask("classmask")
 public interface MethodSecurityService {
 
 	@PreAuthorize("denyAll")
@@ -108,4 +127,196 @@ public interface MethodSecurityService {
 	@RequireAdminRole
 	void repeatedAnnotations();
 
+	@PreAuthorize(value = "hasRole('ADMIN')", handlerClass = StarMaskingHandler.class)
+	String preAuthorizeGetCardNumberIfAdmin(String cardNumber);
+
+	@PreAuthorize(value = "hasRole('ADMIN')", handlerClass = StartMaskingHandlerChild.class)
+	String preAuthorizeWithHandlerChildGetCardNumberIfAdmin(String cardNumber);
+
+	@PreAuthorize(value = "hasRole('ADMIN')", handlerClass = StarMaskingHandler.class)
+	String preAuthorizeThrowAccessDeniedManually();
+
+	@PostAuthorize(value = "hasRole('ADMIN')", postProcessorClass = CardNumberMaskingPostProcessor.class)
+	String postAuthorizeGetCardNumberIfAdmin(String cardNumber);
+
+	@PostAuthorize(value = "hasRole('ADMIN')", postProcessorClass = PostMaskingPostProcessor.class)
+	String postAuthorizeThrowAccessDeniedManually();
+
+	@PreAuthorize(value = "denyAll()", handlerClass = MaskAnnotationHandler.class)
+	@Mask("methodmask")
+	String preAuthorizeDeniedMethodWithMaskAnnotation();
+
+	@PreAuthorize(value = "denyAll()", handlerClass = MaskAnnotationHandler.class)
+	String preAuthorizeDeniedMethodWithNoMaskAnnotation();
+
+	@NullDenied(role = "ADMIN")
+	String postAuthorizeDeniedWithNullDenied();
+
+	@PostAuthorize(value = "denyAll()", postProcessorClass = MaskAnnotationPostProcessor.class)
+	@Mask("methodmask")
+	String postAuthorizeDeniedMethodWithMaskAnnotation();
+
+	@PostAuthorize(value = "denyAll()", postProcessorClass = MaskAnnotationPostProcessor.class)
+	String postAuthorizeDeniedMethodWithNoMaskAnnotation();
+
+	@PreAuthorize(value = "hasRole('ADMIN')", handlerClass = MaskAnnotationHandler.class)
+	@Mask(expression = "@myMasker.getMask()")
+	String preAuthorizeWithMaskAnnotationUsingBean();
+
+	@PostAuthorize(value = "hasRole('ADMIN')", postProcessorClass = MaskAnnotationPostProcessor.class)
+	@Mask(expression = "@myMasker.getMask(returnObject)")
+	String postAuthorizeWithMaskAnnotationUsingBean();
+
+	@AuthorizeReturnObject
+	UserRecordWithEmailProtected getUserRecordWithEmailProtected();
+
+	@PreAuthorize(value = "hasRole('ADMIN')", handlerClass = UserFallbackDeniedHandler.class)
+	UserRecordWithEmailProtected getUserWithFallbackWhenUnauthorized();
+
+	class StarMaskingHandler implements MethodAuthorizationDeniedHandler {
+
+		@Override
+		public Object handle(MethodInvocation methodInvocation, AuthorizationResult result) {
+			return "***";
+		}
+
+	}
+
+	class StartMaskingHandlerChild extends StarMaskingHandler {
+
+		@Override
+		public Object handle(MethodInvocation methodInvocation, AuthorizationResult result) {
+			return super.handle(methodInvocation, result) + "-child";
+		}
+
+	}
+
+	class MaskAnnotationHandler implements MethodAuthorizationDeniedHandler {
+
+		MaskValueResolver maskValueResolver;
+
+		MaskAnnotationHandler(ApplicationContext context) {
+			this.maskValueResolver = new MaskValueResolver(context);
+		}
+
+		@Override
+		public Object handle(MethodInvocation methodInvocation, AuthorizationResult result) {
+			Mask mask = AnnotationUtils.getAnnotation(methodInvocation.getMethod(), Mask.class);
+			if (mask == null) {
+				mask = AnnotationUtils.getAnnotation(methodInvocation.getMethod().getDeclaringClass(), Mask.class);
+			}
+			return this.maskValueResolver.resolveValue(mask, methodInvocation, null);
+		}
+
+	}
+
+	class MaskAnnotationPostProcessor implements MethodAuthorizationDeniedPostProcessor {
+
+		MaskValueResolver maskValueResolver;
+
+		MaskAnnotationPostProcessor(ApplicationContext context) {
+			this.maskValueResolver = new MaskValueResolver(context);
+		}
+
+		@Override
+		public Object postProcessResult(MethodInvocationResult methodInvocationResult,
+				AuthorizationResult authorizationResult) {
+			MethodInvocation mi = methodInvocationResult.getMethodInvocation();
+			Mask mask = AnnotationUtils.getAnnotation(mi.getMethod(), Mask.class);
+			if (mask == null) {
+				mask = AnnotationUtils.getAnnotation(mi.getMethod().getDeclaringClass(), Mask.class);
+			}
+			return this.maskValueResolver.resolveValue(mask, mi, methodInvocationResult.getResult());
+		}
+
+	}
+
+	class MaskValueResolver {
+
+		DefaultMethodSecurityExpressionHandler expressionHandler;
+
+		MaskValueResolver(ApplicationContext context) {
+			this.expressionHandler = new DefaultMethodSecurityExpressionHandler();
+			this.expressionHandler.setApplicationContext(context);
+		}
+
+		String resolveValue(Mask mask, MethodInvocation mi, Object returnObject) {
+			if (StringUtils.hasText(mask.value())) {
+				return mask.value();
+			}
+			Expression expression = this.expressionHandler.getExpressionParser().parseExpression(mask.expression());
+			EvaluationContext evaluationContext = this.expressionHandler
+				.createEvaluationContext(() -> SecurityContextHolder.getContext().getAuthentication(), mi);
+			if (returnObject != null) {
+				this.expressionHandler.setReturnObject(returnObject, evaluationContext);
+			}
+			return expression.getValue(evaluationContext, String.class);
+		}
+
+	}
+
+	class PostMaskingPostProcessor implements MethodAuthorizationDeniedPostProcessor {
+
+		@Override
+		public Object postProcessResult(MethodInvocationResult contextObject, AuthorizationResult result) {
+			return "***";
+		}
+
+	}
+
+	class CardNumberMaskingPostProcessor implements MethodAuthorizationDeniedPostProcessor {
+
+		static String MASK = "****-****-****-";
+
+		@Override
+		public Object postProcessResult(MethodInvocationResult contextObject, AuthorizationResult result) {
+			String cardNumber = (String) contextObject.getResult();
+			return MASK + cardNumber.substring(cardNumber.length() - 4);
+		}
+
+	}
+
+	class NullPostProcessor implements MethodAuthorizationDeniedPostProcessor {
+
+		@Override
+		public Object postProcessResult(MethodInvocationResult methodInvocationResult,
+				AuthorizationResult authorizationResult) {
+			return null;
+		}
+
+	}
+
+	@Target({ ElementType.METHOD, ElementType.TYPE })
+	@Retention(RetentionPolicy.RUNTIME)
+	@Inherited
+	@interface Mask {
+
+		String value() default "";
+
+		String expression() default "";
+
+	}
+
+	@Target({ ElementType.METHOD, ElementType.TYPE })
+	@Retention(RetentionPolicy.RUNTIME)
+	@Inherited
+	@PostAuthorize(value = "hasRole('{value}')", postProcessorClass = NullPostProcessor.class)
+	@interface NullDenied {
+
+		String role();
+
+	}
+
+	class UserFallbackDeniedHandler implements MethodAuthorizationDeniedHandler {
+
+		private static final UserRecordWithEmailProtected FALLBACK = new UserRecordWithEmailProtected("Protected",
+				"Protected");
+
+		@Override
+		public Object handle(MethodInvocation methodInvocation, AuthorizationResult authorizationResult) {
+			return FALLBACK;
+		}
+
+	}
+
 }

+ 72 - 1
config/src/test/java/org/springframework/security/config/annotation/method/configuration/MethodSecurityServiceImpl.java

@@ -1,5 +1,5 @@
 /*
- * Copyright 2002-2023 the original author or authors.
+ * Copyright 2002-2024 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -18,6 +18,7 @@ package org.springframework.security.config.annotation.method.configuration;
 
 import java.util.List;
 
+import org.springframework.security.access.AccessDeniedException;
 import org.springframework.security.core.Authentication;
 import org.springframework.security.core.context.SecurityContextHolder;
 
@@ -126,4 +127,74 @@ public class MethodSecurityServiceImpl implements MethodSecurityService {
 	public void repeatedAnnotations() {
 	}
 
+	@Override
+	public String postAuthorizeGetCardNumberIfAdmin(String cardNumber) {
+		return cardNumber;
+	}
+
+	@Override
+	public String preAuthorizeGetCardNumberIfAdmin(String cardNumber) {
+		return cardNumber;
+	}
+
+	@Override
+	public String preAuthorizeWithHandlerChildGetCardNumberIfAdmin(String cardNumber) {
+		return cardNumber;
+	}
+
+	@Override
+	public String preAuthorizeThrowAccessDeniedManually() {
+		throw new AccessDeniedException("Access Denied");
+	}
+
+	@Override
+	public String postAuthorizeThrowAccessDeniedManually() {
+		throw new AccessDeniedException("Access Denied");
+	}
+
+	@Override
+	public String preAuthorizeDeniedMethodWithMaskAnnotation() {
+		return "ok";
+	}
+
+	@Override
+	public String preAuthorizeDeniedMethodWithNoMaskAnnotation() {
+		return "ok";
+	}
+
+	@Override
+	public String postAuthorizeDeniedWithNullDenied() {
+		return "ok";
+	}
+
+	@Override
+	public String postAuthorizeDeniedMethodWithMaskAnnotation() {
+		return "ok";
+	}
+
+	@Override
+	public String postAuthorizeDeniedMethodWithNoMaskAnnotation() {
+		return "ok";
+	}
+
+	@Override
+	public String preAuthorizeWithMaskAnnotationUsingBean() {
+		return "ok";
+	}
+
+	@Override
+	public String postAuthorizeWithMaskAnnotationUsingBean() {
+		return "ok";
+	}
+
+	@Override
+	public UserRecordWithEmailProtected getUserRecordWithEmailProtected() {
+		return new UserRecordWithEmailProtected("username", "useremail@example.com");
+	}
+
+	@Override
+	public UserRecordWithEmailProtected getUserWithFallbackWhenUnauthorized() {
+		return new UserRecordWithEmailProtected("username", "useremail@example.com");
+	}
+
 }

+ 29 - 0
config/src/test/java/org/springframework/security/config/annotation/method/configuration/MyMasker.java

@@ -0,0 +1,29 @@
+/*
+ * Copyright 2002-2024 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.security.config.annotation.method.configuration;
+
+public class MyMasker {
+
+	public String getMask(String value) {
+		return value + "-masked";
+	}
+
+	public String getMask() {
+		return "mask";
+	}
+
+}

+ 192 - 0
config/src/test/java/org/springframework/security/config/annotation/method/configuration/PrePostMethodSecurityConfigurationTests.java

@@ -743,6 +743,188 @@ public class PrePostMethodSecurityConfigurationTests {
 		});
 	}
 
+	@Test
+	@WithMockUser
+	void getCardNumberWhenPostAuthorizeAndNotAdminThenReturnMasked() {
+		this.spring
+			.register(MethodSecurityServiceEnabledConfig.class,
+					MethodSecurityService.CardNumberMaskingPostProcessor.class)
+			.autowire();
+		MethodSecurityService service = this.spring.getContext().getBean(MethodSecurityService.class);
+		String cardNumber = service.postAuthorizeGetCardNumberIfAdmin("4444-3333-2222-1111");
+		assertThat(cardNumber).isEqualTo("****-****-****-1111");
+	}
+
+	@Test
+	@WithMockUser
+	void getCardNumberWhenPreAuthorizeAndNotAdminThenReturnMasked() {
+		this.spring.register(MethodSecurityServiceEnabledConfig.class, MethodSecurityService.StarMaskingHandler.class)
+			.autowire();
+		MethodSecurityService service = this.spring.getContext().getBean(MethodSecurityService.class);
+		String cardNumber = service.preAuthorizeGetCardNumberIfAdmin("4444-3333-2222-1111");
+		assertThat(cardNumber).isEqualTo("***");
+	}
+
+	@Test
+	@WithMockUser
+	void getCardNumberWhenPreAuthorizeAndNotAdminAndChildHandlerThenResolveCorrectHandlerAndReturnMasked() {
+		this.spring
+			.register(MethodSecurityServiceEnabledConfig.class, MethodSecurityService.StarMaskingHandler.class,
+					MethodSecurityService.StartMaskingHandlerChild.class)
+			.autowire();
+		MethodSecurityService service = this.spring.getContext().getBean(MethodSecurityService.class);
+		String cardNumber = service.preAuthorizeWithHandlerChildGetCardNumberIfAdmin("4444-3333-2222-1111");
+		assertThat(cardNumber).isEqualTo("***-child");
+	}
+
+	@Test
+	@WithMockUser(roles = "ADMIN")
+	void preAuthorizeWhenHandlerAndAccessDeniedNotThrownFromPreAuthorizeThenNotHandled() {
+		this.spring.register(MethodSecurityServiceEnabledConfig.class, MethodSecurityService.StarMaskingHandler.class)
+			.autowire();
+		MethodSecurityService service = this.spring.getContext().getBean(MethodSecurityService.class);
+		assertThatExceptionOfType(AccessDeniedException.class)
+			.isThrownBy(service::preAuthorizeThrowAccessDeniedManually);
+	}
+
+	@Test
+	@WithMockUser
+	void preAuthorizeWhenDeniedAndHandlerWithCustomAnnotationThenHandlerCanUseMaskFromOtherAnnotation() {
+		this.spring
+			.register(MethodSecurityServiceEnabledConfig.class, MethodSecurityService.MaskAnnotationHandler.class)
+			.autowire();
+		MethodSecurityService service = this.spring.getContext().getBean(MethodSecurityService.class);
+		String result = service.preAuthorizeDeniedMethodWithMaskAnnotation();
+		assertThat(result).isEqualTo("methodmask");
+	}
+
+	@Test
+	@WithMockUser
+	void preAuthorizeWhenDeniedAndHandlerWithCustomAnnotationInClassThenHandlerCanUseMaskFromOtherAnnotation() {
+		this.spring
+			.register(MethodSecurityServiceEnabledConfig.class, MethodSecurityService.MaskAnnotationHandler.class)
+			.autowire();
+		MethodSecurityService service = this.spring.getContext().getBean(MethodSecurityService.class);
+		String result = service.preAuthorizeDeniedMethodWithNoMaskAnnotation();
+		assertThat(result).isEqualTo("classmask");
+	}
+
+	@Test
+	@WithMockUser(roles = "ADMIN")
+	void postAuthorizeWhenHandlerAndAccessDeniedNotThrownFromPostAuthorizeThenNotHandled() {
+		this.spring
+			.register(MethodSecurityServiceEnabledConfig.class, MethodSecurityService.PostMaskingPostProcessor.class)
+			.autowire();
+		MethodSecurityService service = this.spring.getContext().getBean(MethodSecurityService.class);
+		assertThatExceptionOfType(AccessDeniedException.class)
+			.isThrownBy(service::postAuthorizeThrowAccessDeniedManually);
+	}
+
+	@Test
+	@WithMockUser
+	void postAuthorizeWhenNullDeniedMetaAnnotationThanWorks() {
+		this.spring.register(MethodSecurityServiceEnabledConfig.class, MethodSecurityService.NullPostProcessor.class)
+			.autowire();
+		MethodSecurityService service = this.spring.getContext().getBean(MethodSecurityService.class);
+		String result = service.postAuthorizeDeniedWithNullDenied();
+		assertThat(result).isNull();
+	}
+
+	@Test
+	@WithMockUser
+	void postAuthorizeWhenDeniedAndHandlerWithCustomAnnotationThenHandlerCanUseMaskFromOtherAnnotation() {
+		this.spring
+			.register(MethodSecurityServiceEnabledConfig.class, MethodSecurityService.MaskAnnotationPostProcessor.class)
+			.autowire();
+		MethodSecurityService service = this.spring.getContext().getBean(MethodSecurityService.class);
+		String result = service.postAuthorizeDeniedMethodWithMaskAnnotation();
+		assertThat(result).isEqualTo("methodmask");
+	}
+
+	@Test
+	@WithMockUser
+	void postAuthorizeWhenDeniedAndHandlerWithCustomAnnotationInClassThenHandlerCanUseMaskFromOtherAnnotation() {
+		this.spring
+			.register(MethodSecurityServiceEnabledConfig.class, MethodSecurityService.MaskAnnotationPostProcessor.class)
+			.autowire();
+		MethodSecurityService service = this.spring.getContext().getBean(MethodSecurityService.class);
+		String result = service.postAuthorizeDeniedMethodWithNoMaskAnnotation();
+		assertThat(result).isEqualTo("classmask");
+	}
+
+	@Test
+	@WithMockUser
+	void postAuthorizeWhenDeniedAndHandlerWithCustomAnnotationUsingBeanThenHandlerCanUseMaskFromOtherAnnotation() {
+		this.spring
+			.register(MethodSecurityServiceEnabledConfig.class, MethodSecurityService.MaskAnnotationPostProcessor.class,
+					MyMasker.class)
+			.autowire();
+		MethodSecurityService service = this.spring.getContext().getBean(MethodSecurityService.class);
+		String result = service.postAuthorizeWithMaskAnnotationUsingBean();
+		assertThat(result).isEqualTo("ok-masked");
+	}
+
+	@Test
+	@WithMockUser(roles = "ADMIN")
+	void postAuthorizeWhenAllowedAndHandlerWithCustomAnnotationUsingBeanThenInvokeMethodNormally() {
+		this.spring
+			.register(MethodSecurityServiceEnabledConfig.class, MethodSecurityService.MaskAnnotationPostProcessor.class,
+					MyMasker.class)
+			.autowire();
+		MethodSecurityService service = this.spring.getContext().getBean(MethodSecurityService.class);
+		String result = service.postAuthorizeWithMaskAnnotationUsingBean();
+		assertThat(result).isEqualTo("ok");
+	}
+
+	@Test
+	@WithMockUser
+	void preAuthorizeWhenDeniedAndHandlerWithCustomAnnotationUsingBeanThenHandlerCanUseMaskFromOtherAnnotation() {
+		this.spring
+			.register(MethodSecurityServiceEnabledConfig.class, MethodSecurityService.MaskAnnotationHandler.class,
+					MyMasker.class)
+			.autowire();
+		MethodSecurityService service = this.spring.getContext().getBean(MethodSecurityService.class);
+		String result = service.preAuthorizeWithMaskAnnotationUsingBean();
+		assertThat(result).isEqualTo("mask");
+	}
+
+	@Test
+	@WithMockUser(roles = "ADMIN")
+	void preAuthorizeWhenAllowedAndHandlerWithCustomAnnotationUsingBeanThenInvokeMethodNormally() {
+		this.spring
+			.register(MethodSecurityServiceEnabledConfig.class, MethodSecurityService.MaskAnnotationHandler.class,
+					MyMasker.class)
+			.autowire();
+		MethodSecurityService service = this.spring.getContext().getBean(MethodSecurityService.class);
+		String result = service.preAuthorizeWithMaskAnnotationUsingBean();
+		assertThat(result).isEqualTo("ok");
+	}
+
+	@Test
+	@WithMockUser
+	void getUserWhenAuthorizedAndUserEmailIsProtectedAndNotAuthorizedThenReturnEmailMasked() {
+		this.spring
+			.register(MethodSecurityServiceEnabledConfig.class,
+					UserRecordWithEmailProtected.EmailMaskingPostProcessor.class)
+			.autowire();
+		MethodSecurityService service = this.spring.getContext().getBean(MethodSecurityService.class);
+		UserRecordWithEmailProtected user = service.getUserRecordWithEmailProtected();
+		assertThat(user.email()).isEqualTo("use******@example.com");
+		assertThat(user.name()).isEqualTo("username");
+	}
+
+	@Test
+	@WithMockUser
+	void getUserWhenNotAuthorizedAndHandlerFallbackValueThenReturnFallbackValue() {
+		this.spring
+			.register(MethodSecurityServiceEnabledConfig.class, MethodSecurityService.UserFallbackDeniedHandler.class)
+			.autowire();
+		MethodSecurityService service = this.spring.getContext().getBean(MethodSecurityService.class);
+		UserRecordWithEmailProtected user = service.getUserWithFallbackWhenUnauthorized();
+		assertThat(user.email()).isEqualTo("Protected");
+		assertThat(user.name()).isEqualTo("Protected");
+	}
+
 	private static Consumer<ConfigurableWebApplicationContext> disallowBeanOverriding() {
 		return (context) -> ((AnnotationConfigWebApplicationContext) context).setAllowBeanDefinitionOverriding(false);
 	}
@@ -756,6 +938,16 @@ public class PrePostMethodSecurityConfigurationTests {
 		return advisor;
 	}
 
+	@Configuration
+	static class AuthzConfig {
+
+		@Bean
+		Authz authz() {
+			return new Authz();
+		}
+
+	}
+
 	@Configuration
 	@EnableCustomMethodSecurity
 	static class CustomMethodSecurityServiceConfig {

+ 220 - 0
config/src/test/java/org/springframework/security/config/annotation/method/configuration/PrePostReactiveMethodSecurityConfigurationTests.java

@@ -0,0 +1,220 @@
+/*
+ * Copyright 2002-2024 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.security.config.annotation.method.configuration;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import reactor.test.StepVerifier;
+
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.security.access.AccessDeniedException;
+import org.springframework.security.config.test.SpringTestContext;
+import org.springframework.security.config.test.SpringTestContextExtension;
+import org.springframework.security.test.context.annotation.SecurityTestExecutionListeners;
+import org.springframework.security.test.context.support.WithMockUser;
+import org.springframework.test.context.junit.jupiter.SpringExtension;
+
+@ExtendWith({ SpringExtension.class, SpringTestContextExtension.class })
+@SecurityTestExecutionListeners
+public class PrePostReactiveMethodSecurityConfigurationTests {
+
+	public final SpringTestContext spring = new SpringTestContext(this);
+
+	@Test
+	@WithMockUser
+	void getCardNumberWhenPostAuthorizeAndNotAdminThenReturnMasked() {
+		this.spring
+			.register(MethodSecurityServiceEnabledConfig.class,
+					ReactiveMethodSecurityService.CardNumberMaskingPostProcessor.class)
+			.autowire();
+		ReactiveMethodSecurityService service = this.spring.getContext().getBean(ReactiveMethodSecurityService.class);
+		StepVerifier.create(service.postAuthorizeGetCardNumberIfAdmin("4444-3333-2222-1111"))
+			.expectNext("****-****-****-1111")
+			.verifyComplete();
+	}
+
+	@Test
+	@WithMockUser
+	void getCardNumberWhenPreAuthorizeAndNotAdminThenReturnMasked() {
+		this.spring
+			.register(MethodSecurityServiceEnabledConfig.class, ReactiveMethodSecurityService.StarMaskingHandler.class)
+			.autowire();
+		ReactiveMethodSecurityService service = this.spring.getContext().getBean(ReactiveMethodSecurityService.class);
+		StepVerifier.create(service.preAuthorizeGetCardNumberIfAdmin("4444-3333-2222-1111"))
+			.expectNext("***")
+			.verifyComplete();
+	}
+
+	@Test
+	@WithMockUser
+	void getCardNumberWhenPreAuthorizeAndNotAdminAndChildHandlerThenResolveCorrectHandlerAndReturnMasked() {
+		this.spring
+			.register(MethodSecurityServiceEnabledConfig.class, ReactiveMethodSecurityService.StarMaskingHandler.class,
+					ReactiveMethodSecurityService.StartMaskingHandlerChild.class)
+			.autowire();
+		ReactiveMethodSecurityService service = this.spring.getContext().getBean(ReactiveMethodSecurityService.class);
+		StepVerifier.create(service.preAuthorizeWithHandlerChildGetCardNumberIfAdmin("4444-3333-2222-1111"))
+			.expectNext("***-child")
+			.verifyComplete();
+	}
+
+	@Test
+	@WithMockUser(roles = "ADMIN")
+	void preAuthorizeWhenHandlerAndAccessDeniedNotThrownFromPreAuthorizeThenNotHandled() {
+		this.spring
+			.register(MethodSecurityServiceEnabledConfig.class, ReactiveMethodSecurityService.StarMaskingHandler.class)
+			.autowire();
+		ReactiveMethodSecurityService service = this.spring.getContext().getBean(ReactiveMethodSecurityService.class);
+		StepVerifier.create(service.preAuthorizeThrowAccessDeniedManually())
+			.expectError(AccessDeniedException.class)
+			.verify();
+	}
+
+	@Test
+	@WithMockUser
+	void preAuthorizeWhenDeniedAndHandlerWithCustomAnnotationThenHandlerCanUseMaskFromOtherAnnotation() {
+		this.spring
+			.register(MethodSecurityServiceEnabledConfig.class,
+					ReactiveMethodSecurityService.MaskAnnotationHandler.class)
+			.autowire();
+		ReactiveMethodSecurityService service = this.spring.getContext().getBean(ReactiveMethodSecurityService.class);
+		StepVerifier.create(service.preAuthorizeDeniedMethodWithMaskAnnotation())
+			.expectNext("methodmask")
+			.verifyComplete();
+	}
+
+	@Test
+	@WithMockUser
+	void preAuthorizeWhenDeniedAndHandlerWithCustomAnnotationInClassThenHandlerCanUseMaskFromOtherAnnotation() {
+		this.spring
+			.register(MethodSecurityServiceEnabledConfig.class,
+					ReactiveMethodSecurityService.MaskAnnotationHandler.class)
+			.autowire();
+		ReactiveMethodSecurityService service = this.spring.getContext().getBean(ReactiveMethodSecurityService.class);
+		StepVerifier.create(service.preAuthorizeDeniedMethodWithNoMaskAnnotation())
+			.expectNext("classmask")
+			.verifyComplete();
+	}
+
+	@Test
+	@WithMockUser(roles = "ADMIN")
+	void postAuthorizeWhenHandlerAndAccessDeniedNotThrownFromPostAuthorizeThenNotHandled() {
+		this.spring
+			.register(MethodSecurityServiceEnabledConfig.class,
+					ReactiveMethodSecurityService.PostMaskingPostProcessor.class)
+			.autowire();
+		ReactiveMethodSecurityService service = this.spring.getContext().getBean(ReactiveMethodSecurityService.class);
+		StepVerifier.create(service.postAuthorizeThrowAccessDeniedManually())
+			.expectError(AccessDeniedException.class)
+			.verify();
+	}
+
+	@Test
+	@WithMockUser
+	void postAuthorizeWhenNullDeniedMetaAnnotationThanWorks() {
+		this.spring
+			.register(MethodSecurityServiceEnabledConfig.class, ReactiveMethodSecurityService.NullPostProcessor.class)
+			.autowire();
+		ReactiveMethodSecurityService service = this.spring.getContext().getBean(ReactiveMethodSecurityService.class);
+		StepVerifier.create(service.postAuthorizeDeniedWithNullDenied()).verifyComplete();
+	}
+
+	@Test
+	@WithMockUser
+	void postAuthorizeWhenDeniedAndHandlerWithCustomAnnotationThenHandlerCanUseMaskFromOtherAnnotation() {
+		this.spring
+			.register(MethodSecurityServiceEnabledConfig.class,
+					ReactiveMethodSecurityService.MaskAnnotationPostProcessor.class)
+			.autowire();
+		ReactiveMethodSecurityService service = this.spring.getContext().getBean(ReactiveMethodSecurityService.class);
+		StepVerifier.create(service.postAuthorizeDeniedMethodWithMaskAnnotation())
+			.expectNext("methodmask")
+			.verifyComplete();
+	}
+
+	@Test
+	@WithMockUser
+	void postAuthorizeWhenDeniedAndHandlerWithCustomAnnotationInClassThenHandlerCanUseMaskFromOtherAnnotation() {
+		this.spring
+			.register(MethodSecurityServiceEnabledConfig.class,
+					ReactiveMethodSecurityService.MaskAnnotationPostProcessor.class)
+			.autowire();
+		ReactiveMethodSecurityService service = this.spring.getContext().getBean(ReactiveMethodSecurityService.class);
+		StepVerifier.create(service.postAuthorizeDeniedMethodWithNoMaskAnnotation())
+			.expectNext("classmask")
+			.verifyComplete();
+	}
+
+	@Test
+	@WithMockUser
+	void postAuthorizeWhenDeniedAndHandlerWithCustomAnnotationUsingBeanThenHandlerCanUseMaskFromOtherAnnotation() {
+		this.spring
+			.register(MethodSecurityServiceEnabledConfig.class,
+					ReactiveMethodSecurityService.MaskAnnotationPostProcessor.class, MyMasker.class)
+			.autowire();
+		ReactiveMethodSecurityService service = this.spring.getContext().getBean(ReactiveMethodSecurityService.class);
+		StepVerifier.create(service.postAuthorizeWithMaskAnnotationUsingBean())
+			.expectNext("ok-masked")
+			.verifyComplete();
+	}
+
+	@Test
+	@WithMockUser(roles = "ADMIN")
+	void postAuthorizeWhenAllowedAndHandlerWithCustomAnnotationUsingBeanThenInvokeMethodNormally() {
+		this.spring
+			.register(MethodSecurityServiceEnabledConfig.class,
+					ReactiveMethodSecurityService.MaskAnnotationPostProcessor.class, MyMasker.class)
+			.autowire();
+		ReactiveMethodSecurityService service = this.spring.getContext().getBean(ReactiveMethodSecurityService.class);
+		StepVerifier.create(service.postAuthorizeWithMaskAnnotationUsingBean()).expectNext("ok").verifyComplete();
+	}
+
+	@Test
+	@WithMockUser
+	void preAuthorizeWhenDeniedAndHandlerWithCustomAnnotationUsingBeanThenHandlerCanUseMaskFromOtherAnnotation() {
+		this.spring
+			.register(MethodSecurityServiceEnabledConfig.class,
+					ReactiveMethodSecurityService.MaskAnnotationHandler.class, MyMasker.class)
+			.autowire();
+		ReactiveMethodSecurityService service = this.spring.getContext().getBean(ReactiveMethodSecurityService.class);
+		StepVerifier.create(service.preAuthorizeWithMaskAnnotationUsingBean()).expectNext("mask").verifyComplete();
+	}
+
+	@Test
+	@WithMockUser(roles = "ADMIN")
+	void preAuthorizeWhenAllowedAndHandlerWithCustomAnnotationUsingBeanThenInvokeMethodNormally() {
+		this.spring
+			.register(MethodSecurityServiceEnabledConfig.class,
+					ReactiveMethodSecurityService.MaskAnnotationHandler.class, MyMasker.class)
+			.autowire();
+		ReactiveMethodSecurityService service = this.spring.getContext().getBean(ReactiveMethodSecurityService.class);
+		StepVerifier.create(service.preAuthorizeWithMaskAnnotationUsingBean()).expectNext("ok").verifyComplete();
+	}
+
+	@Configuration
+	@EnableReactiveMethodSecurity
+	static class MethodSecurityServiceEnabledConfig {
+
+		@Bean
+		ReactiveMethodSecurityService methodSecurityService() {
+			return new ReactiveMethodSecurityServiceImpl();
+		}
+
+	}
+
+}

+ 222 - 0
config/src/test/java/org/springframework/security/config/annotation/method/configuration/ReactiveMethodSecurityService.java

@@ -0,0 +1,222 @@
+/*
+ * Copyright 2002-2024 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.security.config.annotation.method.configuration;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Inherited;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+import org.aopalliance.intercept.MethodInvocation;
+import reactor.core.publisher.Mono;
+
+import org.springframework.context.ApplicationContext;
+import org.springframework.core.annotation.AnnotationUtils;
+import org.springframework.expression.EvaluationContext;
+import org.springframework.expression.Expression;
+import org.springframework.security.access.expression.method.DefaultMethodSecurityExpressionHandler;
+import org.springframework.security.access.prepost.PostAuthorize;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.security.authorization.AuthorizationResult;
+import org.springframework.security.authorization.method.MethodAuthorizationDeniedHandler;
+import org.springframework.security.authorization.method.MethodAuthorizationDeniedPostProcessor;
+import org.springframework.security.authorization.method.MethodInvocationResult;
+import org.springframework.security.core.context.SecurityContextHolder;
+import org.springframework.util.StringUtils;
+
+/**
+ * @author Rob Winch
+ */
+@ReactiveMethodSecurityService.Mask("classmask")
+public interface ReactiveMethodSecurityService {
+
+	@PreAuthorize(value = "hasRole('ADMIN')", handlerClass = StarMaskingHandler.class)
+	Mono<String> preAuthorizeGetCardNumberIfAdmin(String cardNumber);
+
+	@PreAuthorize(value = "hasRole('ADMIN')", handlerClass = StartMaskingHandlerChild.class)
+	Mono<String> preAuthorizeWithHandlerChildGetCardNumberIfAdmin(String cardNumber);
+
+	@PreAuthorize(value = "hasRole('ADMIN')", handlerClass = StarMaskingHandler.class)
+	Mono<String> preAuthorizeThrowAccessDeniedManually();
+
+	@PostAuthorize(value = "hasRole('ADMIN')", postProcessorClass = CardNumberMaskingPostProcessor.class)
+	Mono<String> postAuthorizeGetCardNumberIfAdmin(String cardNumber);
+
+	@PostAuthorize(value = "hasRole('ADMIN')", postProcessorClass = PostMaskingPostProcessor.class)
+	Mono<String> postAuthorizeThrowAccessDeniedManually();
+
+	@PreAuthorize(value = "denyAll()", handlerClass = MaskAnnotationHandler.class)
+	@Mask("methodmask")
+	Mono<String> preAuthorizeDeniedMethodWithMaskAnnotation();
+
+	@PreAuthorize(value = "denyAll()", handlerClass = MaskAnnotationHandler.class)
+	Mono<String> preAuthorizeDeniedMethodWithNoMaskAnnotation();
+
+	@NullDenied(role = "ADMIN")
+	Mono<String> postAuthorizeDeniedWithNullDenied();
+
+	@PostAuthorize(value = "denyAll()", postProcessorClass = MaskAnnotationPostProcessor.class)
+	@Mask("methodmask")
+	Mono<String> postAuthorizeDeniedMethodWithMaskAnnotation();
+
+	@PostAuthorize(value = "denyAll()", postProcessorClass = MaskAnnotationPostProcessor.class)
+	Mono<String> postAuthorizeDeniedMethodWithNoMaskAnnotation();
+
+	@PreAuthorize(value = "hasRole('ADMIN')", handlerClass = MaskAnnotationHandler.class)
+	@Mask(expression = "@myMasker.getMask()")
+	Mono<String> preAuthorizeWithMaskAnnotationUsingBean();
+
+	@PostAuthorize(value = "hasRole('ADMIN')", postProcessorClass = MaskAnnotationPostProcessor.class)
+	@Mask(expression = "@myMasker.getMask(returnObject)")
+	Mono<String> postAuthorizeWithMaskAnnotationUsingBean();
+
+	class StarMaskingHandler implements MethodAuthorizationDeniedHandler {
+
+		@Override
+		public Object handle(MethodInvocation methodInvocation, AuthorizationResult result) {
+			return "***";
+		}
+
+	}
+
+	class StartMaskingHandlerChild extends StarMaskingHandler {
+
+		@Override
+		public Object handle(MethodInvocation methodInvocation, AuthorizationResult result) {
+			return super.handle(methodInvocation, result) + "-child";
+		}
+
+	}
+
+	class MaskAnnotationHandler implements MethodAuthorizationDeniedHandler {
+
+		MaskValueResolver maskValueResolver;
+
+		MaskAnnotationHandler(ApplicationContext context) {
+			this.maskValueResolver = new MaskValueResolver(context);
+		}
+
+		@Override
+		public Object handle(MethodInvocation methodInvocation, AuthorizationResult result) {
+			Mask mask = AnnotationUtils.getAnnotation(methodInvocation.getMethod(), Mask.class);
+			if (mask == null) {
+				mask = AnnotationUtils.getAnnotation(methodInvocation.getMethod().getDeclaringClass(), Mask.class);
+			}
+			return this.maskValueResolver.resolveValue(mask, methodInvocation, null);
+		}
+
+	}
+
+	class MaskAnnotationPostProcessor implements MethodAuthorizationDeniedPostProcessor {
+
+		MaskValueResolver maskValueResolver;
+
+		MaskAnnotationPostProcessor(ApplicationContext context) {
+			this.maskValueResolver = new MaskValueResolver(context);
+		}
+
+		@Override
+		public Object postProcessResult(MethodInvocationResult methodInvocationResult,
+				AuthorizationResult authorizationResult) {
+			MethodInvocation mi = methodInvocationResult.getMethodInvocation();
+			Mask mask = AnnotationUtils.getAnnotation(mi.getMethod(), Mask.class);
+			if (mask == null) {
+				mask = AnnotationUtils.getAnnotation(mi.getMethod().getDeclaringClass(), Mask.class);
+			}
+			return this.maskValueResolver.resolveValue(mask, mi, methodInvocationResult.getResult());
+		}
+
+	}
+
+	class MaskValueResolver {
+
+		DefaultMethodSecurityExpressionHandler expressionHandler;
+
+		MaskValueResolver(ApplicationContext context) {
+			this.expressionHandler = new DefaultMethodSecurityExpressionHandler();
+			this.expressionHandler.setApplicationContext(context);
+		}
+
+		Mono<String> resolveValue(Mask mask, MethodInvocation mi, Object returnObject) {
+			if (StringUtils.hasText(mask.value())) {
+				return Mono.just(mask.value());
+			}
+			Expression expression = this.expressionHandler.getExpressionParser().parseExpression(mask.expression());
+			EvaluationContext evaluationContext = this.expressionHandler
+				.createEvaluationContext(() -> SecurityContextHolder.getContext().getAuthentication(), mi);
+			if (returnObject != null) {
+				this.expressionHandler.setReturnObject(returnObject, evaluationContext);
+			}
+			return Mono.just(expression.getValue(evaluationContext, String.class));
+		}
+
+	}
+
+	class PostMaskingPostProcessor implements MethodAuthorizationDeniedPostProcessor {
+
+		@Override
+		public Object postProcessResult(MethodInvocationResult contextObject, AuthorizationResult result) {
+			return "***";
+		}
+
+	}
+
+	class CardNumberMaskingPostProcessor implements MethodAuthorizationDeniedPostProcessor {
+
+		static String MASK = "****-****-****-";
+
+		@Override
+		public Object postProcessResult(MethodInvocationResult contextObject, AuthorizationResult result) {
+			String cardNumber = (String) contextObject.getResult();
+			return MASK + cardNumber.substring(cardNumber.length() - 4);
+		}
+
+	}
+
+	class NullPostProcessor implements MethodAuthorizationDeniedPostProcessor {
+
+		@Override
+		public Object postProcessResult(MethodInvocationResult methodInvocationResult,
+				AuthorizationResult authorizationResult) {
+			return null;
+		}
+
+	}
+
+	@Target({ ElementType.METHOD, ElementType.TYPE })
+	@Retention(RetentionPolicy.RUNTIME)
+	@Inherited
+	@interface Mask {
+
+		String value() default "";
+
+		String expression() default "";
+
+	}
+
+	@Target({ ElementType.METHOD, ElementType.TYPE })
+	@Retention(RetentionPolicy.RUNTIME)
+	@Inherited
+	@PostAuthorize(value = "hasRole('{value}')", postProcessorClass = NullPostProcessor.class)
+	@interface NullDenied {
+
+		String role();
+
+	}
+
+}

+ 85 - 0
config/src/test/java/org/springframework/security/config/annotation/method/configuration/ReactiveMethodSecurityServiceImpl.java

@@ -0,0 +1,85 @@
+/*
+ * Copyright 2002-2024 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.security.config.annotation.method.configuration;
+
+import reactor.core.publisher.Mono;
+
+import org.springframework.security.access.AccessDeniedException;
+
+public class ReactiveMethodSecurityServiceImpl implements ReactiveMethodSecurityService {
+
+	@Override
+	public Mono<String> preAuthorizeGetCardNumberIfAdmin(String cardNumber) {
+		return Mono.just(cardNumber);
+	}
+
+	@Override
+	public Mono<String> preAuthorizeWithHandlerChildGetCardNumberIfAdmin(String cardNumber) {
+		return Mono.just(cardNumber);
+	}
+
+	@Override
+	public Mono<String> preAuthorizeThrowAccessDeniedManually() {
+		return Mono.error(new AccessDeniedException("Access Denied"));
+	}
+
+	@Override
+	public Mono<String> postAuthorizeGetCardNumberIfAdmin(String cardNumber) {
+		return Mono.just(cardNumber);
+	}
+
+	@Override
+	public Mono<String> postAuthorizeThrowAccessDeniedManually() {
+		return Mono.error(new AccessDeniedException("Access Denied"));
+	}
+
+	@Override
+	public Mono<String> preAuthorizeDeniedMethodWithMaskAnnotation() {
+		return Mono.just("ok");
+	}
+
+	@Override
+	public Mono<String> preAuthorizeDeniedMethodWithNoMaskAnnotation() {
+		return Mono.just("ok");
+	}
+
+	@Override
+	public Mono<String> postAuthorizeDeniedWithNullDenied() {
+		return Mono.just("ok");
+	}
+
+	@Override
+	public Mono<String> postAuthorizeDeniedMethodWithMaskAnnotation() {
+		return Mono.just("ok");
+	}
+
+	@Override
+	public Mono<String> postAuthorizeDeniedMethodWithNoMaskAnnotation() {
+		return Mono.just("ok");
+	}
+
+	@Override
+	public Mono<String> preAuthorizeWithMaskAnnotationUsingBean() {
+		return Mono.just("ok");
+	}
+
+	@Override
+	public Mono<String> postAuthorizeWithMaskAnnotationUsingBean() {
+		return Mono.just("ok");
+	}
+
+}

+ 55 - 0
config/src/test/java/org/springframework/security/config/annotation/method/configuration/UserRecordWithEmailProtected.java

@@ -0,0 +1,55 @@
+/*
+ * Copyright 2002-2024 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.security.config.annotation.method.configuration;
+
+import org.springframework.security.access.prepost.PostAuthorize;
+import org.springframework.security.authorization.AuthorizationResult;
+import org.springframework.security.authorization.method.MethodAuthorizationDeniedPostProcessor;
+import org.springframework.security.authorization.method.MethodInvocationResult;
+
+public class UserRecordWithEmailProtected {
+
+	private final String name;
+
+	private final String email;
+
+	public UserRecordWithEmailProtected(String name, String email) {
+		this.name = name;
+		this.email = email;
+	}
+
+	public String name() {
+		return this.name;
+	}
+
+	@PostAuthorize(value = "hasRole('ADMIN')", postProcessorClass = EmailMaskingPostProcessor.class)
+	public String email() {
+		return this.email;
+	}
+
+	public static class EmailMaskingPostProcessor implements MethodAuthorizationDeniedPostProcessor {
+
+		@Override
+		public Object postProcessResult(MethodInvocationResult methodInvocationResult,
+				AuthorizationResult authorizationResult) {
+			String email = (String) methodInvocationResult.getResult();
+			return email.replaceAll("(^[^@]{3}|(?!^)\\G)[^@]", "$1*");
+		}
+
+	}
+
+}

+ 10 - 1
core/src/main/java/org/springframework/security/access/prepost/PostAuthorize.java

@@ -1,5 +1,5 @@
 /*
- * Copyright 2002-2016 the original author or authors.
+ * Copyright 2002-2024 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -23,6 +23,9 @@ import java.lang.annotation.Retention;
 import java.lang.annotation.RetentionPolicy;
 import java.lang.annotation.Target;
 
+import org.springframework.security.authorization.method.MethodAuthorizationDeniedPostProcessor;
+import org.springframework.security.authorization.method.ThrowingMethodAuthorizationDeniedPostProcessor;
+
 /**
  * Annotation for specifying a method access-control expression which will be evaluated
  * after a method has been invoked.
@@ -42,4 +45,10 @@ public @interface PostAuthorize {
 	 */
 	String value();
 
+	/**
+	 * @return the {@link MethodAuthorizationDeniedPostProcessor} class used to
+	 * post-process access denied
+	 */
+	Class<? extends MethodAuthorizationDeniedPostProcessor> postProcessorClass() default ThrowingMethodAuthorizationDeniedPostProcessor.class;
+
 }

+ 10 - 1
core/src/main/java/org/springframework/security/access/prepost/PreAuthorize.java

@@ -1,5 +1,5 @@
 /*
- * Copyright 2002-2016 the original author or authors.
+ * Copyright 2002-2024 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -23,6 +23,9 @@ import java.lang.annotation.Retention;
 import java.lang.annotation.RetentionPolicy;
 import java.lang.annotation.Target;
 
+import org.springframework.security.authorization.method.MethodAuthorizationDeniedHandler;
+import org.springframework.security.authorization.method.ThrowingMethodAuthorizationDeniedHandler;
+
 /**
  * Annotation for specifying a method access-control expression which will be evaluated to
  * decide whether a method invocation is allowed or not.
@@ -42,4 +45,10 @@ public @interface PreAuthorize {
 	 */
 	String value();
 
+	/**
+	 * @return the {@link MethodAuthorizationDeniedHandler} class used to handle access
+	 * denied
+	 */
+	Class<? extends MethodAuthorizationDeniedHandler> handlerClass() default ThrowingMethodAuthorizationDeniedHandler.class;
+
 }

+ 43 - 0
core/src/main/java/org/springframework/security/authorization/AuthorizationDeniedException.java

@@ -0,0 +1,43 @@
+/*
+ * Copyright 2002-2024 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.security.authorization;
+
+import org.springframework.security.access.AccessDeniedException;
+import org.springframework.util.Assert;
+
+/**
+ * An {@link AccessDeniedException} that contains the {@link AuthorizationResult}
+ *
+ * @author Marcus da Coregio
+ * @since 6.3
+ */
+public class AuthorizationDeniedException extends AccessDeniedException {
+
+	private final AuthorizationResult result;
+
+	public AuthorizationDeniedException(String msg, AuthorizationResult authorizationResult) {
+		super(msg);
+		Assert.notNull(authorizationResult, "authorizationResult cannot be null");
+		Assert.isTrue(!authorizationResult.isGranted(), "Granted authorization results are not supported");
+		this.result = authorizationResult;
+	}
+
+	public AuthorizationResult getAuthorizationResult() {
+		return this.result;
+	}
+
+}

+ 14 - 5
core/src/main/java/org/springframework/security/authorization/method/AuthorizationManagerAfterMethodInterceptor.java

@@ -1,5 +1,5 @@
 /*
- * Copyright 2002-2023 the original author or authors.
+ * Copyright 2002-2024 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -55,6 +55,8 @@ public final class AuthorizationManagerAfterMethodInterceptor implements Authori
 
 	private final AuthorizationManager<MethodInvocationResult> authorizationManager;
 
+	private final MethodAuthorizationDeniedPostProcessor defaultPostProcessor = new ThrowingMethodAuthorizationDeniedPostProcessor();
+
 	private int order;
 
 	private AuthorizationEventPublisher eventPublisher = AuthorizationManagerAfterMethodInterceptor::noPublish;
@@ -116,8 +118,7 @@ public final class AuthorizationManagerAfterMethodInterceptor implements Authori
 	@Override
 	public Object invoke(MethodInvocation mi) throws Throwable {
 		Object result = mi.proceed();
-		attemptAuthorization(mi, result);
-		return result;
+		return attemptAuthorization(mi, result);
 	}
 
 	@Override
@@ -168,7 +169,7 @@ public final class AuthorizationManagerAfterMethodInterceptor implements Authori
 		this.securityContextHolderStrategy = () -> strategy;
 	}
 
-	private void attemptAuthorization(MethodInvocation mi, Object result) {
+	private Object attemptAuthorization(MethodInvocation mi, Object result) {
 		this.logger.debug(LogMessage.of(() -> "Authorizing method invocation " + mi));
 		MethodInvocationResult object = new MethodInvocationResult(mi, result);
 		AuthorizationDecision decision = this.authorizationManager.check(this::getAuthentication, object);
@@ -176,9 +177,17 @@ public final class AuthorizationManagerAfterMethodInterceptor implements Authori
 		if (decision != null && !decision.isGranted()) {
 			this.logger.debug(LogMessage.of(() -> "Failed to authorize " + mi + " with authorization manager "
 					+ this.authorizationManager + " and decision " + decision));
-			throw new AccessDeniedException("Access Denied");
+			return postProcess(object, decision);
 		}
 		this.logger.debug(LogMessage.of(() -> "Authorized method invocation " + mi));
+		return result;
+	}
+
+	private Object postProcess(MethodInvocationResult mi, AuthorizationDecision decision) {
+		if (decision instanceof MethodAuthorizationDeniedPostProcessor postProcessableDecision) {
+			return postProcessableDecision.postProcessResult(mi, decision);
+		}
+		return this.defaultPostProcessor.postProcessResult(mi, decision);
 	}
 
 	private Authentication getAuthentication() {

+ 26 - 4
core/src/main/java/org/springframework/security/authorization/method/AuthorizationManagerAfterReactiveMethodInterceptor.java

@@ -1,5 +1,5 @@
 /*
- * Copyright 2002-2023 the original author or authors.
+ * Copyright 2002-2024 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -33,6 +33,7 @@ import org.springframework.core.MethodParameter;
 import org.springframework.core.ReactiveAdapter;
 import org.springframework.core.ReactiveAdapterRegistry;
 import org.springframework.security.access.prepost.PostAuthorize;
+import org.springframework.security.authorization.AuthorizationDecision;
 import org.springframework.security.authorization.ReactiveAuthorizationManager;
 import org.springframework.security.core.Authentication;
 import org.springframework.util.Assert;
@@ -57,6 +58,8 @@ public final class AuthorizationManagerAfterReactiveMethodInterceptor implements
 
 	private int order = AuthorizationInterceptorsOrder.LAST.getOrder();
 
+	private final MethodAuthorizationDeniedPostProcessor defaultPostProcessor = new ThrowingMethodAuthorizationDeniedPostProcessor();
+
 	/**
 	 * Creates an instance for the {@link PostAuthorize} annotation.
 	 * @return the {@link AuthorizationManagerAfterReactiveMethodInterceptor} to use
@@ -144,9 +147,28 @@ public final class AuthorizationManagerAfterReactiveMethodInterceptor implements
 		return adapter != null && adapter.isMultiValue();
 	}
 
-	private Mono<?> postAuthorize(Mono<Authentication> authentication, MethodInvocation mi, Object result) {
-		return this.authorizationManager.verify(authentication, new MethodInvocationResult(mi, result))
-			.thenReturn(result);
+	private Mono<Object> postAuthorize(Mono<Authentication> authentication, MethodInvocation mi, Object result) {
+		MethodInvocationResult invocationResult = new MethodInvocationResult(mi, result);
+		return this.authorizationManager.check(authentication, invocationResult)
+			.switchIfEmpty(Mono.just(new AuthorizationDecision(false)))
+			.flatMap((decision) -> postProcess(decision, invocationResult));
+	}
+
+	private Mono<Object> postProcess(AuthorizationDecision decision, MethodInvocationResult methodInvocationResult) {
+		if (decision.isGranted()) {
+			return Mono.just(methodInvocationResult.getResult());
+		}
+		return Mono.fromSupplier(() -> {
+			if (decision instanceof MethodAuthorizationDeniedPostProcessor postProcessableDecision) {
+				return postProcessableDecision.postProcessResult(methodInvocationResult, decision);
+			}
+			return this.defaultPostProcessor.postProcessResult(methodInvocationResult, decision);
+		}).flatMap((processedResult) -> {
+			if (Mono.class.isAssignableFrom(processedResult.getClass())) {
+				return (Mono<?>) processedResult;
+			}
+			return Mono.justOrEmpty(processedResult);
+		});
 	}
 
 	@Override

+ 14 - 5
core/src/main/java/org/springframework/security/authorization/method/AuthorizationManagerBeforeMethodInterceptor.java

@@ -1,5 +1,5 @@
 /*
- * Copyright 2002-2023 the original author or authors.
+ * Copyright 2002-2024 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -59,6 +59,8 @@ public final class AuthorizationManagerBeforeMethodInterceptor implements Author
 
 	private final AuthorizationManager<MethodInvocation> authorizationManager;
 
+	private final MethodAuthorizationDeniedHandler defaultHandler = new ThrowingMethodAuthorizationDeniedHandler();
+
 	private int order = AuthorizationInterceptorsOrder.FIRST.getOrder();
 
 	private AuthorizationEventPublisher eventPublisher = AuthorizationManagerBeforeMethodInterceptor::noPublish;
@@ -190,8 +192,7 @@ public final class AuthorizationManagerBeforeMethodInterceptor implements Author
 	 */
 	@Override
 	public Object invoke(MethodInvocation mi) throws Throwable {
-		attemptAuthorization(mi);
-		return mi.proceed();
+		return attemptAuthorization(mi);
 	}
 
 	@Override
@@ -242,16 +243,24 @@ public final class AuthorizationManagerBeforeMethodInterceptor implements Author
 		this.securityContextHolderStrategy = () -> securityContextHolderStrategy;
 	}
 
-	private void attemptAuthorization(MethodInvocation mi) {
+	private Object attemptAuthorization(MethodInvocation mi) throws Throwable {
 		this.logger.debug(LogMessage.of(() -> "Authorizing method invocation " + mi));
 		AuthorizationDecision decision = this.authorizationManager.check(this::getAuthentication, mi);
 		this.eventPublisher.publishAuthorizationEvent(this::getAuthentication, mi, decision);
 		if (decision != null && !decision.isGranted()) {
 			this.logger.debug(LogMessage.of(() -> "Failed to authorize " + mi + " with authorization manager "
 					+ this.authorizationManager + " and decision " + decision));
-			throw new AccessDeniedException("Access Denied");
+			return handle(mi, decision);
 		}
 		this.logger.debug(LogMessage.of(() -> "Authorized method invocation " + mi));
+		return mi.proceed();
+	}
+
+	private Object handle(MethodInvocation mi, AuthorizationDecision decision) {
+		if (decision instanceof MethodAuthorizationDeniedHandler handler) {
+			return handler.handle(mi, decision);
+		}
+		return this.defaultHandler.handle(mi, decision);
 	}
 
 	private Authentication getAuthentication() {

+ 47 - 10
core/src/main/java/org/springframework/security/authorization/method/AuthorizationManagerBeforeReactiveMethodInterceptor.java

@@ -1,5 +1,5 @@
 /*
- * Copyright 2002-2023 the original author or authors.
+ * Copyright 2002-2024 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -32,6 +32,7 @@ import org.springframework.core.MethodParameter;
 import org.springframework.core.ReactiveAdapter;
 import org.springframework.core.ReactiveAdapterRegistry;
 import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.security.authorization.AuthorizationDecision;
 import org.springframework.security.authorization.ReactiveAuthorizationManager;
 import org.springframework.security.core.Authentication;
 import org.springframework.util.Assert;
@@ -57,6 +58,8 @@ public final class AuthorizationManagerBeforeReactiveMethodInterceptor implement
 
 	private int order = AuthorizationInterceptorsOrder.FIRST.getOrder();
 
+	private final MethodAuthorizationDeniedHandler defaultHandler = new ThrowingMethodAuthorizationDeniedHandler();
+
 	/**
 	 * Creates an instance for the {@link PreAuthorize} annotation.
 	 * @return the {@link AuthorizationManagerBeforeReactiveMethodInterceptor} to use
@@ -112,31 +115,65 @@ public final class AuthorizationManagerBeforeReactiveMethodInterceptor implement
 						+ " must return an instance of org.reactivestreams.Publisher "
 						+ "(for example, a Mono or Flux) or the function must be a Kotlin coroutine "
 						+ "in order to support Reactor Context");
-		Mono<Authentication> authentication = ReactiveAuthenticationUtils.getAuthentication();
 		ReactiveAdapter adapter = ReactiveAdapterRegistry.getSharedInstance().getAdapter(type);
-		Mono<Void> preAuthorize = this.authorizationManager.verify(authentication, mi);
 		if (hasFlowReturnType) {
 			if (isSuspendingFunction) {
-				return preAuthorize.thenMany(Flux.defer(() -> ReactiveMethodInvocationUtils.proceed(mi)));
+				return preAuthorized(mi, Flux.defer(() -> ReactiveMethodInvocationUtils.proceed(mi)));
 			}
 			else {
 				Assert.state(adapter != null, () -> "The returnType " + type + " on " + method
 						+ " must have a org.springframework.core.ReactiveAdapter registered");
-				Flux<?> response = preAuthorize
-					.thenMany(Flux.defer(() -> adapter.toPublisher(ReactiveMethodInvocationUtils.proceed(mi))));
+				Flux<Object> response = preAuthorized(mi,
+						Flux.defer(() -> adapter.toPublisher(ReactiveMethodInvocationUtils.proceed(mi))));
 				return KotlinDelegate.asFlow(response);
 			}
 		}
 		if (isMultiValue(type, adapter)) {
-			Publisher<?> publisher = Flux.defer(() -> ReactiveMethodInvocationUtils.proceed(mi));
-			Flux<?> result = preAuthorize.thenMany(publisher);
+			Flux<?> result = preAuthorized(mi, Flux.defer(() -> ReactiveMethodInvocationUtils.proceed(mi)));
 			return (adapter != null) ? adapter.fromPublisher(result) : result;
 		}
-		Mono<?> publisher = Mono.defer(() -> ReactiveMethodInvocationUtils.proceed(mi));
-		Mono<?> result = preAuthorize.then(publisher);
+		Mono<?> result = preAuthorized(mi, Mono.defer(() -> ReactiveMethodInvocationUtils.proceed(mi)));
 		return (adapter != null) ? adapter.fromPublisher(result) : result;
 	}
 
+	private Flux<Object> preAuthorized(MethodInvocation mi, Flux<Object> mapping) {
+		Mono<Authentication> authentication = ReactiveAuthenticationUtils.getAuthentication();
+		return this.authorizationManager.check(authentication, mi)
+			.switchIfEmpty(Mono.just(new AuthorizationDecision(false)))
+			.flatMapMany((decision) -> {
+				if (decision.isGranted()) {
+					return mapping;
+				}
+				return postProcess(decision, mi);
+			});
+	}
+
+	private Mono<Object> preAuthorized(MethodInvocation mi, Mono<Object> mapping) {
+		Mono<Authentication> authentication = ReactiveAuthenticationUtils.getAuthentication();
+		return this.authorizationManager.check(authentication, mi)
+			.switchIfEmpty(Mono.just(new AuthorizationDecision(false)))
+			.flatMap((decision) -> {
+				if (decision.isGranted()) {
+					return mapping;
+				}
+				return postProcess(decision, mi);
+			});
+	}
+
+	private Mono<Object> postProcess(AuthorizationDecision decision, MethodInvocation mi) {
+		return Mono.fromSupplier(() -> {
+			if (decision instanceof MethodAuthorizationDeniedHandler handler) {
+				return handler.handle(mi, decision);
+			}
+			return this.defaultHandler.handle(mi, decision);
+		}).flatMap((result) -> {
+			if (Mono.class.isAssignableFrom(result.getClass())) {
+				return (Mono<?>) result;
+			}
+			return Mono.justOrEmpty(result);
+		});
+	}
+
 	private boolean isMultiValue(Class<?> returnType, ReactiveAdapter adapter) {
 		if (Flux.class.isAssignableFrom(returnType)) {
 			return true;

+ 46 - 0
core/src/main/java/org/springframework/security/authorization/method/MethodAuthorizationDeniedHandler.java

@@ -0,0 +1,46 @@
+/*
+ * Copyright 2002-2024 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.security.authorization.method;
+
+import org.aopalliance.intercept.MethodInvocation;
+
+import org.springframework.lang.Nullable;
+import org.springframework.security.authorization.AuthorizationResult;
+
+/**
+ * An interface used to define a strategy to handle denied method invocations
+ *
+ * @author Marcus da Coregio
+ * @since 6.3
+ * @see org.springframework.security.access.prepost.PreAuthorize
+ */
+public interface MethodAuthorizationDeniedHandler {
+
+	/**
+	 * Handle denied method invocations, implementations might either throw an
+	 * {@link org.springframework.security.access.AccessDeniedException} or a replacement
+	 * result instead of invoking the method, e.g. a masked value.
+	 * @param methodInvocation the {@link MethodInvocation} related to the authorization
+	 * denied
+	 * @param authorizationResult the authorization denied result
+	 * @return a replacement result for the denied method invocation, or null, or a
+	 * {@link reactor.core.publisher.Mono} for reactive applications
+	 */
+	@Nullable
+	Object handle(MethodInvocation methodInvocation, AuthorizationResult authorizationResult);
+
+}

+ 46 - 0
core/src/main/java/org/springframework/security/authorization/method/MethodAuthorizationDeniedPostProcessor.java

@@ -0,0 +1,46 @@
+/*
+ * Copyright 2002-2024 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.security.authorization.method;
+
+import org.springframework.lang.Nullable;
+import org.springframework.security.authorization.AuthorizationResult;
+
+/**
+ * An interface to define a strategy to handle denied method invocation results
+ *
+ * @author Marcus da Coregio
+ * @since 6.3
+ * @see org.springframework.security.access.prepost.PostAuthorize
+ */
+public interface MethodAuthorizationDeniedPostProcessor {
+
+	/**
+	 * Post-process the denied result produced by a method invocation, implementations
+	 * might either throw an
+	 * {@link org.springframework.security.access.AccessDeniedException} or return a
+	 * replacement result instead of the denied result, e.g. a masked value.
+	 * @param methodInvocationResult the object containing the method invocation and the
+	 * result produced
+	 * @param authorizationResult the {@link AuthorizationResult} containing the
+	 * authorization denied details
+	 * @return a replacement result for the denied result, or null, or a
+	 * {@link reactor.core.publisher.Mono} for reactive applications
+	 */
+	@Nullable
+	Object postProcessResult(MethodInvocationResult methodInvocationResult, AuthorizationResult authorizationResult);
+
+}

+ 41 - 0
core/src/main/java/org/springframework/security/authorization/method/PostAuthorizeAuthorizationDecision.java

@@ -0,0 +1,41 @@
+/*
+ * Copyright 2002-2024 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.security.authorization.method;
+
+import org.springframework.expression.Expression;
+import org.springframework.security.authorization.AuthorizationResult;
+import org.springframework.security.authorization.ExpressionAuthorizationDecision;
+import org.springframework.util.Assert;
+
+class PostAuthorizeAuthorizationDecision extends ExpressionAuthorizationDecision
+		implements MethodAuthorizationDeniedPostProcessor {
+
+	private final MethodAuthorizationDeniedPostProcessor postProcessor;
+
+	PostAuthorizeAuthorizationDecision(boolean granted, Expression expression,
+			MethodAuthorizationDeniedPostProcessor postProcessor) {
+		super(granted, expression);
+		Assert.notNull(postProcessor, "postProcessor cannot be null");
+		this.postProcessor = postProcessor;
+	}
+
+	@Override
+	public Object postProcessResult(MethodInvocationResult methodInvocationResult, AuthorizationResult result) {
+		return this.postProcessor.postProcessResult(methodInvocationResult, result);
+	}
+
+}

+ 18 - 4
core/src/main/java/org/springframework/security/authorization/method/PostAuthorizeAuthorizationManager.java

@@ -1,5 +1,5 @@
 /*
- * Copyright 2002-2022 the original author or authors.
+ * Copyright 2002-2024 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -20,13 +20,13 @@ import java.util.function.Supplier;
 
 import org.aopalliance.intercept.MethodInvocation;
 
+import org.springframework.context.ApplicationContext;
 import org.springframework.expression.EvaluationContext;
 import org.springframework.security.access.expression.ExpressionUtils;
 import org.springframework.security.access.expression.method.MethodSecurityExpressionHandler;
 import org.springframework.security.access.prepost.PostAuthorize;
 import org.springframework.security.authorization.AuthorizationDecision;
 import org.springframework.security.authorization.AuthorizationManager;
-import org.springframework.security.authorization.ExpressionAuthorizationDecision;
 import org.springframework.security.core.Authentication;
 
 /**
@@ -61,6 +61,18 @@ public final class PostAuthorizeAuthorizationManager implements AuthorizationMan
 		this.registry.setTemplateDefaults(defaults);
 	}
 
+	/**
+	 * Invokes
+	 * {@link PostAuthorizeExpressionAttributeRegistry#setApplicationContext(ApplicationContext)}
+	 * with the provided {@link ApplicationContext}.
+	 * @param context the {@link ApplicationContext}
+	 * @since 6.3
+	 * @see PreAuthorizeExpressionAttributeRegistry#setApplicationContext(ApplicationContext)
+	 */
+	public void setApplicationContext(ApplicationContext context) {
+		this.registry.setApplicationContext(context);
+	}
+
 	/**
 	 * Determine if an {@link Authentication} has access to the returned object by
 	 * evaluating the {@link PostAuthorize} annotation that the {@link MethodInvocation}
@@ -76,11 +88,13 @@ public final class PostAuthorizeAuthorizationManager implements AuthorizationMan
 		if (attribute == ExpressionAttribute.NULL_ATTRIBUTE) {
 			return null;
 		}
+		PostAuthorizeExpressionAttribute postAuthorizeAttribute = (PostAuthorizeExpressionAttribute) attribute;
 		MethodSecurityExpressionHandler expressionHandler = this.registry.getExpressionHandler();
 		EvaluationContext ctx = expressionHandler.createEvaluationContext(authentication, mi.getMethodInvocation());
 		expressionHandler.setReturnObject(mi.getResult(), ctx);
-		boolean granted = ExpressionUtils.evaluateAsBoolean(attribute.getExpression(), ctx);
-		return new ExpressionAuthorizationDecision(granted, attribute.getExpression());
+		boolean granted = ExpressionUtils.evaluateAsBoolean(postAuthorizeAttribute.getExpression(), ctx);
+		return new PostAuthorizeAuthorizationDecision(granted, postAuthorizeAttribute.getExpression(),
+				postAuthorizeAttribute.getPostProcessor());
 	}
 
 }

+ 42 - 0
core/src/main/java/org/springframework/security/authorization/method/PostAuthorizeExpressionAttribute.java

@@ -0,0 +1,42 @@
+/*
+ * Copyright 2002-2024 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.security.authorization.method;
+
+import org.springframework.expression.Expression;
+import org.springframework.util.Assert;
+
+/**
+ * An {@link ExpressionAttribute} that carries additional properties for
+ * {@code @PostAuthorize}.
+ *
+ * @author Marcus da Coregio
+ */
+class PostAuthorizeExpressionAttribute extends ExpressionAttribute {
+
+	private final MethodAuthorizationDeniedPostProcessor postProcessor;
+
+	PostAuthorizeExpressionAttribute(Expression expression, MethodAuthorizationDeniedPostProcessor postProcessor) {
+		super(expression);
+		Assert.notNull(postProcessor, "postProcessor cannot be null");
+		this.postProcessor = postProcessor;
+	}
+
+	MethodAuthorizationDeniedPostProcessor getPostProcessor() {
+		return this.postProcessor;
+	}
+
+}

+ 40 - 1
core/src/main/java/org/springframework/security/authorization/method/PostAuthorizeExpressionAttributeRegistry.java

@@ -18,13 +18,16 @@ package org.springframework.security.authorization.method;
 
 import java.lang.reflect.AnnotatedElement;
 import java.lang.reflect.Method;
+import java.util.Arrays;
 import java.util.function.Function;
 
 import reactor.util.annotation.NonNull;
 
 import org.springframework.aop.support.AopUtils;
+import org.springframework.context.ApplicationContext;
 import org.springframework.expression.Expression;
 import org.springframework.security.access.prepost.PostAuthorize;
+import org.springframework.util.Assert;
 
 /**
  * For internal use only, as this contract is likely to change.
@@ -35,6 +38,14 @@ import org.springframework.security.access.prepost.PostAuthorize;
  */
 final class PostAuthorizeExpressionAttributeRegistry extends AbstractExpressionAttributeRegistry<ExpressionAttribute> {
 
+	private final MethodAuthorizationDeniedPostProcessor defaultPostProcessor = new ThrowingMethodAuthorizationDeniedPostProcessor();
+
+	private Function<Class<? extends MethodAuthorizationDeniedPostProcessor>, MethodAuthorizationDeniedPostProcessor> postProcessorResolver;
+
+	PostAuthorizeExpressionAttributeRegistry() {
+		this.postProcessorResolver = (clazz) -> this.defaultPostProcessor;
+	}
+
 	@NonNull
 	@Override
 	ExpressionAttribute resolveAttribute(Method method, Class<?> targetClass) {
@@ -44,7 +55,9 @@ final class PostAuthorizeExpressionAttributeRegistry extends AbstractExpressionA
 			return ExpressionAttribute.NULL_ATTRIBUTE;
 		}
 		Expression expression = getExpressionHandler().getExpressionParser().parseExpression(postAuthorize.value());
-		return new ExpressionAttribute(expression);
+		MethodAuthorizationDeniedPostProcessor postProcessor = this.postProcessorResolver
+			.apply(postAuthorize.postProcessorClass());
+		return new PostAuthorizeExpressionAttribute(expression, postProcessor);
 	}
 
 	private PostAuthorize findPostAuthorizeAnnotation(Method method, Class<?> targetClass) {
@@ -53,4 +66,30 @@ final class PostAuthorizeExpressionAttributeRegistry extends AbstractExpressionA
 		return (postAuthorize != null) ? postAuthorize : lookup.apply(targetClass(method, targetClass));
 	}
 
+	/**
+	 * Uses the provided {@link ApplicationContext} to resolve the
+	 * {@link MethodAuthorizationDeniedPostProcessor} from {@link PostAuthorize}
+	 * @param context the {@link ApplicationContext} to use
+	 */
+	void setApplicationContext(ApplicationContext context) {
+		Assert.notNull(context, "context cannot be null");
+		this.postProcessorResolver = (postProcessorClass) -> resolvePostProcessor(context, postProcessorClass);
+	}
+
+	private MethodAuthorizationDeniedPostProcessor resolvePostProcessor(ApplicationContext context,
+			Class<? extends MethodAuthorizationDeniedPostProcessor> postProcessorClass) {
+		if (postProcessorClass == this.defaultPostProcessor.getClass()) {
+			return this.defaultPostProcessor;
+		}
+		String[] beanNames = context.getBeanNamesForType(postProcessorClass);
+		if (beanNames.length == 0) {
+			throw new IllegalStateException("Could not find a bean of type " + postProcessorClass.getName());
+		}
+		if (beanNames.length > 1) {
+			throw new IllegalStateException("Expected to find a single bean of type " + postProcessorClass.getName()
+					+ " but found " + Arrays.toString(beanNames));
+		}
+		return context.getBean(beanNames[0], postProcessorClass);
+	}
+
 }

+ 8 - 2
core/src/main/java/org/springframework/security/authorization/method/PostAuthorizeReactiveAuthorizationManager.java

@@ -1,5 +1,5 @@
 /*
- * Copyright 2002-2022 the original author or authors.
+ * Copyright 2002-2024 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -19,6 +19,7 @@ package org.springframework.security.authorization.method;
 import org.aopalliance.intercept.MethodInvocation;
 import reactor.core.publisher.Mono;
 
+import org.springframework.context.ApplicationContext;
 import org.springframework.security.access.expression.method.DefaultMethodSecurityExpressionHandler;
 import org.springframework.security.access.expression.method.MethodSecurityExpressionHandler;
 import org.springframework.security.access.prepost.PostAuthorize;
@@ -61,6 +62,10 @@ public final class PostAuthorizeReactiveAuthorizationManager
 		this.registry.setTemplateDefaults(defaults);
 	}
 
+	public void setApplicationContext(ApplicationContext context) {
+		this.registry.setApplicationContext(context);
+	}
+
 	/**
 	 * Determines if an {@link Authentication} has access to the returned object from the
 	 * {@link MethodInvocation} by evaluating an expression from the {@link PostAuthorize}
@@ -77,13 +82,14 @@ public final class PostAuthorizeReactiveAuthorizationManager
 		if (attribute == ExpressionAttribute.NULL_ATTRIBUTE) {
 			return Mono.empty();
 		}
+		PostAuthorizeExpressionAttribute postAuthorizeAttribute = (PostAuthorizeExpressionAttribute) attribute;
 		MethodSecurityExpressionHandler expressionHandler = this.registry.getExpressionHandler();
 		// @formatter:off
 		return authentication
 				.map((auth) -> expressionHandler.createEvaluationContext(auth, mi))
 				.doOnNext((ctx) -> expressionHandler.setReturnObject(result.getResult(), ctx))
 				.flatMap((ctx) -> ReactiveExpressionUtils.evaluateAsBoolean(attribute.getExpression(), ctx))
-				.map((granted) -> new ExpressionAttributeAuthorizationDecision(granted, attribute));
+				.map((granted) -> new PostAuthorizeAuthorizationDecision(granted, postAuthorizeAttribute.getExpression(), postAuthorizeAttribute.getPostProcessor()));
 		// @formatter:on
 	}
 

+ 43 - 0
core/src/main/java/org/springframework/security/authorization/method/PreAuthorizeAuthorizationDecision.java

@@ -0,0 +1,43 @@
+/*
+ * Copyright 2002-2024 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.security.authorization.method;
+
+import org.aopalliance.intercept.MethodInvocation;
+
+import org.springframework.expression.Expression;
+import org.springframework.security.authorization.AuthorizationResult;
+import org.springframework.security.authorization.ExpressionAuthorizationDecision;
+import org.springframework.util.Assert;
+
+class PreAuthorizeAuthorizationDecision extends ExpressionAuthorizationDecision
+		implements MethodAuthorizationDeniedHandler {
+
+	private final MethodAuthorizationDeniedHandler handler;
+
+	PreAuthorizeAuthorizationDecision(boolean granted, Expression expression,
+			MethodAuthorizationDeniedHandler handler) {
+		super(granted, expression);
+		Assert.notNull(handler, "handler cannot be null");
+		this.handler = handler;
+	}
+
+	@Override
+	public Object handle(MethodInvocation methodInvocation, AuthorizationResult result) {
+		return this.handler.handle(methodInvocation, result);
+	}
+
+}

+ 10 - 4
core/src/main/java/org/springframework/security/authorization/method/PreAuthorizeAuthorizationManager.java

@@ -1,5 +1,5 @@
 /*
- * Copyright 2002-2022 the original author or authors.
+ * Copyright 2002-2024 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -20,13 +20,13 @@ import java.util.function.Supplier;
 
 import org.aopalliance.intercept.MethodInvocation;
 
+import org.springframework.context.ApplicationContext;
 import org.springframework.expression.EvaluationContext;
 import org.springframework.security.access.expression.ExpressionUtils;
 import org.springframework.security.access.expression.method.MethodSecurityExpressionHandler;
 import org.springframework.security.access.prepost.PreAuthorize;
 import org.springframework.security.authorization.AuthorizationDecision;
 import org.springframework.security.authorization.AuthorizationManager;
-import org.springframework.security.authorization.ExpressionAuthorizationDecision;
 import org.springframework.security.core.Authentication;
 
 /**
@@ -61,6 +61,10 @@ public final class PreAuthorizeAuthorizationManager implements AuthorizationMana
 		this.registry.setTemplateDefaults(defaults);
 	}
 
+	public void setApplicationContext(ApplicationContext context) {
+		this.registry.setApplicationContext(context);
+	}
+
 	/**
 	 * Determine if an {@link Authentication} has access to a method by evaluating an
 	 * expression from the {@link PreAuthorize} annotation that the
@@ -76,9 +80,11 @@ public final class PreAuthorizeAuthorizationManager implements AuthorizationMana
 		if (attribute == ExpressionAttribute.NULL_ATTRIBUTE) {
 			return null;
 		}
+		PreAuthorizeExpressionAttribute preAuthorizeAttribute = (PreAuthorizeExpressionAttribute) attribute;
 		EvaluationContext ctx = this.registry.getExpressionHandler().createEvaluationContext(authentication, mi);
-		boolean granted = ExpressionUtils.evaluateAsBoolean(attribute.getExpression(), ctx);
-		return new ExpressionAuthorizationDecision(granted, attribute.getExpression());
+		boolean granted = ExpressionUtils.evaluateAsBoolean(preAuthorizeAttribute.getExpression(), ctx);
+		return new PreAuthorizeAuthorizationDecision(granted, preAuthorizeAttribute.getExpression(),
+				preAuthorizeAttribute.getHandler());
 	}
 
 }

+ 42 - 0
core/src/main/java/org/springframework/security/authorization/method/PreAuthorizeExpressionAttribute.java

@@ -0,0 +1,42 @@
+/*
+ * Copyright 2002-2024 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.security.authorization.method;
+
+import org.springframework.expression.Expression;
+import org.springframework.util.Assert;
+
+/**
+ * An {@link ExpressionAttribute} that carries additional properties for
+ * {@code @PreAuthorize}.
+ *
+ * @author Marcus da Coregio
+ */
+class PreAuthorizeExpressionAttribute extends ExpressionAttribute {
+
+	private final MethodAuthorizationDeniedHandler handler;
+
+	PreAuthorizeExpressionAttribute(Expression expression, MethodAuthorizationDeniedHandler handler) {
+		super(expression);
+		Assert.notNull(handler, "handler cannot be null");
+		this.handler = handler;
+	}
+
+	MethodAuthorizationDeniedHandler getHandler() {
+		return this.handler;
+	}
+
+}

+ 39 - 1
core/src/main/java/org/springframework/security/authorization/method/PreAuthorizeExpressionAttributeRegistry.java

@@ -18,13 +18,16 @@ package org.springframework.security.authorization.method;
 
 import java.lang.reflect.AnnotatedElement;
 import java.lang.reflect.Method;
+import java.util.Arrays;
 import java.util.function.Function;
 
 import reactor.util.annotation.NonNull;
 
 import org.springframework.aop.support.AopUtils;
+import org.springframework.context.ApplicationContext;
 import org.springframework.expression.Expression;
 import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.util.Assert;
 
 /**
  * For internal use only, as this contract is likely to change.
@@ -35,6 +38,14 @@ import org.springframework.security.access.prepost.PreAuthorize;
  */
 final class PreAuthorizeExpressionAttributeRegistry extends AbstractExpressionAttributeRegistry<ExpressionAttribute> {
 
+	private final MethodAuthorizationDeniedHandler defaultHandler = new ThrowingMethodAuthorizationDeniedHandler();
+
+	private Function<Class<? extends MethodAuthorizationDeniedHandler>, MethodAuthorizationDeniedHandler> handlerResolver;
+
+	PreAuthorizeExpressionAttributeRegistry() {
+		this.handlerResolver = (clazz) -> this.defaultHandler;
+	}
+
 	@NonNull
 	@Override
 	ExpressionAttribute resolveAttribute(Method method, Class<?> targetClass) {
@@ -44,7 +55,8 @@ final class PreAuthorizeExpressionAttributeRegistry extends AbstractExpressionAt
 			return ExpressionAttribute.NULL_ATTRIBUTE;
 		}
 		Expression expression = getExpressionHandler().getExpressionParser().parseExpression(preAuthorize.value());
-		return new ExpressionAttribute(expression);
+		MethodAuthorizationDeniedHandler handler = this.handlerResolver.apply(preAuthorize.handlerClass());
+		return new PreAuthorizeExpressionAttribute(expression, handler);
 	}
 
 	private PreAuthorize findPreAuthorizeAnnotation(Method method, Class<?> targetClass) {
@@ -53,4 +65,30 @@ final class PreAuthorizeExpressionAttributeRegistry extends AbstractExpressionAt
 		return (preAuthorize != null) ? preAuthorize : lookup.apply(targetClass(method, targetClass));
 	}
 
+	/**
+	 * Uses the provided {@link ApplicationContext} to resolve the
+	 * {@link MethodAuthorizationDeniedHandler} from {@link PreAuthorize}.
+	 * @param context the {@link ApplicationContext} to use
+	 */
+	void setApplicationContext(ApplicationContext context) {
+		Assert.notNull(context, "context cannot be null");
+		this.handlerResolver = (clazz) -> resolveHandler(context, clazz);
+	}
+
+	private MethodAuthorizationDeniedHandler resolveHandler(ApplicationContext context,
+			Class<? extends MethodAuthorizationDeniedHandler> handlerClass) {
+		if (handlerClass == this.defaultHandler.getClass()) {
+			return this.defaultHandler;
+		}
+		String[] beanNames = context.getBeanNamesForType(handlerClass);
+		if (beanNames.length == 0) {
+			throw new IllegalStateException("Could not find a bean of type " + handlerClass.getName());
+		}
+		if (beanNames.length > 1) {
+			throw new IllegalStateException("Expected to find a single bean of type " + handlerClass.getName()
+					+ " but found " + Arrays.toString(beanNames));
+		}
+		return context.getBean(beanNames[0], handlerClass);
+	}
+
 }

+ 8 - 2
core/src/main/java/org/springframework/security/authorization/method/PreAuthorizeReactiveAuthorizationManager.java

@@ -1,5 +1,5 @@
 /*
- * Copyright 2002-2022 the original author or authors.
+ * Copyright 2002-2024 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -19,6 +19,7 @@ package org.springframework.security.authorization.method;
 import org.aopalliance.intercept.MethodInvocation;
 import reactor.core.publisher.Mono;
 
+import org.springframework.context.ApplicationContext;
 import org.springframework.security.access.expression.method.DefaultMethodSecurityExpressionHandler;
 import org.springframework.security.access.expression.method.MethodSecurityExpressionHandler;
 import org.springframework.security.access.prepost.PreAuthorize;
@@ -60,6 +61,10 @@ public final class PreAuthorizeReactiveAuthorizationManager implements ReactiveA
 		this.registry.setTemplateDefaults(defaults);
 	}
 
+	public void setApplicationContext(ApplicationContext context) {
+		this.registry.setApplicationContext(context);
+	}
+
 	/**
 	 * Determines if an {@link Authentication} has access to the {@link MethodInvocation}
 	 * by evaluating an expression from the {@link PreAuthorize} annotation.
@@ -74,11 +79,12 @@ public final class PreAuthorizeReactiveAuthorizationManager implements ReactiveA
 		if (attribute == ExpressionAttribute.NULL_ATTRIBUTE) {
 			return Mono.empty();
 		}
+		PreAuthorizeExpressionAttribute preAuthorizeAttribute = (PreAuthorizeExpressionAttribute) attribute;
 		// @formatter:off
 		return authentication
 				.map((auth) -> this.registry.getExpressionHandler().createEvaluationContext(auth, mi))
 				.flatMap((ctx) -> ReactiveExpressionUtils.evaluateAsBoolean(attribute.getExpression(), ctx))
-				.map((granted) -> new ExpressionAttributeAuthorizationDecision(granted, attribute));
+				.map((granted) -> new PreAuthorizeAuthorizationDecision(granted, preAuthorizeAttribute.getExpression(), preAuthorizeAttribute.getHandler()));
 		// @formatter:on
 	}
 

+ 38 - 0
core/src/main/java/org/springframework/security/authorization/method/ThrowingMethodAuthorizationDeniedHandler.java

@@ -0,0 +1,38 @@
+/*
+ * Copyright 2002-2024 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.security.authorization.method;
+
+import org.aopalliance.intercept.MethodInvocation;
+
+import org.springframework.security.authorization.AuthorizationDeniedException;
+import org.springframework.security.authorization.AuthorizationResult;
+
+/**
+ * An implementation of {@link MethodAuthorizationDeniedHandler} that throws
+ * {@link AuthorizationDeniedException}
+ *
+ * @author Marcus da Coregio
+ * @since 6.3
+ */
+public final class ThrowingMethodAuthorizationDeniedHandler implements MethodAuthorizationDeniedHandler {
+
+	@Override
+	public Object handle(MethodInvocation methodInvocation, AuthorizationResult result) {
+		throw new AuthorizationDeniedException("Access Denied", result);
+	}
+
+}

+ 36 - 0
core/src/main/java/org/springframework/security/authorization/method/ThrowingMethodAuthorizationDeniedPostProcessor.java

@@ -0,0 +1,36 @@
+/*
+ * Copyright 2002-2024 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.security.authorization.method;
+
+import org.springframework.security.authorization.AuthorizationDeniedException;
+import org.springframework.security.authorization.AuthorizationResult;
+
+/**
+ * An implementation of {@link MethodAuthorizationDeniedPostProcessor} that throws
+ * {@link AuthorizationDeniedException}
+ *
+ * @author Marcus da Coregio
+ * @since 6.3
+ */
+public final class ThrowingMethodAuthorizationDeniedPostProcessor implements MethodAuthorizationDeniedPostProcessor {
+
+	@Override
+	public Object postProcessResult(MethodInvocationResult methodInvocationResult, AuthorizationResult result) {
+		throw new AuthorizationDeniedException("Access Denied", result);
+	}
+
+}

+ 164 - 8
core/src/test/java/org/springframework/security/authorization/method/AuthorizationManagerAfterReactiveMethodInterceptorTests.java

@@ -1,5 +1,5 @@
 /*
- * Copyright 2002-2022 the original author or authors.
+ * Copyright 2002-2024 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -23,8 +23,12 @@ import reactor.core.publisher.Flux;
 import reactor.core.publisher.Mono;
 
 import org.springframework.aop.Pointcut;
+import org.springframework.expression.common.LiteralExpression;
 import org.springframework.security.access.AccessDeniedException;
 import org.springframework.security.access.intercept.method.MockMethodInvocation;
+import org.springframework.security.authorization.AuthorizationDecision;
+import org.springframework.security.authorization.AuthorizationDeniedException;
+import org.springframework.security.authorization.AuthorizationResult;
 import org.springframework.security.authorization.ReactiveAuthorizationManager;
 
 import static org.assertj.core.api.Assertions.assertThat;
@@ -66,14 +70,15 @@ public class AuthorizationManagerAfterReactiveMethodInterceptorTests {
 		given(mockMethodInvocation.proceed()).willReturn(Mono.just("john"));
 		ReactiveAuthorizationManager<MethodInvocationResult> mockReactiveAuthorizationManager = mock(
 				ReactiveAuthorizationManager.class);
-		given(mockReactiveAuthorizationManager.verify(any(), any())).willReturn(Mono.empty());
+		given(mockReactiveAuthorizationManager.check(any(), any()))
+			.willReturn(Mono.just(new AuthorizationDecision(true)));
 		AuthorizationManagerAfterReactiveMethodInterceptor interceptor = new AuthorizationManagerAfterReactiveMethodInterceptor(
 				Pointcut.TRUE, mockReactiveAuthorizationManager);
 		Object result = interceptor.invoke(mockMethodInvocation);
 		assertThat(result).asInstanceOf(InstanceOfAssertFactories.type(Mono.class))
 			.extracting(Mono::block)
 			.isEqualTo("john");
-		verify(mockReactiveAuthorizationManager).verify(any(), any());
+		verify(mockReactiveAuthorizationManager).check(any(), any());
 	}
 
 	@Test
@@ -83,7 +88,8 @@ public class AuthorizationManagerAfterReactiveMethodInterceptorTests {
 		given(mockMethodInvocation.proceed()).willReturn(Flux.just("john", "bob"));
 		ReactiveAuthorizationManager<MethodInvocationResult> mockReactiveAuthorizationManager = mock(
 				ReactiveAuthorizationManager.class);
-		given(mockReactiveAuthorizationManager.verify(any(), any())).willReturn(Mono.empty());
+		given(mockReactiveAuthorizationManager.check(any(), any()))
+			.willReturn(Mono.just(new AuthorizationDecision(true)));
 		AuthorizationManagerAfterReactiveMethodInterceptor interceptor = new AuthorizationManagerAfterReactiveMethodInterceptor(
 				Pointcut.TRUE, mockReactiveAuthorizationManager);
 		Object result = interceptor.invoke(mockMethodInvocation);
@@ -91,7 +97,7 @@ public class AuthorizationManagerAfterReactiveMethodInterceptorTests {
 			.extracting(Flux::collectList)
 			.extracting(Mono::block, InstanceOfAssertFactories.list(String.class))
 			.containsExactly("john", "bob");
-		verify(mockReactiveAuthorizationManager, times(2)).verify(any(), any());
+		verify(mockReactiveAuthorizationManager, times(2)).check(any(), any());
 	}
 
 	@Test
@@ -101,8 +107,8 @@ public class AuthorizationManagerAfterReactiveMethodInterceptorTests {
 		given(mockMethodInvocation.proceed()).willReturn(Mono.just("john"));
 		ReactiveAuthorizationManager<MethodInvocationResult> mockReactiveAuthorizationManager = mock(
 				ReactiveAuthorizationManager.class);
-		given(mockReactiveAuthorizationManager.verify(any(), any()))
-			.willReturn(Mono.error(new AccessDeniedException("Access Denied")));
+		given(mockReactiveAuthorizationManager.check(any(), any()))
+			.willReturn(Mono.just(new AuthorizationDecision(false)));
 		AuthorizationManagerAfterReactiveMethodInterceptor interceptor = new AuthorizationManagerAfterReactiveMethodInterceptor(
 				Pointcut.TRUE, mockReactiveAuthorizationManager);
 		Object result = interceptor.invoke(mockMethodInvocation);
@@ -110,7 +116,157 @@ public class AuthorizationManagerAfterReactiveMethodInterceptorTests {
 			.isThrownBy(() -> assertThat(result).asInstanceOf(InstanceOfAssertFactories.type(Mono.class))
 				.extracting(Mono::block))
 			.withMessage("Access Denied");
-		verify(mockReactiveAuthorizationManager).verify(any(), any());
+		verify(mockReactiveAuthorizationManager).check(any(), any());
+	}
+
+	@Test
+	public void invokeFluxWhenAllValuesDeniedAndPostProcessorThenPostProcessorAppliedToEachValueEmitted()
+			throws Throwable {
+		MethodInvocation mockMethodInvocation = spy(
+				new MockMethodInvocation(new Sample(), Sample.class.getDeclaredMethod("flux")));
+		given(mockMethodInvocation.proceed()).willReturn(Flux.just("john", "bob"));
+		ReactiveAuthorizationManager<MethodInvocationResult> mockReactiveAuthorizationManager = mock(
+				ReactiveAuthorizationManager.class);
+		given(mockReactiveAuthorizationManager.check(any(), any()))
+			.will((invocation) -> Mono.just(createDecision(new MaskingPostProcessor())));
+		AuthorizationManagerAfterReactiveMethodInterceptor interceptor = new AuthorizationManagerAfterReactiveMethodInterceptor(
+				Pointcut.TRUE, mockReactiveAuthorizationManager);
+		Object result = interceptor.invoke(mockMethodInvocation);
+		assertThat(result).asInstanceOf(InstanceOfAssertFactories.type(Flux.class))
+			.extracting(Flux::collectList)
+			.extracting(Mono::block, InstanceOfAssertFactories.list(String.class))
+			.containsExactly("john-masked", "bob-masked");
+		verify(mockReactiveAuthorizationManager, times(2)).check(any(), any());
+	}
+
+	@Test
+	public void invokeFluxWhenOneValueDeniedAndPostProcessorThenPostProcessorAppliedToDeniedValue() throws Throwable {
+		MethodInvocation mockMethodInvocation = spy(
+				new MockMethodInvocation(new Sample(), Sample.class.getDeclaredMethod("flux")));
+		given(mockMethodInvocation.proceed()).willReturn(Flux.just("john", "bob"));
+		ReactiveAuthorizationManager<MethodInvocationResult> mockReactiveAuthorizationManager = mock(
+				ReactiveAuthorizationManager.class);
+		given(mockReactiveAuthorizationManager.check(any(), any())).willAnswer((invocation) -> {
+			MethodInvocationResult argument = invocation.getArgument(1);
+			if ("john".equals(argument.getResult())) {
+				return Mono.just(new AuthorizationDecision(true));
+			}
+			return Mono.just(createDecision(new MaskingPostProcessor()));
+		});
+		AuthorizationManagerAfterReactiveMethodInterceptor interceptor = new AuthorizationManagerAfterReactiveMethodInterceptor(
+				Pointcut.TRUE, mockReactiveAuthorizationManager);
+		Object result = interceptor.invoke(mockMethodInvocation);
+		assertThat(result).asInstanceOf(InstanceOfAssertFactories.type(Flux.class))
+			.extracting(Flux::collectList)
+			.extracting(Mono::block, InstanceOfAssertFactories.list(String.class))
+			.containsExactly("john", "bob-masked");
+		verify(mockReactiveAuthorizationManager, times(2)).check(any(), any());
+	}
+
+	@Test
+	public void invokeMonoWhenPostProcessableDecisionThenPostProcess() throws Throwable {
+		MethodInvocation mockMethodInvocation = spy(
+				new MockMethodInvocation(new Sample(), Sample.class.getDeclaredMethod("mono")));
+		given(mockMethodInvocation.proceed()).willReturn(Mono.just("john"));
+		ReactiveAuthorizationManager<MethodInvocationResult> mockReactiveAuthorizationManager = mock(
+				ReactiveAuthorizationManager.class);
+		PostAuthorizeAuthorizationDecision decision = new PostAuthorizeAuthorizationDecision(false,
+				new LiteralExpression("1234"), new MaskingPostProcessor());
+		given(mockReactiveAuthorizationManager.check(any(), any())).willReturn(Mono.just(decision));
+		AuthorizationManagerAfterReactiveMethodInterceptor interceptor = new AuthorizationManagerAfterReactiveMethodInterceptor(
+				Pointcut.TRUE, mockReactiveAuthorizationManager);
+		Object result = interceptor.invoke(mockMethodInvocation);
+		assertThat(result).asInstanceOf(InstanceOfAssertFactories.type(Mono.class))
+			.extracting(Mono::block)
+			.isEqualTo("john-masked");
+		verify(mockReactiveAuthorizationManager).check(any(), any());
+	}
+
+	@Test
+	public void invokeMonoWhenPostProcessableDecisionAndPostProcessResultIsMonoThenPostProcessWorks() throws Throwable {
+		MethodInvocation mockMethodInvocation = spy(
+				new MockMethodInvocation(new Sample(), Sample.class.getDeclaredMethod("mono")));
+		given(mockMethodInvocation.proceed()).willReturn(Mono.just("john"));
+		ReactiveAuthorizationManager<MethodInvocationResult> mockReactiveAuthorizationManager = mock(
+				ReactiveAuthorizationManager.class);
+		PostAuthorizeAuthorizationDecision decision = new PostAuthorizeAuthorizationDecision(false,
+				new LiteralExpression("1234"), new MonoMaskingPostProcessor());
+		given(mockReactiveAuthorizationManager.check(any(), any())).willReturn(Mono.just(decision));
+		AuthorizationManagerAfterReactiveMethodInterceptor interceptor = new AuthorizationManagerAfterReactiveMethodInterceptor(
+				Pointcut.TRUE, mockReactiveAuthorizationManager);
+		Object result = interceptor.invoke(mockMethodInvocation);
+		assertThat(result).asInstanceOf(InstanceOfAssertFactories.type(Mono.class))
+			.extracting(Mono::block)
+			.isEqualTo("john-masked");
+		verify(mockReactiveAuthorizationManager).check(any(), any());
+	}
+
+	@Test
+	public void invokeMonoWhenPostProcessableDecisionAndPostProcessResultIsNullThenPostProcessWorks() throws Throwable {
+		MethodInvocation mockMethodInvocation = spy(
+				new MockMethodInvocation(new Sample(), Sample.class.getDeclaredMethod("mono")));
+		given(mockMethodInvocation.proceed()).willReturn(Mono.just("john"));
+		ReactiveAuthorizationManager<MethodInvocationResult> mockReactiveAuthorizationManager = mock(
+				ReactiveAuthorizationManager.class);
+		PostAuthorizeAuthorizationDecision decision = new PostAuthorizeAuthorizationDecision(false,
+				new LiteralExpression("1234"), new NullPostProcessor());
+		given(mockReactiveAuthorizationManager.check(any(), any())).willReturn(Mono.just(decision));
+		AuthorizationManagerAfterReactiveMethodInterceptor interceptor = new AuthorizationManagerAfterReactiveMethodInterceptor(
+				Pointcut.TRUE, mockReactiveAuthorizationManager);
+		Object result = interceptor.invoke(mockMethodInvocation);
+		assertThat(result).asInstanceOf(InstanceOfAssertFactories.type(Mono.class))
+			.extracting(Mono::block)
+			.isEqualTo(null);
+		verify(mockReactiveAuthorizationManager).check(any(), any());
+	}
+
+	@Test
+	public void invokeMonoWhenEmptyDecisionThenUseDefaultPostProcessor() throws Throwable {
+		MethodInvocation mockMethodInvocation = spy(
+				new MockMethodInvocation(new Sample(), Sample.class.getDeclaredMethod("mono")));
+		given(mockMethodInvocation.proceed()).willReturn(Mono.just("john"));
+		ReactiveAuthorizationManager<MethodInvocationResult> mockReactiveAuthorizationManager = mock(
+				ReactiveAuthorizationManager.class);
+		given(mockReactiveAuthorizationManager.check(any(), any())).willReturn(Mono.empty());
+		AuthorizationManagerAfterReactiveMethodInterceptor interceptor = new AuthorizationManagerAfterReactiveMethodInterceptor(
+				Pointcut.TRUE, mockReactiveAuthorizationManager);
+		Object result = interceptor.invoke(mockMethodInvocation);
+		assertThatExceptionOfType(AuthorizationDeniedException.class)
+			.isThrownBy(() -> assertThat(result).asInstanceOf(InstanceOfAssertFactories.type(Mono.class))
+				.extracting(Mono::block))
+			.withMessage("Access Denied");
+		verify(mockReactiveAuthorizationManager).check(any(), any());
+	}
+
+	private PostAuthorizeAuthorizationDecision createDecision(MethodAuthorizationDeniedPostProcessor postProcessor) {
+		return new PostAuthorizeAuthorizationDecision(false, new LiteralExpression("1234"), postProcessor);
+	}
+
+	static class MaskingPostProcessor implements MethodAuthorizationDeniedPostProcessor {
+
+		@Override
+		public Object postProcessResult(MethodInvocationResult contextObject, AuthorizationResult result) {
+			return contextObject.getResult() + "-masked";
+		}
+
+	}
+
+	static class MonoMaskingPostProcessor implements MethodAuthorizationDeniedPostProcessor {
+
+		@Override
+		public Object postProcessResult(MethodInvocationResult contextObject, AuthorizationResult result) {
+			return Mono.just(contextObject.getResult() + "-masked");
+		}
+
+	}
+
+	static class NullPostProcessor implements MethodAuthorizationDeniedPostProcessor {
+
+		@Override
+		public Object postProcessResult(MethodInvocationResult contextObject, AuthorizationResult result) {
+			return null;
+		}
+
 	}
 
 	class Sample {

+ 126 - 8
core/src/test/java/org/springframework/security/authorization/method/AuthorizationManagerBeforeReactiveMethodInterceptorTests.java

@@ -1,5 +1,5 @@
 /*
- * Copyright 2002-2022 the original author or authors.
+ * Copyright 2002-2024 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -23,8 +23,12 @@ import reactor.core.publisher.Flux;
 import reactor.core.publisher.Mono;
 
 import org.springframework.aop.Pointcut;
+import org.springframework.expression.common.LiteralExpression;
 import org.springframework.security.access.AccessDeniedException;
 import org.springframework.security.access.intercept.method.MockMethodInvocation;
+import org.springframework.security.authorization.AuthorizationDecision;
+import org.springframework.security.authorization.AuthorizationDeniedException;
+import org.springframework.security.authorization.AuthorizationResult;
 import org.springframework.security.authorization.ReactiveAuthorizationManager;
 
 import static org.assertj.core.api.Assertions.assertThat;
@@ -67,14 +71,15 @@ public class AuthorizationManagerBeforeReactiveMethodInterceptorTests {
 		given(mockMethodInvocation.proceed()).willReturn(Mono.just("john"));
 		ReactiveAuthorizationManager<MethodInvocation> mockReactiveAuthorizationManager = mock(
 				ReactiveAuthorizationManager.class);
-		given(mockReactiveAuthorizationManager.verify(any(), eq(mockMethodInvocation))).willReturn(Mono.empty());
+		given(mockReactiveAuthorizationManager.check(any(), eq(mockMethodInvocation)))
+			.willReturn(Mono.just(new AuthorizationDecision(true)));
 		AuthorizationManagerBeforeReactiveMethodInterceptor interceptor = new AuthorizationManagerBeforeReactiveMethodInterceptor(
 				Pointcut.TRUE, mockReactiveAuthorizationManager);
 		Object result = interceptor.invoke(mockMethodInvocation);
 		assertThat(result).asInstanceOf(InstanceOfAssertFactories.type(Mono.class))
 			.extracting(Mono::block)
 			.isEqualTo("john");
-		verify(mockReactiveAuthorizationManager).verify(any(), eq(mockMethodInvocation));
+		verify(mockReactiveAuthorizationManager).check(any(), eq(mockMethodInvocation));
 	}
 
 	@Test
@@ -84,7 +89,8 @@ public class AuthorizationManagerBeforeReactiveMethodInterceptorTests {
 		given(mockMethodInvocation.proceed()).willReturn(Flux.just("john", "bob"));
 		ReactiveAuthorizationManager<MethodInvocation> mockReactiveAuthorizationManager = mock(
 				ReactiveAuthorizationManager.class);
-		given(mockReactiveAuthorizationManager.verify(any(), eq(mockMethodInvocation))).willReturn(Mono.empty());
+		given(mockReactiveAuthorizationManager.check(any(), eq(mockMethodInvocation)))
+			.willReturn(Mono.just(new AuthorizationDecision((true))));
 		AuthorizationManagerBeforeReactiveMethodInterceptor interceptor = new AuthorizationManagerBeforeReactiveMethodInterceptor(
 				Pointcut.TRUE, mockReactiveAuthorizationManager);
 		Object result = interceptor.invoke(mockMethodInvocation);
@@ -92,7 +98,7 @@ public class AuthorizationManagerBeforeReactiveMethodInterceptorTests {
 			.extracting(Flux::collectList)
 			.extracting(Mono::block, InstanceOfAssertFactories.list(String.class))
 			.containsExactly("john", "bob");
-		verify(mockReactiveAuthorizationManager).verify(any(), eq(mockMethodInvocation));
+		verify(mockReactiveAuthorizationManager).check(any(), eq(mockMethodInvocation));
 	}
 
 	@Test
@@ -102,8 +108,8 @@ public class AuthorizationManagerBeforeReactiveMethodInterceptorTests {
 		given(mockMethodInvocation.proceed()).willReturn(Mono.just("john"));
 		ReactiveAuthorizationManager<MethodInvocation> mockReactiveAuthorizationManager = mock(
 				ReactiveAuthorizationManager.class);
-		given(mockReactiveAuthorizationManager.verify(any(), eq(mockMethodInvocation)))
-			.willReturn(Mono.error(new AccessDeniedException("Access Denied")));
+		given(mockReactiveAuthorizationManager.check(any(), eq(mockMethodInvocation)))
+			.willReturn(Mono.just(new AuthorizationDecision(false)));
 		AuthorizationManagerBeforeReactiveMethodInterceptor interceptor = new AuthorizationManagerBeforeReactiveMethodInterceptor(
 				Pointcut.TRUE, mockReactiveAuthorizationManager);
 		Object result = interceptor.invoke(mockMethodInvocation);
@@ -111,7 +117,119 @@ public class AuthorizationManagerBeforeReactiveMethodInterceptorTests {
 			.isThrownBy(() -> assertThat(result).asInstanceOf(InstanceOfAssertFactories.type(Mono.class))
 				.extracting(Mono::block))
 			.withMessage("Access Denied");
-		verify(mockReactiveAuthorizationManager).verify(any(), eq(mockMethodInvocation));
+		verify(mockReactiveAuthorizationManager).check(any(), eq(mockMethodInvocation));
+	}
+
+	@Test
+	public void invokeMonoWhenDeniedAndPostProcessorThenInvokePostProcessor() throws Throwable {
+		MethodInvocation mockMethodInvocation = spy(
+				new MockMethodInvocation(new Sample(), Sample.class.getDeclaredMethod("mono")));
+		given(mockMethodInvocation.proceed()).willReturn(Mono.just("john"));
+		ReactiveAuthorizationManager<MethodInvocation> mockReactiveAuthorizationManager = mock(
+				ReactiveAuthorizationManager.class);
+		PreAuthorizeAuthorizationDecision decision = new PreAuthorizeAuthorizationDecision(false,
+				new LiteralExpression("1234"), new MaskingPostProcessor());
+		given(mockReactiveAuthorizationManager.check(any(), eq(mockMethodInvocation))).willReturn(Mono.just(decision));
+		AuthorizationManagerBeforeReactiveMethodInterceptor interceptor = new AuthorizationManagerBeforeReactiveMethodInterceptor(
+				Pointcut.TRUE, mockReactiveAuthorizationManager);
+		Object result = interceptor.invoke(mockMethodInvocation);
+		assertThat(result).asInstanceOf(InstanceOfAssertFactories.type(Mono.class))
+			.extracting(Mono::block)
+			.isEqualTo("***");
+		verify(mockReactiveAuthorizationManager).check(any(), eq(mockMethodInvocation));
+	}
+
+	@Test
+	public void invokeMonoWhenDeniedAndMonoPostProcessorThenInvokePostProcessor() throws Throwable {
+		MethodInvocation mockMethodInvocation = spy(
+				new MockMethodInvocation(new Sample(), Sample.class.getDeclaredMethod("mono")));
+		given(mockMethodInvocation.proceed()).willReturn(Mono.just("john"));
+		ReactiveAuthorizationManager<MethodInvocation> mockReactiveAuthorizationManager = mock(
+				ReactiveAuthorizationManager.class);
+		PreAuthorizeAuthorizationDecision decision = new PreAuthorizeAuthorizationDecision(false,
+				new LiteralExpression("1234"), new MonoMaskingPostProcessor());
+		given(mockReactiveAuthorizationManager.check(any(), eq(mockMethodInvocation))).willReturn(Mono.just(decision));
+		AuthorizationManagerBeforeReactiveMethodInterceptor interceptor = new AuthorizationManagerBeforeReactiveMethodInterceptor(
+				Pointcut.TRUE, mockReactiveAuthorizationManager);
+		Object result = interceptor.invoke(mockMethodInvocation);
+		assertThat(result).asInstanceOf(InstanceOfAssertFactories.type(Mono.class))
+			.extracting(Mono::block)
+			.isEqualTo("***");
+		verify(mockReactiveAuthorizationManager).check(any(), eq(mockMethodInvocation));
+	}
+
+	@Test
+	public void invokeFluxWhenDeniedAndPostProcessorThenInvokePostProcessor() throws Throwable {
+		MethodInvocation mockMethodInvocation = spy(
+				new MockMethodInvocation(new Sample(), Sample.class.getDeclaredMethod("flux")));
+		given(mockMethodInvocation.proceed()).willReturn(Flux.just("john", "bob"));
+		ReactiveAuthorizationManager<MethodInvocation> mockReactiveAuthorizationManager = mock(
+				ReactiveAuthorizationManager.class);
+		PreAuthorizeAuthorizationDecision decision = new PreAuthorizeAuthorizationDecision(false,
+				new LiteralExpression("1234"), new MonoMaskingPostProcessor());
+		given(mockReactiveAuthorizationManager.check(any(), eq(mockMethodInvocation))).willReturn(Mono.just(decision));
+		AuthorizationManagerBeforeReactiveMethodInterceptor interceptor = new AuthorizationManagerBeforeReactiveMethodInterceptor(
+				Pointcut.TRUE, mockReactiveAuthorizationManager);
+		Object result = interceptor.invoke(mockMethodInvocation);
+		assertThat(result).asInstanceOf(InstanceOfAssertFactories.type(Flux.class))
+			.extracting(Flux::collectList)
+			.extracting(Mono::block, InstanceOfAssertFactories.list(String.class))
+			.containsExactly("***");
+		verify(mockReactiveAuthorizationManager).check(any(), eq(mockMethodInvocation));
+	}
+
+	@Test
+	public void invokeMonoWhenEmptyDecisionThenInvokeDefaultPostProcessor() throws Throwable {
+		MethodInvocation mockMethodInvocation = spy(
+				new MockMethodInvocation(new Sample(), Sample.class.getDeclaredMethod("mono")));
+		given(mockMethodInvocation.proceed()).willReturn(Mono.just("john"));
+		ReactiveAuthorizationManager<MethodInvocation> mockReactiveAuthorizationManager = mock(
+				ReactiveAuthorizationManager.class);
+		given(mockReactiveAuthorizationManager.check(any(), eq(mockMethodInvocation))).willReturn(Mono.empty());
+		AuthorizationManagerBeforeReactiveMethodInterceptor interceptor = new AuthorizationManagerBeforeReactiveMethodInterceptor(
+				Pointcut.TRUE, mockReactiveAuthorizationManager);
+		Object result = interceptor.invoke(mockMethodInvocation);
+		assertThatExceptionOfType(AuthorizationDeniedException.class)
+			.isThrownBy(() -> assertThat(result).asInstanceOf(InstanceOfAssertFactories.type(Mono.class))
+				.extracting(Mono::block))
+			.withMessage("Access Denied");
+		verify(mockReactiveAuthorizationManager).check(any(), eq(mockMethodInvocation));
+	}
+
+	@Test
+	public void invokeFluxWhenEmptyDecisionThenInvokeDefaultPostProcessor() throws Throwable {
+		MethodInvocation mockMethodInvocation = spy(
+				new MockMethodInvocation(new Sample(), Sample.class.getDeclaredMethod("flux")));
+		given(mockMethodInvocation.proceed()).willReturn(Flux.just("john", "bob"));
+		ReactiveAuthorizationManager<MethodInvocation> mockReactiveAuthorizationManager = mock(
+				ReactiveAuthorizationManager.class);
+		given(mockReactiveAuthorizationManager.check(any(), eq(mockMethodInvocation))).willReturn(Mono.empty());
+		AuthorizationManagerBeforeReactiveMethodInterceptor interceptor = new AuthorizationManagerBeforeReactiveMethodInterceptor(
+				Pointcut.TRUE, mockReactiveAuthorizationManager);
+		Object result = interceptor.invoke(mockMethodInvocation);
+		assertThatExceptionOfType(AuthorizationDeniedException.class)
+			.isThrownBy(() -> assertThat(result).asInstanceOf(InstanceOfAssertFactories.type(Flux.class))
+				.extracting(Flux::blockFirst))
+			.withMessage("Access Denied");
+		verify(mockReactiveAuthorizationManager).check(any(), eq(mockMethodInvocation));
+	}
+
+	static class MaskingPostProcessor implements MethodAuthorizationDeniedHandler {
+
+		@Override
+		public Object handle(MethodInvocation methodInvocation, AuthorizationResult result) {
+			return "***";
+		}
+
+	}
+
+	static class MonoMaskingPostProcessor implements MethodAuthorizationDeniedHandler {
+
+		@Override
+		public Object handle(MethodInvocation methodInvocation, AuthorizationResult result) {
+			return Mono.just("***");
+		}
+
 	}
 
 	class Sample {

+ 454 - 0
docs/modules/ROOT/pages/servlet/authorization/method-security.adoc

@@ -44,6 +44,7 @@ Consider learning about the following use cases:
 * Understanding <<method-security-architecture,how method security works>> and reasons to use it
 * Comparing <<request-vs-method,request-level and method-level authorization>>
 * Authorizing methods with <<use-preauthorize,`@PreAuthorize`>> and <<use-postauthorize,`@PostAuthorize`>>
+* Providing <<fallback-values-authorization-denied,fallback values when authorization is denied>>
 * Filtering methods with <<use-prefilter,`@PreFilter`>> and <<use-postfilter,`@PostFilter`>>
 * Authorizing methods with <<use-jsr250,JSR-250 annotations>>
 * Authorizing methods with <<use-aspectj,AspectJ expressions>>
@@ -2208,6 +2209,459 @@ And if they do have that authority, they'll see:
 You can also add the Spring Boot property `spring.jackson.default-property-inclusion=non_null` to exclude the null value, if you also don't want to reveal the JSON key to an unauthorized user.
 ====
 
+[[fallback-values-authorization-denied]]
+== Providing Fallback Values When Authorization is Denied
+
+There are some scenarios where you may not wish to throw an `AccessDeniedException` when a method is invoked without the required permissions.
+Instead, you might wish to return a post-processed result, like a masked result, or a default value in cases where access denied happened before invoking the method.
+
+Spring Security provides support for handling and post-processing method access denied with the <<authorizing-with-annotations,`@PreAuthorize` and `@PostAuthorize` annotations>> respectively.
+The `@PreAuthorize` annotation works with implementations of `MethodAuthorizationDeniedHandler` while the `@PostAuthorize` annotation works with implementations of `MethodAuthorizationDeniedPostProcessor`.
+
+=== Using with `@PreAuthorize`
+
+Let's consider the example from the <<authorize-object,previous section>>, but instead of creating the `AccessDeniedExceptionInterceptor` to transform an `AccessDeniedException` to a `null` return value, we will use the `handlerClass` attribute from `@PreAuthorize`:
+
+[tabs]
+======
+Java::
++
+[source,java,role="primary"]
+----
+public class NullMethodAuthorizationDeniedHandler implements MethodAuthorizationDeniedHandler { <1>
+
+    @Override
+    public Object handle(MethodInvocation methodInvocation, AuthorizationResult authorizationResult) {
+        return null;
+    }
+
+}
+
+@Configuration
+@EnableMethodSecurity
+public class SecurityConfig {
+
+    @Bean <2>
+    public NullMethodAuthorizationDeniedHandler nullMethodAuthorizationDeniedHandler() {
+        return new NullMethodAuthorizationDeniedHandler();
+    }
+
+}
+
+public class User {
+    // ...
+
+    @PreAuthorize(value = "hasAuthority('user:read')", handlerClass = NullMethodAuthorizationDeniedHandler.class)
+    public String getEmail() {
+        return this.email;
+    }
+}
+----
+
+Kotlin::
++
+[source,kotlin,role="secondary"]
+----
+class NullMethodAuthorizationDeniedHandler : MethodAuthorizationDeniedHandler { <1>
+
+    override fun handle(methodInvocation: MethodInvocation, authorizationResult: AuthorizationResult): Any {
+        return null
+    }
+
+}
+
+@Configuration
+@EnableMethodSecurity
+class SecurityConfig {
+
+    @Bean <2>
+    fun nullMethodAuthorizationDeniedHandler(): NullMethodAuthorizationDeniedHandler {
+        return MaskMethodAuthorizationDeniedHandler()
+    }
+
+}
+
+class User (val name:String, @get:PreAuthorize(value = "hasAuthority('user:read')", handlerClass = NullMethodAuthorizationDeniedHandler::class) val email:String) <3>
+----
+======
+
+<1> Create an implementation of `MethodAuthorizationDeniedHandler` that returns a `null` value
+<2> Register the `NullMethodAuthorizationDeniedHandler` as a bean
+<3> Pass the `NullMethodAuthorizationDeniedHandler` to the `handlerClass` attribute of `@PreAuthorize`
+
+And then you can verify that a `null` value is returned instead of the `AccessDeniedException`:
+
+[tabs]
+======
+Java::
++
+[source,java,role="primary"]
+----
+@Autowired
+UserRepository users;
+
+@Test
+void getEmailWhenProxiedThenNullEmail() {
+    Optional<User> securedUser = users.findByName("name");
+    assertThat(securedUser.get().getEmail()).isNull();
+}
+----
+
+Kotlin::
++
+[source,kotlin,role="secondary"]
+----
+@Autowired
+var users:UserRepository? = null
+
+@Test
+fun getEmailWhenProxiedThenNullEmail() {
+    val securedUser: Optional<User> = users.findByName("name")
+    assertThat(securedUser.get().getEmail()).isNull()
+}
+----
+======
+
+=== Using with `@PostAuthorize`
+
+The same can be achieved with `@PostAuthorize`, however, since `@PostAuthorize` checks are performed after the method is invoked, we have access to the resulting value of the invocation, allowing you to provide fallback values based on the unauthorized results.
+Let's continue with the previous example, but instead of returning `null`, we will return a masked value of the email:
+
+[tabs]
+======
+Java::
++
+[source,java,role="primary"]
+----
+public class EmailMaskingMethodAuthorizationDeniedPostProcessor implements MethodAuthorizationDeniedPostProcessor { <1>
+
+    @Override
+    public Object postProcessResult(MethodInvocationResult methodInvocationResult, AuthorizationResult authorizationResult) {
+        String email = (String) methodInvocationResult.getResult();
+        return email.replaceAll("(^[^@]{3}|(?!^)\\G)[^@]", "$1*");
+    }
+
+}
+
+@Configuration
+@EnableMethodSecurity
+public class SecurityConfig {
+
+    @Bean <2>
+    public EmailMaskingMethodAuthorizationDeniedPostProcessor emailMaskingMethodAuthorizationDeniedPostProcessor() {
+        return new EmailMaskingMethodAuthorizationDeniedPostProcessor();
+    }
+
+}
+
+public class User {
+    // ...
+
+    @PostAuthorize(value = "hasAuthority('user:read')", postProcessorClass = EmailMaskingMethodAuthorizationDeniedPostProcessor.class)
+    public String getEmail() {
+        return this.email;
+    }
+}
+----
+
+Kotlin::
++
+[source,kotlin,role="secondary"]
+----
+class EmailMaskingMethodAuthorizationDeniedPostProcessor : MethodAuthorizationDeniedPostProcessor {
+
+    override fun postProcessResult(methodInvocationResult: MethodInvocationResult, authorizationResult: AuthorizationResult): Any {
+        val email = methodInvocationResult.result as String
+        return email.replace("(^[^@]{3}|(?!^)\\G)[^@]".toRegex(), "$1*")
+    }
+
+}
+
+@Configuration
+@EnableMethodSecurity
+class SecurityConfig {
+
+    @Bean
+    fun emailMaskingMethodAuthorizationDeniedPostProcessor(): EmailMaskingMethodAuthorizationDeniedPostProcessor {
+        return EmailMaskingMethodAuthorizationDeniedPostProcessor()
+    }
+
+}
+
+class User (val name:String, @PostAuthorize(value = "hasAuthority('user:read')", postProcessorClass = EmailMaskingMethodAuthorizationDeniedPostProcessor::class) val email:String) <3>
+----
+======
+
+<1> Create an implementation of `MethodAuthorizationDeniedPostProcessor` that returns a masked value of the unauthorized result value
+<2> Register the `EmailMaskingMethodAuthorizationDeniedPostProcessor` as a bean
+<3> Pass the `EmailMaskingMethodAuthorizationDeniedPostProcessor` to the `postProcessorClass` attribute of `@PostAuthorize`
+
+And then you can verify that a masked email is returned instead of an `AccessDeniedException`:
+
+[tabs]
+======
+Java::
++
+[source,java,role="primary"]
+----
+@Autowired
+UserRepository users;
+
+@Test
+void getEmailWhenProxiedThenMaskedEmail() {
+    Optional<User> securedUser = users.findByName("name");
+    // email is useremail@example.com
+    assertThat(securedUser.get().getEmail()).isEqualTo("use******@example.com");
+}
+----
+
+Kotlin::
++
+[source,kotlin,role="secondary"]
+----
+@Autowired
+var users:UserRepository? = null
+
+@Test
+fun getEmailWhenProxiedThenMaskedEmail() {
+    val securedUser: Optional<User> = users.findByName("name")
+    // email is useremail@example.com
+    assertThat(securedUser.get().getEmail()).isEqualTo("use******@example.com")
+}
+----
+======
+
+When implementing the `MethodAuthorizationDeniedHandler` or the `MethodAuthorizationDeniedPostProcessor` you have a few options on what you can return:
+
+- A `null` value.
+- A non-null value, respecting the method's return type.
+- Throw an exception, usually an instance of `AccessDeniedException`. This is the default behavior.
+- A `Mono` type for reactive applications.
+
+Note that since the handler and the post-processor must be registered as beans, you can inject dependencies into them if you need a more complex logic.
+In addition to that, you have available the `MethodInvocation` or the `MethodInvocationResult`, as well as the `AuthorizationResult` for more details related to the authorization decision.
+
+=== Deciding What to Return Based on Available Parameters
+
+Consider a scenario where there might multiple mask values for different methods, it would be not so productive if we had to create a handler or post-processor for each of those methods, although it is perfectly fine to do that.
+In such cases, we can use the information passed via parameters to decide what to do.
+For example, we can create a custom `@Mask` annotation and a handler that detects that annotation to decide what mask value to return:
+
+[tabs]
+======
+Java::
++
+[source,java,role="primary"]
+----
+import org.springframework.core.annotation.AnnotationUtils;
+
+@Target({ ElementType.METHOD, ElementType.TYPE })
+@Retention(RetentionPolicy.RUNTIME)
+public @interface Mask {
+
+    String value();
+
+}
+
+public class MaskAnnotationDeniedHandler implements MethodAuthorizationDeniedHandler {
+
+    @Override
+    public Object handle(MethodInvocation methodInvocation, AuthorizationResult authorizationResult) {
+        Mask mask = AnnotationUtils.getAnnotation(methodInvocation.getMethod(), Mask.class);
+        return mask.value();
+    }
+
+}
+
+@Configuration
+@EnableMethodSecurity
+public class SecurityConfig {
+
+    @Bean
+    public MaskAnnotationDeniedHandler maskAnnotationDeniedHandler() {
+        return new MaskAnnotationDeniedHandler();
+    }
+
+}
+
+@Component
+public class MyService {
+
+    @PreAuthorize(value = "hasAuthority('user:read')", handlerClass = MaskAnnotationDeniedHandler.class)
+    @Mask("***")
+    public String foo() {
+        return "foo";
+    }
+
+    @PreAuthorize(value = "hasAuthority('user:read')", handlerClass = MaskAnnotationDeniedHandler.class)
+    @Mask("???")
+    public String bar() {
+        return "bar";
+    }
+
+}
+----
+
+Kotlin::
++
+[source,kotlin,role="secondary"]
+----
+import org.springframework.core.annotation.AnnotationUtils
+
+@Target(AnnotationTarget.FUNCTION, AnnotationTarget.CLASS)
+@Retention(AnnotationRetention.RUNTIME)
+annotation class Mask(val value: String)
+
+class MaskAnnotationDeniedHandler : MethodAuthorizationDeniedHandler {
+
+    override fun handle(methodInvocation: MethodInvocation, authorizationResult: AuthorizationResult): Any {
+        val mask = AnnotationUtils.getAnnotation(methodInvocation.method, Mask::class.java)
+        return mask.value
+    }
+
+}
+
+@Configuration
+@EnableMethodSecurity
+class SecurityConfig {
+
+    @Bean
+    fun maskAnnotationDeniedHandler(): MaskAnnotationDeniedHandler {
+        return MaskAnnotationDeniedHandler()
+    }
+
+}
+
+@Component
+class MyService {
+
+    @PreAuthorize(value = "hasAuthority('user:read')", handlerClass = MaskAnnotationDeniedHandler::class)
+    @Mask("***")
+    fun foo(): String {
+        return "foo"
+    }
+
+    @PreAuthorize(value = "hasAuthority('user:read')", handlerClass = MaskAnnotationDeniedHandler::class)
+    @Mask("???")
+    fun bar(): String {
+        return "bar"
+    }
+
+}
+----
+======
+
+Now the return values when access is denied will be decided based on the `@Mask` annotation:
+
+[tabs]
+======
+Java::
++
+[source,java,role="primary"]
+----
+@Autowired
+MyService myService;
+
+@Test
+void fooWhenDeniedThenReturnStars() {
+    String value = this.myService.foo();
+    assertThat(value).isEqualTo("***");
+}
+
+@Test
+void barWhenDeniedThenReturnQuestionMarks() {
+    String value = this.myService.foo();
+    assertThat(value).isEqualTo("???");
+}
+----
+
+Kotlin::
++
+[source,kotlin,role="secondary"]
+----
+@Autowired
+var myService: MyService
+
+@Test
+fun fooWhenDeniedThenReturnStars() {
+    val value: String = myService.foo()
+    assertThat(value).isEqualTo("***")
+}
+
+@Test
+fun barWhenDeniedThenReturnQuestionMarks() {
+    val value: String = myService.foo()
+    assertThat(value).isEqualTo("???")
+}
+----
+======
+
+=== Combining with Meta Annotation Support
+
+Some authorization expressions may be long enough that it can become hard to read or to maintain.
+For example, consider the following `@PreAuthorize` expression:
+
+[tabs]
+======
+Java::
++
+[source,java,role="primary"]
+----
+@PreAuthorize(value = "@myAuthorizationBean.check()", handlerClass = NullAuthorizationDeniedHandler.class)
+public String myMethod() {
+    // ...
+}
+----
+
+Kotlin::
++
+[source,kotlin,role="secondary"]
+----
+@PreAuthorize(value = "@myAuthorizationBean.check()", handlerClass = NullAuthorizationDeniedHandler::class)
+fun myMethod(): String {
+    // ...
+}
+----
+======
+
+The way it is, it is somewhat hard to read it, but we can do better.
+By using the <<meta-annotations,meta annotation support>>, we can simplify it to:
+
+[tabs]
+======
+Java::
++
+[source,java,role="primary"]
+----
+@Target({ ElementType.METHOD, ElementType.TYPE })
+@Retention(RetentionPolicy.RUNTIME)
+@PreAuthorize(value = "@myAuthorizationBean.check()", handlerClass = NullAuthorizationDeniedHandler.class)
+public @interface NullDenied {}
+
+@NullDenied
+public String myMethod() {
+    // ...
+}
+----
+
+Kotlin::
++
+[source,kotlin,role="secondary"]
+----
+@Target(AnnotationTarget.FUNCTION, AnnotationTarget.CLASS)
+@Retention(AnnotationRetention.RUNTIME)
+@PreAuthorize(value = "@myAuthorizationBean.check()", handlerClass = NullAuthorizationDeniedHandler::class)
+annotation class NullDenied
+
+@NullDenied
+fun myMethod(): String {
+    // ...
+}
+----
+======
+
+Make sure to read the <<meta-annotations,Meta Annotations Support>> section for more details on the usage.
+
 [[migration-enableglobalmethodsecurity]]
 == Migrating from `@EnableGlobalMethodSecurity`
 

+ 1 - 0
docs/modules/ROOT/pages/whats-new.adoc

@@ -16,6 +16,7 @@ Below are the highlights of the release.
 
 - https://github.com/spring-projects/spring-security/issues/14596[gh-14596] - xref:servlet/authorization/method-security.adoc[docs] - Add Programmatic Proxy Support for Method Security
 - https://github.com/spring-projects/spring-security/issues/14597[gh-14597] - xref:servlet/authorization/method-security.adoc[docs] - Add Securing of Return Values
+- https://github.com/spring-projects/spring-security/issues/14601[gh-14601] - xref:servlet/authorization/method-security.adoc#fallback-values-authorization-denied[docs] - Add Authorization Denied Handlers for Method Security
 
 == Configuration