Security Configuration Recipes#
Palmyra doesn’t own your Spring Security setup — the user-management extension gives you an AuthenticationProvider, but the filter chain is yours to compose. These are the four recipes every app ends up writing.
1. Session-based login for a SPA#
The single-page-app default: POST /auth/login establishes an HTTP session, a JSESSIONID cookie authenticates subsequent requests, CSRF protects mutations, 401 on session expiry.
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final LocalDBAuthenticationProvider localDbProvider;
@Bean
public SecurityContextRepository securityContextRepository() {
return new DelegatingSecurityContextRepository(
new RequestAttributeSecurityContextRepository(),
new HttpSessionSecurityContextRepository());
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http,
SecurityContextRepository repo) throws Exception {
CsrfTokenRequestAttributeHandler csrfHandler = new CsrfTokenRequestAttributeHandler();
csrfHandler.setCsrfRequestAttributeName(null); // eager resolution — see §3
return http
.authenticationProvider(localDbProvider)
.securityContext(c -> c.securityContextRepository(repo))
.sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED))
.csrf(c -> c
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
.csrfTokenRequestHandler(csrfHandler)
.ignoringRequestMatchers("/auth/login", "/auth/logout"))
.authorizeHttpRequests(a -> a
.requestMatchers("/auth/login", "/auth/logout", "/public/**").permitAll()
.anyRequest().authenticated())
.exceptionHandling(e -> e.authenticationEntryPoint(
new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED)))
.build();
}
}Pair it with an idle timeout in application.yml:
server:
servlet:
session:
timeout: 15m
cookie:
http-only: true
same-site: lax
secure: false # true in prod behind HTTPSThe matching /auth/login controller:
@PostMapping("/auth/login")
public ResponseEntity<LoginResponse> login(@RequestBody LoginRequest req,
HttpServletRequest request,
HttpServletResponse response) {
var token = UsernamePasswordAuthenticationToken.unauthenticated(req.getUserName(), req.getPassword());
Authentication auth;
try { auth = authenticationManager.authenticate(token); }
catch (AuthenticationException ex) { throw new UnAuthorizedException("AUTH001", "Invalid credentials"); }
SecurityContext ctx = SecurityContextHolder.createEmptyContext();
ctx.setAuthentication(auth);
SecurityContextHolder.setContext(ctx);
securityContextRepository.saveContext(ctx, request, response);
return ResponseEntity.ok(new LoginResponse(auth.getName()));
}2. Multi-chain setup (user session + device / API-key)#
When you have a browser SPA and headless clients (agents, scripts, IoT devices), use two SecurityFilterChain beans ordered by URL pattern:
@Bean
@Order(Ordered.HIGHEST_PRECEDENCE)
public SecurityFilterChain deviceChain(HttpSecurity http) throws Exception {
return http
.securityMatcher("/device/v*/**")
.csrf(c -> c.disable())
.sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authenticationProvider(deviceTokenProvider)
.addFilterBefore(new DeviceTokenFilter(authManager()),
UsernamePasswordAuthenticationFilter.class)
.authorizeHttpRequests(a -> a.anyRequest().authenticated())
.build();
}
@Bean
@Order(Ordered.LOWEST_PRECEDENCE)
public SecurityFilterChain apiChain(HttpSecurity http,
SecurityContextRepository repo) throws Exception {
// Session-based chain — see recipe 1
return http.securityMatcher("/**") /* ... */ .build();
}Each chain has its own providers, session policy, and CSRF stance. See Programmatic Access for the token filter + provider implementation.
3. CSRF for SPAs — eager token resolution#
Spring Security 6’s default CsrfTokenRequestHandler defers token resolution until the token is explicitly read, which means the XSRF-TOKEN cookie never lands on the first GET and axios can’t echo it on the next POST. Two edits fix it:
CsrfTokenRequestAttributeHandler handler = new CsrfTokenRequestAttributeHandler();
handler.setCsrfRequestAttributeName(null); // null = resolve eagerly
http.csrf(c -> c
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
.csrfTokenRequestHandler(handler));withHttpOnlyFalse() lets JavaScript read the cookie; axios’s defaults (xsrfCookieName: 'XSRF-TOKEN', xsrfHeaderName: 'X-XSRF-TOKEN') then match Spring’s names and the round-trip just works. See Frontend: session auth wiring for the client side.
Exempt the auth endpoints — login can’t carry a token the client doesn’t have yet:
.ignoringRequestMatchers("/auth/login", "/auth/logout")And exempt stateless (API-key) requests — they don’t participate in the session:
RequestMatcher apiKey = r -> r.getHeader("X-Device-Token") != null;
.ignoringRequestMatchers(apiKey);4. Mapping palmyra exceptions to HTTP codes#
Palmyra-dbpwd-mgmt throws UnAuthorizedException (401) on bad credentials; palmyra-core throws EndPointForbiddenException (403) when a handler rejects. Spring’s default handler maps both to 500 unless you tell it otherwise:
@RestControllerAdvice
@Order(Ordered.HIGHEST_PRECEDENCE)
public class ApiExceptionHandler {
@ExceptionHandler(UnAuthorizedException.class)
public ResponseEntity<ProblemDetail> unauthorized(UnAuthorizedException e) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body(ProblemDetail.forStatusAndDetail(HttpStatus.UNAUTHORIZED,
e.getErrorCode() + " " + e.getMessage()));
}
@ExceptionHandler(EndPointForbiddenException.class)
public ResponseEntity<ProblemDetail> forbidden(EndPointForbiddenException e) {
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(ProblemDetail.forStatusAndDetail(HttpStatus.FORBIDDEN,
e.getErrorCode() + " " + e.getMessage()));
}
}Palmyra’s own PalmyraExceptionHandler already handles ResourceNotFoundException, DataNotFoundException, and ResourceAlreadyExistsException. You only add the two above.
Checklist for a new SPA-backed Palmyra app#
- Register
LocalDBAuthenticationProvider(user-management extension). - Two beans —
SecurityContextRepository+ theSecurityFilterChainin recipe 1. - Session timeout + cookie attributes in
application.yml. POST /auth/login+POST /auth/logoutcontroller.ApiExceptionHandlerfor the 401/403 mapping.- On the frontend:
withCredentials: truein your store factory’s axios customizer (see session-auth wiring).
See also#
- User Management extension and its integration guide
- Programmatic Access — API keys for non-browser clients
- Frontend: session auth wiring