Spring Security Event System and Structured Auth Logging
Spring Security Event System and Structured Auth Logging
The Assumption
Spring Security logs authentication events at DEBUG level. Enable DEBUG logging and you have an audit trail. The assumption: general logging is sufficient for security audit.
DEBUG-level logging is disabled in production. It generates too much noise, too much output, and too much performance overhead. When DEBUG is disabled, all those authentication events disappear. No login records. No failure records. Nothing for incident response.
Even when DEBUG is enabled, the log format is unstructured. o.s.s.a.ProviderManager : Authentication attempt using org.springframework.security.authentication.dao.DaoAuthenticationProvider tells you nothing useful for incident response. It does not contain the username, the IP, the tenant, or the outcome. Parsing these logs at scale requires fragile regex patterns.
The Attack
Scenario: Undetected account takeover. An attacker gains Alice’s credentials via credential stuffing. They log in from an IP in a different country. They export all of Alice’s project data. They log out. Alice logs in the next day and notices nothing. The security team has no audit trail because auth logging was at DEBUG level, which was disabled in production 6 months ago to reduce log volume.
Three weeks later, Alice’s data appears on a competitor’s website. The incident response team asks: “When was the account compromised? From where? What was accessed?” No data to answer any of these questions.
The Spec or Mechanism
Spring Security publishes authentication events via the ApplicationEventPublisher:
AuthenticationSuccessEvent- successful authenticationAbstractAuthenticationFailureEvent- failed authentication (with cause-specific subclasses)AuthorizationDeniedEvent- access denied (Spring Security 6.1+)SessionCreationEvent/SessionDestroyedEvent- session lifecycle
These events fire regardless of log level. They are application events, not log statements. An @EventListener receives them at INFO, WARN, ERROR, or any level. They cannot be silenced by changing log configuration.
The Implementation
Event Listener for Auth Events
// VULNERABLE: Relying on DEBUG-level log output
// This class does nothing in production because DEBUG is off
@Component
public class VulnerableAuthLogging {
private static final Logger log = LoggerFactory.getLogger(VulnerableAuthLogging.class);
// No event listeners. Auth events are only visible in Spring Security's
// internal DEBUG logs, which are disabled in production.
}
// HARDENED: Dedicated auth event logger at INFO level
@Component
public class AuthEventLogger {
private static final Logger authLog = LoggerFactory.getLogger("SECURITY_AUDIT");
@EventListener
public void onAuthSuccess(AuthenticationSuccessEvent event) {
Authentication auth = event.getAuthentication();
Map<String, Object> logEntry = new LinkedHashMap<>();
logEntry.put("event_type", "AUTH_LOGIN_SUCCESS");
logEntry.put("user_id", auth.getName());
logEntry.put("authorities", auth.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.collect(Collectors.toList()));
// Enrich with request context from MDC
logEntry.put("ip_address", MDC.get("client_ip"));
logEntry.put("tenant_id", MDC.get("tenant_id"));
logEntry.put("session_id_hash", MDC.get("session_id_hash"));
logEntry.put("user_agent", MDC.get("user_agent"));
authLog.info(toJson(logEntry));
}
@EventListener
public void onAuthFailure(AbstractAuthenticationFailureEvent event) {
Authentication auth = event.getAuthentication();
AuthenticationException exception = event.getException();
Map<String, Object> logEntry = new LinkedHashMap<>();
logEntry.put("event_type", "AUTH_LOGIN_FAILURE");
logEntry.put("attempted_user", auth.getName());
logEntry.put("failure_reason", exception.getClass().getSimpleName());
logEntry.put("ip_address", MDC.get("client_ip"));
logEntry.put("tenant_id", MDC.get("tenant_id"));
logEntry.put("user_agent", MDC.get("user_agent"));
// Do NOT log: password, token, session ID, stack trace
authLog.info(toJson(logEntry));
}
@EventListener
public void onAuthorizationDenied(AuthorizationDeniedEvent event) {
Map<String, Object> logEntry = new LinkedHashMap<>();
logEntry.put("event_type", "AUTH_ACCESS_DENIED");
logEntry.put("user_id", event.getAuthentication().get().getName());
logEntry.put("resource", MDC.get("request_uri"));
logEntry.put("method", MDC.get("request_method"));
logEntry.put("ip_address", MDC.get("client_ip"));
logEntry.put("tenant_id", MDC.get("tenant_id"));
authLog.warn(toJson(logEntry));
}
private String toJson(Map<String, Object> map) {
try {
return new ObjectMapper().writeValueAsString(map);
} catch (JsonProcessingException e) {
return map.toString();
}
}
}
MDC Population Filter
The event listener reads context from MDC (Mapped Diagnostic Context). A filter must populate MDC before authentication occurs:
@Component
@Order(Ordered.HIGHEST_PRECEDENCE)
public class SecurityContextMdcFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response, FilterChain chain) throws Exception {
try {
MDC.put("client_ip", extractClientIp(request));
MDC.put("user_agent", sanitizeForLog(request.getHeader("User-Agent")));
MDC.put("request_uri", request.getRequestURI());
MDC.put("request_method", request.getMethod());
// Hash the session ID (never log raw session IDs)
HttpSession session = request.getSession(false);
if (session != null) {
String hash = sha256(session.getId()).substring(0, 16);
MDC.put("session_id_hash", hash);
}
// Tenant from header or subdomain
String tenant = request.getHeader("X-Tenant-ID");
if (tenant != null) {
MDC.put("tenant_id", tenant);
}
chain.doFilter(request, response);
} finally {
MDC.clear();
}
}
private String sanitizeForLog(String value) {
if (value == null) return "unknown";
// Prevent log injection: strip newlines and control characters
return value.replaceAll("[\\r\\n\\t]", "_").substring(
0, Math.min(value.length(), 200));
}
private String sha256(String input) {
try {
MessageDigest md = MessageDigest.getInstance("SHA-256");
byte[] digest = md.digest(input.getBytes(StandardCharsets.UTF_8));
return HexFormat.of().formatHex(digest);
} catch (NoSuchAlgorithmException e) {
throw new RuntimeException(e);
}
}
}
Structured Logging Configuration (Logback)
<!-- logback-spring.xml -->
<configuration>
<!-- Regular application log: structured JSON -->
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
<encoder class="net.logstash.logback.encoder.LogstashEncoder"/>
</appender>
<!-- Security audit log: separate file, never rotated away -->
<appender name="SECURITY_AUDIT" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>/var/log/app/security-audit.log</file>
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
<fileNamePattern>/var/log/app/security-audit.%d{yyyy-MM-dd}.log</fileNamePattern>
<maxHistory>365</maxHistory> <!-- Retain for 1 year -->
</rollingPolicy>
<encoder class="net.logstash.logback.encoder.LogstashEncoder"/>
</appender>
<!-- Security audit logger: always INFO, never disabled -->
<logger name="SECURITY_AUDIT" level="INFO" additivity="false">
<appender-ref ref="SECURITY_AUDIT"/>
<appender-ref ref="CONSOLE"/>
</logger>
<root level="WARN">
<appender-ref ref="CONSOLE"/>
</root>
</configuration>
Custom Events for Business-Critical Auth Operations
// Publish custom events for operations Spring Security does not cover
@Service
public class AuditableUserService {
private final ApplicationEventPublisher eventPublisher;
private final UserRepository userRepository;
@Transactional
public void changePassword(String userId, String newPasswordHash) {
userRepository.updatePasswordHash(userId, newPasswordHash);
eventPublisher.publishEvent(new PasswordChangedEvent(
this, userId, Instant.now()));
}
@Transactional
public void grantRole(String userId, String role, String grantedBy) {
userRepository.addRole(userId, role);
eventPublisher.publishEvent(new RoleChangedEvent(
this, userId, role, "GRANTED", grantedBy, Instant.now()));
}
@Transactional
public void revokeRole(String userId, String role, String revokedBy) {
userRepository.removeRole(userId, role);
eventPublisher.publishEvent(new RoleChangedEvent(
this, userId, role, "REVOKED", revokedBy, Instant.now()));
}
}
@Component
public class CustomAuthEventLogger {
private static final Logger authLog = LoggerFactory.getLogger("SECURITY_AUDIT");
@EventListener
public void onPasswordChanged(PasswordChangedEvent event) {
authLog.info("{}", Map.of(
"event_type", "PASSWORD_CHANGED",
"user_id", event.getUserId(),
"timestamp", event.getTimestamp().toString(),
"ip_address", MDC.get("client_ip")
));
}
@EventListener
public void onRoleChanged(RoleChangedEvent event) {
authLog.warn("{}", Map.of(
"event_type", "ROLE_CHANGED",
"user_id", event.getUserId(),
"role", event.getRole(),
"action", event.getAction(),
"performed_by", event.getPerformedBy(),
"timestamp", event.getTimestamp().toString()
));
}
}
The Verification
@SpringBootTest
class AuthAuditLoggingTest {
@Autowired
private AuthenticationManager authenticationManager;
@SpyBean
private AuthEventLogger authEventLogger;
@Test
void successfulLoginGeneratesAuditEvent() {
Authentication result = authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken("alice", "correct-password"));
// Verify the event listener was called
verify(authEventLogger).onAuthSuccess(any(AuthenticationSuccessEvent.class));
}
@Test
void failedLoginGeneratesAuditEvent() {
assertThrows(AuthenticationException.class, () ->
authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken("alice", "wrong")));
verify(authEventLogger).onAuthFailure(
any(AbstractAuthenticationFailureEvent.class));
}
@Test
void auditLogNeverContainsCredentials(@Autowired OutputCaptureExtension output) {
// Attempt login with known password
try {
authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken("alice", "secret-password-123"));
} catch (AuthenticationException ignored) {}
// Scan all log output
String allOutput = output.getAll();
assertThat(allOutput).doesNotContain("secret-password-123");
assertThat(allOutput).doesNotContain("password");
}
@Test
void roleChangeAuditIncludesPerformer() {
AuditableUserService userService = // inject
userService.grantRole("usr_123", "ADMIN", "usr_456");
// Verify audit log contains who performed the escalation
verify(customAuthEventLogger).onRoleChanged(argThat(event ->
event.getPerformedBy().equals("usr_456") &&
event.getRole().equals("ADMIN") &&
event.getAction().equals("GRANTED")
));
}
}
The third test is critical: it scans all log output to ensure no credential material appears anywhere. Log injection of credentials is a common vulnerability, and this test catches it in the CI pipeline rather than in production incident review.
DEBUG logging logs everything. Including the Authentication object, which in many configurations contains the credentials. Including request headers, which contain bearer tokens. A log aggregation system with DEBUG-level Spring Security logs is a credential store.