Skip to main content
the auth layer

Spring Security Event System and Structured Auth Logging

6 min read Chapter 41 of 45

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 authentication
  • AbstractAuthenticationFailureEvent - 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.