Configure fine-grained access control for messaging applications with pattern-based rules, role checks, and custom authorization logic.
Central authorization manager that delegates to specific authorization managers based on message matcher patterns. This is the primary API for configuring message-based security rules. Thread-safe for concurrent use.
/**
* Authorization manager that delegates based on MessageMatcher patterns.
* Use the builder() method to create instances with fluent configuration.
* Thread-safe for concurrent authorization checks.
*
* @since 5.8
*/
public final class MessageMatcherDelegatingAuthorizationManager
implements AuthorizationManager<Message<?>> {
/**
* Creates a new builder for configuring authorization rules.
*
* @return a Builder instance (never null)
*/
public static Builder builder();
/**
* Performs authorization check on the message.
* Rules are evaluated in order until a match is found.
*
* @param authentication supplier of authentication to check (must not be null)
* @param message the message to authorize (must not be null)
* @return authorization result (never null, granted or denied)
* @throws IllegalArgumentException if authentication or message is null
*/
public AuthorizationResult authorize(
Supplier<? extends Authentication> authentication,
Message<?> message
);
}Usage Example:
MessageMatcherDelegatingAuthorizationManager authManager =
MessageMatcherDelegatingAuthorizationManager.builder()
.nullDestMatcher().authenticated()
.simpSubscribeDestMatchers("/user/queue/errors").permitAll()
.simpDestMatchers("/app/**").hasRole("USER")
.simpSubscribeDestMatchers("/topic/**", "/queue/**").authenticated()
.simpTypeMatchers(SimpMessageType.MESSAGE, SimpMessageType.SUBSCRIBE)
.denyAll()
.anyMessage().denyAll()
.build();Error Handling:
try {
AuthorizationResult result = authManager.authorize(
() -> SecurityContextHolder.getContext().getAuthentication(),
message
);
if (!result.isGranted()) {
// Handle denied access
logger.warn("Access denied for message: {}", message);
}
} catch (IllegalArgumentException e) {
// Handle null parameters
logger.error("Invalid authorization parameters", e);
}The Builder provides a fluent API for configuring message authorization rules. Rules are evaluated in the order they are added.
public static final class Builder {
/**
* Matches any message. Should typically be last in chain as catch-all.
*
* @return Constraint for configuring authorization rule (never null)
*/
public Constraint anyMessage();
/**
* Matches messages with null destination.
* Useful for handling messages without explicit destinations.
*
* @return Constraint for configuring authorization rule (never null)
*/
public Constraint nullDestMatcher();
/**
* Matches messages by STOMP message type (CONNECT, SUBSCRIBE, MESSAGE, etc.).
*
* @param typesToMatch the STOMP message types to match (must not be null or empty)
* @return Constraint for configuring authorization rule (never null)
* @throws IllegalArgumentException if typesToMatch is null or empty
*/
public Constraint simpTypeMatchers(SimpMessageType... typesToMatch);
/**
* Matches messages by destination pattern for any message type.
* Uses Ant-style path patterns: ? (single char), * (path segment), ** (multiple segments).
*
* @param patterns destination patterns (must not be null or empty)
* @return Constraint for configuring authorization rule (never null)
* @throws IllegalArgumentException if patterns is null or empty
*/
public Constraint simpDestMatchers(String... patterns);
/**
* Matches MESSAGE type messages by destination pattern.
* More specific than simpDestMatchers as it also checks message type.
*
* @param patterns destination patterns (must not be null or empty)
* @return Constraint for configuring authorization rule (never null)
* @throws IllegalArgumentException if patterns is null or empty
*/
public Constraint simpMessageDestMatchers(String... patterns);
/**
* Matches SUBSCRIBE type messages by destination pattern.
* More specific than simpDestMatchers as it also checks message type.
*
* @param patterns destination patterns (must not be null or empty)
* @return Constraint for configuring authorization rule (never null)
* @throws IllegalArgumentException if patterns is null or empty
*/
public Constraint simpSubscribeDestMatchers(String... patterns);
/**
* Matches using custom message matchers.
* Allows for complex matching logic beyond built-in patterns.
*
* @param matchers custom MessageMatcher instances (must not be null or empty)
* @return Constraint for configuring authorization rule (never null)
* @throws IllegalArgumentException if matchers is null or empty
*/
public Constraint matchers(MessageMatcher<?>... matchers);
/**
* Builds the MessageMatcherDelegatingAuthorizationManager.
* Rules are evaluated in the order they were added.
*
* @return configured authorization manager (never null)
* @throws IllegalStateException if no rules are configured
*/
public MessageMatcherDelegatingAuthorizationManager build();
}Builder Error Handling:
try {
MessageMatcherDelegatingAuthorizationManager authManager =
MessageMatcherDelegatingAuthorizationManager.builder()
.simpDestMatchers("/app/**").hasRole("USER")
.build();
} catch (IllegalStateException e) {
// No rules configured - this shouldn't happen with at least one rule
logger.error("Failed to build authorization manager", e);
}After selecting a matcher, use Constraint methods to define the authorization requirement. Each method returns the Builder for method chaining.
public interface Constraint {
/**
* Requires user to have the specified role (ROLE_ prefix added automatically).
* Checks if authentication has authority "ROLE_{role}".
*
* @param role the role name without ROLE_ prefix (must not be null or empty)
* @return Builder for configuring additional rules (never null)
* @throws IllegalArgumentException if role is null or empty
*/
Builder hasRole(String role);
/**
* Requires user to have any of the specified roles.
* Returns true if user has at least one of the roles.
*
* @param roles role names without ROLE_ prefix (must not be null or empty)
* @return Builder for configuring additional rules (never null)
* @throws IllegalArgumentException if roles is null or empty
*/
Builder hasAnyRole(String... roles);
/**
* Requires user to have the specified authority.
* Checks exact authority match (no prefix added).
*
* @param authority the authority name (must not be null or empty)
* @return Builder for configuring additional rules (never null)
* @throws IllegalArgumentException if authority is null or empty
*/
Builder hasAuthority(String authority);
/**
* Requires user to have any of the specified authorities.
* Returns true if user has at least one of the authorities.
*
* @param authorities authority names (must not be null or empty)
* @return Builder for configuring additional rules (never null)
* @throws IllegalArgumentException if authorities is null or empty
*/
Builder hasAnyAuthority(String... authorities);
/**
* Permits all users (authenticated and anonymous).
* No authentication check is performed.
*
* @return Builder for configuring additional rules (never null)
*/
Builder permitAll();
/**
* Denies all users.
* Always returns denied authorization result.
*
* @return Builder for configuring additional rules (never null)
*/
Builder denyAll();
/**
* Requires user to be authenticated (including remember-me).
* Anonymous users are denied.
*
* @return Builder for configuring additional rules (never null)
*/
Builder authenticated();
/**
* Requires full authentication (not remember-me).
* Remember-me authenticated users are denied.
*
* @return Builder for configuring additional rules (never null)
*/
Builder fullyAuthenticated();
/**
* Requires remember-me authentication.
* Only remember-me authenticated users are allowed.
*
* @return Builder for configuring additional rules (never null)
*/
Builder rememberMe();
/**
* Requires anonymous authentication.
* Authenticated users are denied.
*
* @return Builder for configuring additional rules (never null)
*/
Builder anonymous();
/**
* Uses custom AuthorizationManager for authorization decision.
* Provides full control over authorization logic.
*
* @param manager custom authorization manager (must not be null)
* @return Builder for configuring additional rules (never null)
* @throws IllegalArgumentException if manager is null
*/
Builder access(AuthorizationManager<MessageAuthorizationContext<?>> manager);
}Usage Examples:
// Role-based authorization
.simpDestMatchers("/app/admin/**").hasRole("ADMIN")
// Multiple roles
.simpDestMatchers("/app/moderator/**").hasAnyRole("ADMIN", "MODERATOR")
// Authority-based
.simpDestMatchers("/app/write/**").hasAuthority("WRITE")
// Permit all
.simpSubscribeDestMatchers("/topic/public").permitAll()
// Authenticated users only
.simpDestMatchers("/app/**").authenticated()
// Custom authorization logic
.simpDestMatchers("/app/sensitive/**").access(customAuthManager)Edge Cases:
// Handle null authentication gracefully
AuthorizationManager<MessageAuthorizationContext<?>> safeManager = (auth, context) -> {
Authentication authentication = auth.get();
if (authentication == null || !authentication.isAuthenticated()) {
return new AuthorizationDecision(false);
}
// Perform authorization check...
return new AuthorizationDecision(true);
};
// Handle missing path variables
AuthorizationManager<MessageAuthorizationContext<?>> variableManager = (auth, context) -> {
Map<String, String> variables = context.getVariables();
String userId = variables.get("userId");
if (userId == null) {
// Path variable not extracted - deny access
return new AuthorizationDecision(false);
}
// Use userId for authorization...
return new AuthorizationDecision(true);
};Channel interceptor that enforces authorization on messages using an AuthorizationManager. Throws AccessDeniedException if authorization fails.
/**
* ChannelInterceptor that performs authorization on messages before they are sent.
* Thread-safe for concurrent use.
*
* @since 5.8
*/
public final class AuthorizationChannelInterceptor implements ChannelInterceptor {
/**
* Creates interceptor with the specified authorization manager.
*
* @param preSendAuthorizationManager authorization manager for pre-send checks (must not be null)
* @throws IllegalArgumentException if preSendAuthorizationManager is null
*/
public AuthorizationChannelInterceptor(
AuthorizationManager<Message<?>> preSendAuthorizationManager
);
/**
* Intercepts message before sending and performs authorization check.
* Called before message is sent to channel.
*
* @param message the message to authorize (must not be null)
* @param channel the channel the message will be sent to (must not be null)
* @return the message if authorized (never null)
* @throws AccessDeniedException if authorization fails
* @throws IllegalArgumentException if message or channel is null
*/
public Message<?> preSend(Message<?> message, MessageChannel channel);
/**
* Sets the SecurityContextHolderStrategy for obtaining authentication.
* Allows customization of how security context is stored/retrieved.
*
* @param securityContextHolderStrategy the strategy to use (must not be null)
* @throws IllegalArgumentException if strategy is null
*/
public void setSecurityContextHolderStrategy(
SecurityContextHolderStrategy securityContextHolderStrategy
);
/**
* Sets the publisher for authorization events.
* Allows publishing authorization events for auditing/monitoring.
*
* @param eventPublisher the event publisher (can be null to disable events)
*/
public void setAuthorizationEventPublisher(
AuthorizationEventPublisher eventPublisher
);
}Usage Example:
import org.springframework.context.annotation.Configuration;
import org.springframework.messaging.simp.config.ChannelRegistration;
import org.springframework.security.config.annotation.web.socket.AbstractSecurityWebSocketMessageBrokerConfigurer;
import org.springframework.security.access.AccessDeniedException;
@Configuration
public class WebSocketSecurityConfig extends AbstractSecurityWebSocketMessageBrokerConfigurer {
@Override
protected void configureInbound(ChannelRegistration registration) {
MessageMatcherDelegatingAuthorizationManager authManager =
MessageMatcherDelegatingAuthorizationManager.builder()
.nullDestMatcher().authenticated()
.simpDestMatchers("/app/**").hasRole("USER")
.anyMessage().denyAll()
.build();
AuthorizationChannelInterceptor interceptor =
new AuthorizationChannelInterceptor(authManager);
// Optional: Configure event publisher for auditing
interceptor.setAuthorizationEventPublisher(authorizationEventPublisher());
registration.interceptors(interceptor);
}
@Bean
public AuthorizationEventPublisher authorizationEventPublisher() {
return new DefaultAuthorizationEventPublisher();
}
}Error Handling:
// Global exception handler for AccessDeniedException
@ControllerAdvice
public class WebSocketExceptionHandler {
@MessageExceptionHandler(AccessDeniedException.class)
public void handleAccessDenied(AccessDeniedException ex) {
logger.warn("Access denied: {}", ex.getMessage());
// Send error message to client
}
}Context object that holds a message along with extracted path variables for use in authorization decisions. Immutable and thread-safe.
/**
* Holds a Message and extracted path variables for authorization.
* Immutable and thread-safe.
*
* @param <T> the message payload type
* @since 5.8
*/
public final class MessageAuthorizationContext<T> {
/**
* Creates context with message only.
* Variables map will be empty.
*
* @param message the message (must not be null)
* @throws IllegalArgumentException if message is null
*/
public MessageAuthorizationContext(Message<T> message);
/**
* Creates context with message and path variables.
*
* @param message the message (must not be null)
* @param variables path variables extracted from destination (can be null, treated as empty map)
* @throws IllegalArgumentException if message is null
*/
public MessageAuthorizationContext(
Message<T> message,
Map<String, String> variables
);
/**
* Returns the message.
*
* @return the message (never null)
*/
public Message<T> getMessage();
/**
* Returns extracted path variables from destination pattern matching.
* Variables are extracted when using path patterns like /app/user/{userId}/**.
*
* @return map of variable names to values (never null, may be empty)
*/
public Map<String, String> getVariables();
}Usage Example:
// Custom authorization manager using path variables
AuthorizationManager<MessageAuthorizationContext<?>> customManager = (authentication, context) -> {
Map<String, String> variables = context.getVariables();
String userId = variables.get("userId");
// Handle missing variable
if (userId == null) {
logger.warn("Missing userId in path variables");
return new AuthorizationDecision(false);
}
// Check if authenticated user can access this userId
Authentication auth = authentication.get();
if (auth == null || !auth.isAuthenticated()) {
return new AuthorizationDecision(false);
}
String authenticatedUsername = auth.getName();
boolean granted = authenticatedUsername.equals(userId);
return new AuthorizationDecision(granted);
};
// Use with destination pattern containing path variable
MessageMatcherDelegatingAuthorizationManager authManager =
MessageMatcherDelegatingAuthorizationManager.builder()
.simpDestMatchers("/app/user/{userId}/**").access(customManager)
.build();Multiple Path Variables:
AuthorizationManager<MessageAuthorizationContext<?>> multiVariableManager = (auth, context) -> {
Map<String, String> variables = context.getVariables();
String teamId = variables.get("teamId");
String memberId = variables.get("memberId");
if (teamId == null || memberId == null) {
return new AuthorizationDecision(false);
}
Authentication authentication = auth.get();
// Complex authorization logic using both variables
return new AuthorizationDecision(checkTeamMemberAccess(authentication, teamId, memberId));
};@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig extends AbstractSecurityWebSocketMessageBrokerConfigurer {
@Override
protected void configureInbound(ChannelRegistration registration) {
MessageMatcherDelegatingAuthorizationManager authManager =
MessageMatcherDelegatingAuthorizationManager.builder()
.nullDestMatcher().authenticated()
.simpSubscribeDestMatchers("/user/queue/errors").permitAll()
.simpDestMatchers("/app/**").authenticated()
.simpSubscribeDestMatchers("/user/**", "/topic/**").authenticated()
.anyMessage().denyAll()
.build();
registration.interceptors(new AuthorizationChannelInterceptor(authManager));
}
@Override
protected boolean sameOriginDisabled() {
return true; // Disable same-origin check if using CSRF token validation
}
}MessageMatcherDelegatingAuthorizationManager authManager =
MessageMatcherDelegatingAuthorizationManager.builder()
// Admin-only destinations (most specific first)
.simpDestMatchers("/app/admin/**").hasRole("ADMIN")
// Moderator or admin
.simpDestMatchers("/app/moderate/**").hasAnyRole("ADMIN", "MODERATOR")
// Authenticated users (less specific)
.simpDestMatchers("/app/**").authenticated()
// Public subscriptions
.simpSubscribeDestMatchers("/topic/public/**").permitAll()
// Private subscriptions require authentication
.simpSubscribeDestMatchers("/topic/**", "/queue/**").authenticated()
// Deny everything else (catch-all)
.anyMessage().denyAll()
.build();MessageMatcherDelegatingAuthorizationManager authManager =
MessageMatcherDelegatingAuthorizationManager.builder()
// Allow CONNECT from anyone
.simpTypeMatchers(SimpMessageType.CONNECT).permitAll()
// Require auth for MESSAGE and SUBSCRIBE
.simpTypeMatchers(SimpMessageType.MESSAGE).authenticated()
.simpTypeMatchers(SimpMessageType.SUBSCRIBE).authenticated()
// Deny UNSUBSCRIBE and DISCONNECT
.simpTypeMatchers(SimpMessageType.UNSUBSCRIBE, SimpMessageType.DISCONNECT)
.denyAll()
.anyMessage().denyAll()
.build();// Custom manager that checks path variables
AuthorizationManager<MessageAuthorizationContext<?>> userAuthManager = (auth, context) -> {
String userId = context.getVariables().get("userId");
if (userId == null) {
return new AuthorizationDecision(false);
}
Authentication authentication = auth.get();
if (authentication == null || !authentication.isAuthenticated()) {
return new AuthorizationDecision(false);
}
String authenticatedUser = authentication.getName();
// User can only access their own resources
return new AuthorizationDecision(authenticatedUser.equals(userId));
};
MessageMatcherDelegatingAuthorizationManager authManager =
MessageMatcherDelegatingAuthorizationManager.builder()
// Use path variable {userId} in pattern
.simpDestMatchers("/app/user/{userId}/**").access(userAuthManager)
.simpSubscribeDestMatchers("/user/{userId}/queue/**").access(userAuthManager)
.anyMessage().denyAll()
.build();@Service
public class TeamAuthorizationService {
public boolean canAccessTeam(String username, String teamId) {
// Complex business logic to check team membership
return teamRepository.isMember(username, teamId);
}
}
@Configuration
public class ComplexAuthorizationConfig {
@Autowired
private TeamAuthorizationService teamService;
@Bean
public MessageMatcherDelegatingAuthorizationManager authorizationManager() {
AuthorizationManager<MessageAuthorizationContext<?>> teamManager = (auth, context) -> {
Map<String, String> variables = context.getVariables();
String teamId = variables.get("teamId");
String username = auth.get().getName();
if (teamId == null) {
return new AuthorizationDecision(false);
}
boolean canAccess = teamService.canAccessTeam(username, teamId);
return new AuthorizationDecision(canAccess);
};
return MessageMatcherDelegatingAuthorizationManager.builder()
.simpDestMatchers("/app/team/{teamId}/**").access(teamManager)
.anyMessage().denyAll()
.build();
}
}Rules are evaluated in the order they are added to the builder. The first matching rule determines the authorization decision.
Important: Order matters! More specific rules should come before less specific ones.
// CORRECT: Specific before general
MessageMatcherDelegatingAuthorizationManager authManager =
MessageMatcherDelegatingAuthorizationManager.builder()
.simpDestMatchers("/app/admin/**").hasRole("ADMIN") // Specific
.simpDestMatchers("/app/**").authenticated() // General
.anyMessage().denyAll() // Catch-all
.build();
// INCORRECT: General before specific (admin rule never reached)
MessageMatcherDelegatingAuthorizationManager wrongManager =
MessageMatcherDelegatingAuthorizationManager.builder()
.simpDestMatchers("/app/**").authenticated() // General - matches first!
.simpDestMatchers("/app/admin/**").hasRole("ADMIN") // Never reached
.anyMessage().denyAll()
.build();Optimization Example:
// Optimize by ordering rules by frequency
MessageMatcherDelegatingAuthorizationManager optimizedManager =
MessageMatcherDelegatingAuthorizationManager.builder()
// Most frequent: public subscriptions
.simpSubscribeDestMatchers("/topic/public/**").permitAll()
// Second most frequent: user messages
.simpDestMatchers("/app/**").authenticated()
// Less frequent: admin operations
.simpDestMatchers("/app/admin/**").hasRole("ADMIN")
// Catch-all
.anyMessage().denyAll()
.build();Symptoms: All WebSocket messages fail with AccessDeniedException.
Causes:
Solutions:
// Ensure catch-all rule is last
.anyMessage().denyAll() // Must be last
// Ensure authentication is propagated
registration.interceptors(new SecurityContextPropagationChannelInterceptor());
// Add debug logging
@Bean
public AuthorizationEventPublisher debugEventPublisher() {
return (authorizationEvent) -> {
logger.debug("Authorization event: {}", authorizationEvent);
};
}Symptoms: Rule configured but not applied.
Causes:
Solutions:
// Use correct matcher for message type
.simpMessageDestMatchers("/app/**") // Only MESSAGE type
.simpSubscribeDestMatchers("/topic/**") // Only SUBSCRIBE type
.simpDestMatchers("/app/**") // Any type
// Check actual destination in logs
logger.debug("Message destination: {}", message.getHeaders().get("simpDestination"));Symptoms: Variables map is empty in custom authorization manager.
Causes:
Solutions:
// Use path variable syntax in pattern
.simpDestMatchers("/app/user/{userId}/**") // Correct: {userId} extracted
// Not: .simpDestMatchers("/app/user/*/**") // Wrong: no variable extractionSymptoms: NullPointerException when accessing authentication.
Causes:
Solutions:
// Always check for null
AuthorizationManager<MessageAuthorizationContext<?>> safeManager = (auth, context) -> {
Authentication authentication = auth.get();
if (authentication == null || !authentication.isAuthenticated()) {
return new AuthorizationDecision(false);
}
// Use authentication...
};
// Ensure security context propagation
registration.interceptors(new SecurityContextPropagationChannelInterceptor());