Incident Detection from Auth Logs: Patterns, Anomalies, and Correlation
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:
| Pattern | Signal | Confidence |
|---|---|---|
| Login from new IP + new device + new geolocation | Account takeover | Medium |
| Login from two geolocations within impossible travel time | Account takeover | High |
| Role change followed by data export within 1 hour | Privilege escalation | High |
| Multiple accounts succeeding from IPs that also have failures | Credential stuffing | High |
| Session created without corresponding login event | Token forgery or session hijacking | Critical |
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.