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 HTTPS

The 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#

  1. Register LocalDBAuthenticationProvider (user-management extension).
  2. Two beans — SecurityContextRepository + the SecurityFilterChain in recipe 1.
  3. Session timeout + cookie attributes in application.yml.
  4. POST /auth/login + POST /auth/logout controller.
  5. ApiExceptionHandler for the 401/403 mapping.
  6. On the frontend: withCredentials: true in your store factory’s axios customizer (see session-auth wiring).

See also#