Skip to main content
the auth layer

Incident Detection from Auth Logs: Patterns, Anomalies, and Correlation

6 min read Chapter 42 of 45

Incident Detection from Auth Logs

The Assumption

If nobody complains, the auth system is working correctly. The assumption: attacks that do not cause visible failures are not happening.

Credential stuffing that succeeds does not generate failed login alerts. The attacker logs in with a valid password (reused from a breach). From the system’s perspective, it looks like a normal login. The attacker accesses data, exports it, and logs out. No anomaly in failure rates. No account lockout. The breach is detected weeks later, if at all.

The Attack

Scenario: Impossible travel. Alice logs in from New York at 9:00 AM. Forty-five minutes later, a login occurs for Alice’s account from Tokyo. No commercial flight covers New York to Tokyo in 45 minutes. One of these sessions is the attacker.

Scenario: Privilege escalation sequence. A user with read-only access logs in. Over the next hour, four role changes are made to their account, escalating them from viewer to admin. The role changes were made by an admin account that authenticated from an unusual IP. The admin account was compromised first, then used to escalate the attacker’s original account.

Scenario: Credential stuffing (success pattern). 500 login attempts in one hour from 200 different IPs, targeting 500 different accounts. 497 fail. 3 succeed. The 3 successes are from IP addresses that have never been seen before for those accounts. The failure rate (99.4%) is normal for that volume. No alert fires because the per-IP rate is below threshold, the per-account rate is 1 attempt, and the global failure rate is within bounds.

The Spec or Mechanism

Incident detection from auth logs requires pattern matching across multiple events:

PatternSignalConfidence
Login from new IP + new device + new geolocationAccount takeoverMedium
Login from two geolocations within impossible travel timeAccount takeoverHigh
Role change followed by data export within 1 hourPrivilege escalationHigh
Multiple accounts succeeding from IPs that also have failuresCredential stuffingHigh
Session created without corresponding login eventToken forgery or session hijackingCritical

The Implementation

Impossible Travel Detection

// VULNERABLE: No geographic analysis of login patterns
// Every login from any location is treated identically
// HARDENED: Impossible travel detector
@Service
public class ImpossibleTravelDetector {

    private final AuthEventRepository eventRepository;
    private final GeoIpService geoIpService;
    private final AlertService alertService;

    private static final double MAX_TRAVEL_SPEED_KPH = 1200.0; // Fastest commercial flight

    @EventListener
    public void onLoginSuccess(AuthenticationSuccessEvent event) {
        String userId = event.getAuthentication().getName();
        String currentIp = MDC.get("client_ip");

        GeoLocation currentLocation = geoIpService.locate(currentIp);
        if (currentLocation == null) return;

        // Find previous login
        Optional<AuthEvent> previousLogin = eventRepository
            .findMostRecentSuccessfulLogin(userId);

        if (previousLogin.isEmpty()) return;

        GeoLocation previousLocation = geoIpService.locate(
            previousLogin.get().getIpAddress());
        if (previousLocation == null) return;

        // Calculate distance and time
        double distanceKm = haversineDistance(
            previousLocation.lat(), previousLocation.lon(),
            currentLocation.lat(), currentLocation.lon());

        Duration timeBetween = Duration.between(
            previousLogin.get().getTimestamp(), Instant.now());

        double hoursElapsed = timeBetween.toMinutes() / 60.0;
        if (hoursElapsed <= 0) return;

        double requiredSpeedKph = distanceKm / hoursElapsed;

        if (requiredSpeedKph > MAX_TRAVEL_SPEED_KPH && distanceKm > 100) {
            alertService.sendAlert("IMPOSSIBLE_TRAVEL", Map.of(
                "user_id", userId,
                "previous_location", previousLocation.city() + ", " + previousLocation.country(),
                "current_location", currentLocation.city() + ", " + currentLocation.country(),
                "distance_km", distanceKm,
                "time_between_minutes", timeBetween.toMinutes(),
                "required_speed_kph", requiredSpeedKph
            ));
        }
    }

    private double haversineDistance(double lat1, double lon1,
            double lat2, double lon2) {
        double R = 6371; // Earth radius in km
        double dLat = Math.toRadians(lat2 - lat1);
        double dLon = Math.toRadians(lon2 - lon1);
        double a = Math.sin(dLat / 2) * Math.sin(dLat / 2)
            + Math.cos(Math.toRadians(lat1)) * Math.cos(Math.toRadians(lat2))
            * Math.sin(dLon / 2) * Math.sin(dLon / 2);
        double c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
        return R * c;
    }
}

Credential Stuffing Pattern Detection

@Service
public class CredentialStuffingDetector {

    private final RedisTemplate<String, String> redis;
    private final AlertService alertService;

    /**
     * Correlate IPs across successful and failed logins.
     * Credential stuffing fingerprint: an IP that has both successful
     * and failed logins for DIFFERENT accounts in a short window.
     */
    @Scheduled(fixedRate = 5, timeUnit = TimeUnit.MINUTES)
    public void detectStuffingPattern() {
        // Get all IPs with login failures in the last 15 minutes
        Set<String> failureIps = redis.opsForSet().members("auth:failure_ips:current");
        if (failureIps == null || failureIps.isEmpty()) return;

        // Get all IPs with login successes in the last 15 minutes
        Set<String> successIps = redis.opsForSet().members("auth:success_ips:current");
        if (successIps == null) return;

        // IPs that appear in both sets
        Set<String> suspiciousIps = new HashSet<>(failureIps);
        suspiciousIps.retainAll(successIps);

        for (String ip : suspiciousIps) {
            Long failureCount = redis.opsForValue().get("auth:ip_failures:" + ip) != null
                ? Long.parseLong(redis.opsForValue().get("auth:ip_failures:" + ip))
                : 0L;
            Long successCount = redis.opsForValue().get("auth:ip_successes:" + ip) != null
                ? Long.parseLong(redis.opsForValue().get("auth:ip_successes:" + ip))
                : 0L;

            // Stuffing pattern: many failures, few successes, from same IP
            if (failureCount > 10 && successCount > 0 && successCount < failureCount) {
                Set<String> compromisedAccounts = redis.opsForSet().members(
                    "auth:ip_success_accounts:" + ip);

                alertService.sendAlert("CREDENTIAL_STUFFING_DETECTED", Map.of(
                    "ip_address", ip,
                    "failure_count", failureCount,
                    "success_count", successCount,
                    "potentially_compromised_accounts", compromisedAccounts
                ));

                // Force password reset for compromised accounts
                if (compromisedAccounts != null) {
                    compromisedAccounts.forEach(this::forcePasswordReset);
                }
            }
        }
    }
}

Privilege Escalation Detection

@Component
public class PrivilegeEscalationDetector {

    private static final Logger authLog = LoggerFactory.getLogger("SECURITY_AUDIT");
    private final AlertService alertService;

    @EventListener
    public void onRoleChanged(RoleChangedEvent event) {
        if (!"GRANTED".equals(event.getAction())) return;

        // High-privilege roles that warrant immediate alerting
        Set<String> highPrivilegeRoles = Set.of("ADMIN", "SUPER_ADMIN",
            "BILLING_ADMIN", "TENANT_ADMIN");

        if (highPrivilegeRoles.contains(event.getRole())) {
            alertService.sendAlert("PRIVILEGE_ESCALATION", Map.of(
                "user_id", event.getUserId(),
                "role_granted", event.getRole(),
                "granted_by", event.getPerformedBy(),
                "timestamp", event.getTimestamp().toString(),
                "ip_address", MDC.get("client_ip")
            ));

            authLog.warn("{}", Map.of(
                "event_type", "PRIVILEGE_ESCALATION_ALERT",
                "user_id", event.getUserId(),
                "role", event.getRole(),
                "granted_by", event.getPerformedBy()
            ));
        }
    }
}

The Verification

@SpringBootTest
class IncidentDetectionTest {

    @Autowired
    private ImpossibleTravelDetector travelDetector;

    @Autowired
    private AuthEventRepository eventRepository;

    @MockBean
    private AlertService alertService;

    @MockBean
    private GeoIpService geoIpService;

    @Test
    void impossibleTravelTriggersAlert() {
        // Previous login: New York, 45 minutes ago
        when(geoIpService.locate("1.2.3.4"))
            .thenReturn(new GeoLocation(40.7128, -74.0060, "New York", "US"));
        eventRepository.save(new AuthEvent(
            "alice", "1.2.3.4", "AUTH_LOGIN_SUCCESS",
            Instant.now().minus(Duration.ofMinutes(45))));

        // Current login: Tokyo
        when(geoIpService.locate("5.6.7.8"))
            .thenReturn(new GeoLocation(35.6762, 139.6503, "Tokyo", "JP"));
        MDC.put("client_ip", "5.6.7.8");

        Authentication auth = new TestingAuthenticationToken("alice", null);
        travelDetector.onLoginSuccess(new AuthenticationSuccessEvent(auth));

        verify(alertService).sendAlert(eq("IMPOSSIBLE_TRAVEL"), argThat(details ->
            details.get("user_id").equals("alice") &&
            (double) details.get("distance_km") > 10000
        ));
    }

    @Test
    void normalTravelDoesNotTriggerAlert() {
        // Previous login: Manhattan, 2 hours ago
        when(geoIpService.locate("1.2.3.4"))
            .thenReturn(new GeoLocation(40.7128, -74.0060, "New York", "US"));
        eventRepository.save(new AuthEvent(
            "bob", "1.2.3.4", "AUTH_LOGIN_SUCCESS",
            Instant.now().minus(Duration.ofHours(2))));

        // Current login: Brooklyn (same city)
        when(geoIpService.locate("5.6.7.8"))
            .thenReturn(new GeoLocation(40.6782, -73.9442, "Brooklyn", "US"));
        MDC.put("client_ip", "5.6.7.8");

        Authentication auth = new TestingAuthenticationToken("bob", null);
        travelDetector.onLoginSuccess(new AuthenticationSuccessEvent(auth));

        verify(alertService, never()).sendAlert(any(), any());
    }

    @Test
    void privilegeEscalationAlertFires() {
        PrivilegeEscalationDetector detector = new PrivilegeEscalationDetector(alertService);

        RoleChangedEvent event = new RoleChangedEvent(
            this, "usr_attacker", "ADMIN", "GRANTED", "usr_compromised_admin", Instant.now());

        detector.onRoleChanged(event);

        verify(alertService).sendAlert(eq("PRIVILEGE_ESCALATION"), argThat(details ->
            details.get("user_id").equals("usr_attacker") &&
            details.get("granted_by").equals("usr_compromised_admin")
        ));
    }
}

The first test validates impossible travel: New York to Tokyo in 45 minutes triggers an alert. The second test proves that normal movement (Manhattan to Brooklyn in 2 hours) does not. Together, they define the detection boundary: alerts fire for physically impossible scenarios, not for normal user behavior.

Credential stuffing with a success rate of 0.1% generates 999 failures and 1 successful unauthorized access per 1,000 attempts. If you log only successes or only failures in isolation, the pattern is invisible. The attack looks like normal background noise. The single success looks like a legitimate login.