Authentication Vulnerabilities in Java: Session Management & Advanced Security (Part 3)

20+ years in software development, now focused on application security. Writing hands-on guides on secure coding patterns, vulnerability analysis, and security architecture.
In Part 1, we discussed the requirements of passwords according to NIST recommendations, various methods of rate limiting using sliding window and exponential backoff, and ways to avoid enumeration of usernames using constant time methods.
In Part 2 of this tutorial series, we discussed the safe transmission of credentials using HTTPS and HSTS, vulnerabilities associated with password resets and safe implementation of the same, and the prevention of bypassing the multiple-step authentication process using proper state management.
In this third part, we'll discuss cryptographic password storage, session management vulnerabilities, defense-in-depth, and production monitoring.
1. Credential Storage: The Cryptographic Foundation
Credential storage is the final line of defense. When someone finally decides to dump our database - whether it’s from SQL injection, a misconfigured backup process, a compromised admin account, or a nefarious internal user - password storage can make the difference between instant account capture and expensive cracking efforts that could take years.
Passwords should not be stored in plaintext. Although this may seem obvious, this type of mistake still occurs on live systems.
The Storage Hierarchy
Plaintext (Severity: Critical)
username: alice
password: MyPassword123
Database breach equals immediate, total account compromise. There is no acceptable use case for storing passwords in plaintext. Ever.
Encrypted (Severity: High)
username: alice
password: AES_ENCRYPTED(MyPassword123, encryption_key)
Encryption is a two-way process. If the attacker gets their hands on the encryption key, which is often kept in the same infrastructure, sometimes even in the same database, all the passwords can be compromised in an instant. Encryption is for data which we need to read back, whereas passwords only need to be verified.
Fast Hash Without Salt (Severity: High)
username: alice
password: MD5(MyPassword123) //5f4dcc3b5aa765d61d8327deb882cf99
MD5, SHA-1, and SHA-256 are fast hash functions. A contemporary GPU can perform billions of MD5 hash calculations per second. Rainbow tables are precomputed hash-to-password mappings. They can be used to perform instant lookup attacks on common passwords. Since any person with a given password will have the same hash, one password hash can be used to break all passwords with a given hash.
Fast Hash With Salt (Severity: Medium)
username: alice
salt: 8f7a9e2b4c1d3e5f
password: SHA256(MyPassword123 + salt)
Salts outsmart rainbow tables and make it impossible to detect when multiple people share a password. Each password must be cracked separately. But again, SHA-256 is too fast, producing billions of hashes per second, and passwords are still cracked too quickly.
Slow, Salted Hash (Severity: Low - This is the baseline)
username: alice
password: \(2a\)12$R9h/cIPz0gi.URNNX3kh2OPST9/PgBkqquzi.Ss7KIUgO2t0jWMUW
Slow hashing algorithms, Argon2id, bcrypt, and scrypt, have built-in computational overhead. The bcrypt method, with cost factor 12, takes about 250ms per hash operation. The delay is not noticeable during normal logons, but it is catastrophic for attempts at brute-force hacking. An attacker capable of processing 10 billion MD5 hashes per second might be able to process only thousands of bcrypt hashes per second.
Why Bcrypt and Argon2id
Bcrypt implements Eksblowfish with a configurable cost factor: iterations = 2^cost. Cost factor 12 means 4,096 iterations of the core algorithm.
Bcrypt automatically generates and stores the salt within the hash string:
This is pretty cool: one string contains all the information required for a password verification later on. The password-verifying function retrieves the cost factor and salt from the stored string.
Argon2id is the winner of the Password Hashing Competition in 2015. It is memory-hard, requiring large allocations of RAM, and time-hard, requiring large allocations of CPU time. GPUs have lots of cores but little memory per core, and Argon2id takes advantage of this.
The configuration parameters are memory (RAM required in kilobytes; recommended value is 65536 for a value of 64MB), iterations (number of passes made on the memory; recommended value is 3), and parallelism (number of threads; recommended value is 4).
Argon2id is better for new systems, as the memory hardness provides strong protection against specialized cracking devices. Bcrypt is a safe option, albeit a bit easier to implement, as it is more established and has more library availability.
Implementation: Spring Security Password Encoding
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
@Configuration
public class PasswordEncoderConfig {
@Bean
public PasswordEncoder passwordEncoder() {
// BCrypt with cost factor 12 - adjust based on server capacity
// Target ~250ms hash time. If logins are slow, it might need to be reduced
return new BCryptPasswordEncoder(12);
// Argon2 alternative - requires more memory but more secure
// Parameters: saltLength, hashLength, parallelism, memory (KB), iterations
// return new Argon2PasswordEncoder(16, 32, 1, 65536, 3);
// PBKDF2 - only if we need FIPS 140-2 compliance
// return new Pbkdf2PasswordEncoder("", 600000, 256);
}
}
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
@Service
public class UserService {
private static final Logger logger = LoggerFactory.getLogger(UserService.class);
private static final String DUMMY_HASH = "\(2a\)12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/X4.AQPVsBXe3MZxlm";
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
private final PasswordValidationService passwordValidationService;
private final SessionRevocationService sessionRevocationService;
public UserService(UserRepository userRepository,
PasswordEncoder passwordEncoder,
PasswordValidationService passwordValidationService,
SessionRevocationService sessionRevocationService) {
this.userRepository = userRepository;
this.passwordEncoder = passwordEncoder;
this.passwordValidationService = passwordValidationService;
this.sessionRevocationService = sessionRevocationService;
}
@Transactional
public User registerUser(String username, String email, String rawPassword) {
ValidationResult validation = passwordValidationService
.validatePassword(rawPassword, null);
if (!validation.isValid()) {
throw new IllegalArgumentException("Password does not meet requirements: " +
String.join(", ", validation.errors()));
}
if (userRepository.existsByUsername(username)) {
throw new IllegalArgumentException("Username already exists");
}
String hashedPassword = passwordEncoder.encode(rawPassword);
User user = new User();
user.setUsername(username);
user.setEmail(email);
user.setPasswordHash(hashedPassword);
user.setPasswordChangedDate(Instant.now());
user.setPasswordHistory(new ArrayList<>());
logger.info("Registered new user: {}", username);
return userRepository.save(user);
}
public boolean authenticate(String username, String rawPassword) {
User user = userRepository.findByUsername(username).orElse(null);
if (user == null) {
// Constant-time dummy operation to prevent timing attacks
passwordEncoder.matches(rawPassword, DUMMY_HASH);
return false;
}
boolean authenticated = passwordEncoder.matches(rawPassword, user.getPasswordHash());
if (authenticated) {
// Check if password needs rehashing (algorithm or cost factor changed)
if (passwordEncoder.upgradeEncoding(user.getPasswordHash())) {
String newHash = passwordEncoder.encode(rawPassword);
user.setPasswordHash(newHash);
userRepository.save(user);
logger.info("Upgraded password hash for user: {}", username);
}
}
return authenticated;
}
@Transactional
public void changePassword(User user, String currentPassword, String newPassword,
String currentSessionId) {
if (!passwordEncoder.matches(currentPassword, user.getPasswordHash())) {
throw new IllegalArgumentException("Current password is incorrect");
}
if (matchesRecentPassword(newPassword, user.getPasswordHistory())) {
throw new IllegalArgumentException("Cannot reuse recent passwords");
}
ValidationResult validation = passwordValidationService
.validatePassword(newPassword, user);
if (!validation.isValid()) {
throw new IllegalArgumentException(String.join(", ", validation.errors()));
}
String newHash = passwordEncoder.encode(newPassword);
// Maintain history of last 5 password hashes
List<String> history = new ArrayList<>(user.getPasswordHistory());
history.add(0, user.getPasswordHash());
if (history.size() > 5) {
history = history.subList(0, 5);
}
user.setPasswordHash(newHash);
user.setPasswordHistory(history);
user.setPasswordChangedDate(Instant.now());
userRepository.save(user);
// Revoke other sessions - keep current session active
sessionRevocationService.revokeAllSessionsExceptCurrent(user.getId(), currentSessionId);
logger.info("Password changed for user: {}", user.getUsername());
}
private boolean matchesRecentPassword(String password, List<String> history) {
return history.stream()
.anyMatch(hash -> passwordEncoder.matches(password, hash));
}
}
The upgradeEncoding check is subtle but important. When we increase our bcrypt cost factor (say, from 10 to 12), existing hashes are still at the old cost. This transparently rehashes during successful login. Users don't need to do anything; their passwords get stronger automatically over time.
Migration from Weak Hashes
Legacy systems often store passwords with weak hashes. We can't decrypt these (that's the point), so migration happens opportunistically during user login:
import org.apache.commons.codec.digest.DigestUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class PasswordMigrationService {
private static final Logger logger = LoggerFactory.getLogger(PasswordMigrationService.class);
private final UserRepository userRepository;
private final PasswordEncoder modernEncoder;
public PasswordMigrationService(UserRepository userRepository,
PasswordEncoder modernEncoder) {
this.userRepository = userRepository;
this.modernEncoder = modernEncoder;
}
@Transactional
public boolean authenticateAndMigrate(String username, String rawPassword) {
User user = userRepository.findByUsername(username).orElse(null);
if (user == null) {
return false;
}
String storedHash = user.getPasswordHash();
// Check if already migrated by looking at hash format
if (isModernHash(storedHash)) {
return modernEncoder.matches(rawPassword, storedHash);
}
// Legacy hash - verify with old method (here assuming SHA-256)
String legacyHash = DigestUtils.sha256Hex(rawPassword);
boolean authenticated = storedHash.equals(legacyHash);
if (authenticated) {
// Migrate to strong hash immediately
String newHash = modernEncoder.encode(rawPassword);
user.setPasswordHash(newHash);
userRepository.save(user);
logger.info("Migrated password hash for user: {}", username);
}
return authenticated;
}
private boolean isModernHash(String hash) {
return hash.startsWith("\(2a\)") ||
hash.startsWith("\(2b\)") ||
hash.startsWith("$argon2");
}
}
Active accounts migrate automatically at next login. Inactive accounts retain weak hashes until they log in. For very old inactive accounts, consider forcing a password reset after some period - if they haven't logged in for 2 years, they probably need to reset anyway.
2. Session Management: Maintaining Authentication State
Authentication verifies a person’s identity at a given time. Session management preserves a person’s authenticated identity over a sequence of requests. Session vulnerabilities permit attackers to take over valid sessions without ever having to breach a person’s credentials—no need to “crack” a password when it’s easier to “steal” a session!
Session Fixation
Session fixation occurs when an attacker can set a victim's session identifier before they authenticate:
Attacker visits site, receives session ID:
ABC123.Attacker sends victim a link:
https://example.com/login?sessionid=ABC123.Victim clicks link and logs in using session
ABC123.Attacker uses session
ABC123to access victim's account.
This works because the session ID persists across the authentication boundary. The same session that was anonymous becomes authenticated, and both attacker and victim hold it.
Mitigation: Regenerate session ID on authentication:
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.web.SecurityFilterChain;
@Configuration
@EnableWebSecurity
public class SessionSecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.sessionManagement(session -> session
.sessionFixation().migrateSession()
.maximumSessions(1)
.maxSessionsPreventsLogin(false)
.expiredUrl("/login?expired=true")
)
.headers(headers -> headers
.httpStrictTransportSecurity(hsts -> hsts
.includeSubDomains(true)
.maxAgeInSeconds(31536000)
)
);
return http.build();
}
}
The migrateSession() strategy creates a new session ID while preserving session attributes like shopping cart contents. If we don't need attribute preservation, newSession() is more paranoid.
Session Token Security
Session tokens must resist theft and tampering. Cookie configuration is critical:
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.session.web.http.CookieSerializer;
import org.springframework.session.web.http.DefaultCookieSerializer;
@Configuration
public class CookieConfig {
@Bean
public CookieSerializer cookieSerializer() {
DefaultCookieSerializer serializer = new DefaultCookieSerializer();
serializer.setCookieName("SESSIONID");
serializer.setUseHttpOnlyCookie(true);
serializer.setUseSecureCookie(true);
serializer.setSameSite("Strict");
serializer.setCookieMaxAge(3600);
return serializer;
}
}
Each of these attributes will mitigate one of the attacks. HttpOnly will prevent JavaScript access, which is crucial in preventing XSS, even if an attacker injects script, they can't read the session cookie. Secure will ensure that only an HTTPS connection will carry this cookie, which will prevent an attacker from intercepting it on a network. SameSite = Strict will ensure that this cookie is only transmitted when requests originate from the same site, which will prevent most of the CSRF attacks. Note that if we use strict, when a user clicks on a link in an email and navigates to our site, this cookie will not be transmitted, which might break some flows. We might want to use Lax in that situation.
Session Termination
Sessions should end explicitly and implicitly:
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.security.core.session.SessionInformation;
import org.springframework.security.core.session.SessionRegistry;
import org.springframework.stereotype.Service;
import java.util.List;
@Service
public class SessionManagementService {
private static final Logger logger = LoggerFactory.getLogger(SessionManagementService.class);
private final SessionRegistry sessionRegistry;
public SessionManagementService(SessionRegistry sessionRegistry) {
this.sessionRegistry = sessionRegistry;
}
public void revokeAllSessions(String username) {
List<SessionInformation> sessions = sessionRegistry
.getAllSessions(username, false);
sessions.forEach(SessionInformation::expireNow);
logger.info("Revoked all {} sessions for user: {}", sessions.size(), username);
}
public void revokeAllSessionsExceptCurrent(String username, String currentSessionId) {
List<SessionInformation> sessions = sessionRegistry
.getAllSessions(username, false);
long revokedCount = sessions.stream()
.filter(s -> !s.getSessionId().equals(currentSessionId))
.peek(SessionInformation::expireNow)
.count();
logger.info("Revoked {} sessions for user: {} (kept current)", revokedCount, username);
}
public void revokeSession(String sessionId) {
SessionInformation session = sessionRegistry.getSessionInformation(sessionId);
if (session != null) {
session.expireNow();
logger.debug("Revoked session: {}", sessionId);
}
}
@Scheduled(fixedRate = 60000)
public void cleanupExpiredSessions() {
List<Object> principals = sessionRegistry.getAllPrincipals();
int cleanedCount = 0;
for (Object principal : principals) {
List<SessionInformation> sessions = sessionRegistry
.getAllSessions(principal, true);
for (SessionInformation session : sessions) {
if (session.isExpired()) {
sessionRegistry.removeSessionInformation(session.getSessionId());
cleanedCount++;
}
}
}
if (cleanedCount > 0) {
logger.debug("Cleaned up {} expired sessions", cleanedCount);
}
}
}
Sessions should be revoked on password changes (all but the current one), logout (current), security events like detected compromise (all), idle timeout when inactivity has exceeded 30 minutes, and absolute timeout when 8 hours has elapsed, irrespective of inactivity. The absolute timeout prevents "forever sessions" where the user stays logged in indefinitely by maintaining activity.
React Session Handling
import axios, { AxiosInstance, AxiosResponse, AxiosError, InternalAxiosRequestConfig } from 'axios';
interface ApiErrorResponse {
error: string;
code?: string;
}
const apiClient: AxiosInstance = axios.create({
baseURL: '/api',
timeout: 10000,
withCredentials: true
});
async function refreshAuthToken(): Promise<void> {
await apiClient.post('/auth/refresh');
}
apiClient.interceptors.response.use(
(response: AxiosResponse): AxiosResponse => response,
async (error: AxiosError<ApiErrorResponse>): Promise<never> => {
const originalRequest = error.config as InternalAxiosRequestConfig & {
_retry?: boolean;
};
if (error.response?.status === 401 && !originalRequest._retry) {
originalRequest._retry = true;
try {
await refreshAuthToken();
return apiClient(originalRequest);
} catch (refreshError) {
window.location.href = '/login?session=expired';
return Promise.reject(refreshError);
}
}
return Promise.reject(error);
}
);
export { apiClient };
// Idle timeout manager
class IdleTimeoutManager {
private timeoutId: ReturnType<typeof setTimeout> | null = null;
private readonly idleTimeoutMs: number;
private readonly checkIntervalMs: number;
private lastActivity: number;
constructor(idleMinutes: number = 30, checkIntervalSeconds: number = 60) {
this.idleTimeoutMs = idleMinutes * 60 * 1000;
this.checkIntervalMs = checkIntervalSeconds * 1000;
this.lastActivity = Date.now();
}
start(): void {
this.lastActivity = Date.now();
this.addActivityListeners();
this.scheduleCheck();
}
stop(): void {
this.removeActivityListeners();
if (this.timeoutId) {
clearTimeout(this.timeoutId);
this.timeoutId = null;
}
}
private addActivityListeners(): void {
const events = ['mousedown', 'keydown', 'scroll', 'touchstart'];
events.forEach(event => {
document.addEventListener(event, this.handleActivity);
});
}
private removeActivityListeners(): void {
const events = ['mousedown', 'keydown', 'scroll', 'touchstart'];
events.forEach(event => {
document.removeEventListener(event, this.handleActivity);
});
}
private handleActivity = (): void => {
this.lastActivity = Date.now();
};
private scheduleCheck(): void {
this.timeoutId = setTimeout(() => {
const idleTime = Date.now() - this.lastActivity;
if (idleTime >= this.idleTimeoutMs) {
this.handleTimeout();
} else {
this.scheduleCheck();
}
}, this.checkIntervalMs);
}
private handleTimeout(): void {
this.stop();
apiClient.post('/auth/logout').finally(() => {
window.location.href = '/login?reason=idle';
});
}
}
export const idleManager = new IdleTimeoutManager(30, 60);
The idle timeout manager is an UI enhancement, not a security feature. The server-side session expiration should be used as an authoritative timeout. This is because clientside applications can be modified, or users may close their browser before the logout event is fired. This is simply an enhancement for a more pleasant user experience.
3. Defense in Depth: CAPTCHA and Risk-Based Authentication
Single-layer defenses are not effective. Defense in depth means multiple independent controls are used, each catching what others miss. For example, in terms of authentication, CAPTCHA will be used to detect bots, and risk-based authentication will be used to detect anomalies.
CAPTCHA Integration
CAPTCHA challenges should appear after repeated failures, not on every login. Making legitimate users solve puzzles is friction that degrades UX; only apply it when there's evidence of abuse:
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestClient;
@Service
public class CaptchaValidationService {
private static final Logger logger = LoggerFactory.getLogger(CaptchaValidationService.class);
private final RestClient restClient;
private final String secretKey;
public CaptchaValidationService(@Value("${recaptcha.secret-key}") String secretKey) {
this.secretKey = secretKey;
this.restClient = RestClient.builder()
.baseUrl("https://www.google.com/recaptcha/api")
.build();
}
public boolean validateCaptcha(String captchaResponse, String remoteIp) {
if (captchaResponse == null || captchaResponse.isBlank()) {
return false;
}
try {
CaptchaVerifyResponse response = restClient.post()
.uri(uriBuilder -> uriBuilder
.path("/siteverify")
.queryParam("secret", secretKey)
.queryParam("response", captchaResponse)
.queryParam("remoteip", remoteIp)
.build())
.retrieve()
.body(CaptchaVerifyResponse.class);
if (response == null) {
logger.warn("Null response from CAPTCHA verification");
return false;
}
if (!response.success()) {
logger.debug("CAPTCHA verification failed: {}", response.errorCodes());
}
return response.success();
} catch (Exception e) {
logger.error("CAPTCHA verification error", e);
// Fail open - don't block legitimate users if CAPTCHA service is down
// Consider risk tolerance here
return true;
}
}
}
record CaptchaVerifyResponse(
boolean success,
String challengeTs,
String hostname,
java.util.List<String> errorCodes
) {
public CaptchaVerifyResponse {
if (errorCodes == null) {
errorCodes = java.util.List.of();
}
}
}
import jakarta.servlet.http.HttpServletRequest;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.Map;
@RestController
@RequestMapping("/api/auth")
public class LoginController {
private static final Logger logger = LoggerFactory.getLogger(LoginController.class);
private static final int CAPTCHA_THRESHOLD = 3;
private static final String DUMMY_HASH =
"\(2a\)12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/X4.AQPVsBXe3MZxlm";
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
private final AuthenticationThrottleService throttleService;
private final CaptchaValidationService captchaService;
private final JwtService jwtService;
public LoginController(UserRepository userRepository,
PasswordEncoder passwordEncoder,
AuthenticationThrottleService throttleService,
CaptchaValidationService captchaService,
JwtService jwtService) {
this.userRepository = userRepository;
this.passwordEncoder = passwordEncoder;
this.throttleService = throttleService;
this.captchaService = captchaService;
this.jwtService = jwtService;
}
@PostMapping("/login")
public ResponseEntity<Map<String, Object>> login(
@RequestBody LoginWithCaptchaRequest request,
HttpServletRequest httpRequest) {
String ipAddress = getClientIp(httpRequest);
// Check if CAPTCHA is required based on failure count
if (requiresCaptcha(request.username(), ipAddress)) {
if (request.captchaToken() == null ||
!captchaService.validateCaptcha(request.captchaToken(), ipAddress)) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(Map.of(
"error", "CAPTCHA verification required",
"captchaRequired", true
));
}
}
// Check rate limiting
ThrottleResult throttle = throttleService.checkThrottle(request.username(), ipAddress);
if (!throttle.isAllowed()) {
return ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS)
.body(Map.of("error", "Too many attempts. Please try again later."));
}
// Authenticate
User user = userRepository.findByUsername(request.username()).orElse(null);
boolean authenticated = false;
if (user != null) {
authenticated = passwordEncoder.matches(request.password(), user.getPasswordHash());
} else {
passwordEncoder.matches(request.password(), DUMMY_HASH);
}
throttleService.recordAttempt(request.username(), ipAddress, authenticated);
if (authenticated) {
throttleService.resetFailures(request.username(), ipAddress);
String token = jwtService.generateToken(user);
logger.info("Successful login for user: {} from IP: {}", user.getUsername(), ipAddress);
return ResponseEntity.ok(Map.of(
"status", "authenticated",
"token", token
));
}
logger.warn("Failed login for username: {} from IP: {}", request.username(), ipAddress);
// Include captchaRequired in response if threshold exceeded
boolean captchaNeeded = requiresCaptcha(request.username(), ipAddress);
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body(Map.of(
"error", "Invalid credentials",
"captchaRequired", captchaNeeded
));
}
private boolean requiresCaptcha(String username, String ipAddress) {
ThrottleResult result = throttleService.checkThrottle(username, ipAddress);
// This is a simplified check - in production you'd track attempt counts
return false; // Implement based on your throttle service's failure count
}
private String getClientIp(HttpServletRequest request) {
String xff = request.getHeader("X-Forwarded-For");
if (xff != null && !xff.isEmpty()) {
return xff.split(",")[0].trim();
}
return request.getRemoteAddr();
}
}
record LoginWithCaptchaRequest(
String username,
String password,
String captchaToken
) {}
Risk-Based Authentication
In risk-based authentication, the context is analyzed to detect anomalies in login activities. If the login credentials entered are correct, but the login is attempted on a different device, in a different country, at an unusual time, then:
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
@Service
public class AuthenticationRiskService {
private static final Logger logger = LoggerFactory.getLogger(AuthenticationRiskService.class);
private final GeoIpService geoIpService;
private final DeviceFingerprintService deviceService;
private final ThreatIntelligenceService threatService;
private final LoginAttemptRepository loginAttemptRepository;
public AuthenticationRiskService(GeoIpService geoIpService,
DeviceFingerprintService deviceService,
ThreatIntelligenceService threatService,
LoginAttemptRepository loginAttemptRepository) {
this.geoIpService = geoIpService;
this.deviceService = deviceService;
this.threatService = threatService;
this.loginAttemptRepository = loginAttemptRepository;
}
public RiskAssessment assessRisk(LoginAttemptContext context) {
List<RiskFactor> factors = new ArrayList<>();
int riskScore = 0;
// New device detection
if (!deviceService.isKnownDevice(context.userId(), context.deviceFingerprint())) {
factors.add(new RiskFactor("NEW_DEVICE", "Login from unrecognized device", 30));
riskScore += 30;
}
// Geographic anomaly detection
GeoLocation location = geoIpService.lookup(context.ipAddress());
if (!isUsualLocation(context.userId(), location)) {
factors.add(new RiskFactor("UNUSUAL_LOCATION", "Login from unusual location", 25));
riskScore += 25;
}
// Impossible travel detection
if (isImpossibleTravel(context.userId(), location, context.timestamp())) {
factors.add(new RiskFactor("IMPOSSIBLE_TRAVEL",
"Login location inconsistent with recent activity", 40));
riskScore += 40;
}
// Known malicious IP
if (threatService.isMaliciousIp(context.ipAddress())) {
factors.add(new RiskFactor("MALICIOUS_IP", "Login from known malicious IP", 50));
riskScore += 50;
}
RiskLevel level = determineRiskLevel(riskScore);
logger.info("Risk assessment for user {}: score={}, level={}, factors={}",
context.userId(), riskScore, level, factors.size());
return new RiskAssessment(riskScore, level, factors);
}
private boolean isUsualLocation(Long userId, GeoLocation location) {
List<LoginAttempt> recentLogins = loginAttemptRepository
.findRecentSuccessfulLogins(userId, Instant.now().minus(Duration.ofDays(30)));
return recentLogins.stream()
.map(login -> geoIpService.lookup(login.getIpAddress()))
.anyMatch(recent -> calculateDistance(recent, location) < 500); // 500km radius
}
private boolean isImpossibleTravel(Long userId, GeoLocation currentLocation, Instant timestamp) {
LoginAttempt lastLogin = loginAttemptRepository
.findLastSuccessfulLogin(userId)
.orElse(null);
if (lastLogin == null) {
return false;
}
GeoLocation lastLocation = geoIpService.lookup(lastLogin.getIpAddress());
double distanceKm = calculateDistance(lastLocation, currentLocation);
Duration timeDiff = Duration.between(lastLogin.getTimestamp(), timestamp);
// Max plausible speed: 900 km/h (commercial jet)
double maxPossibleDistanceKm = (timeDiff.toMinutes() / 60.0) * 900;
return distanceKm > maxPossibleDistanceKm;
}
private double calculateDistance(GeoLocation loc1, GeoLocation loc2) {
// Haversine formula for great-circle distance
double earthRadiusKm = 6371;
double lat1Rad = Math.toRadians(loc1.latitude());
double lat2Rad = Math.toRadians(loc2.latitude());
double deltaLatRad = Math.toRadians(loc2.latitude() - loc1.latitude());
double deltaLonRad = Math.toRadians(loc2.longitude() - loc1.longitude());
double a = Math.sin(deltaLatRad / 2) * Math.sin(deltaLatRad / 2) +
Math.cos(lat1Rad) * Math.cos(lat2Rad) *
Math.sin(deltaLonRad / 2) * Math.sin(deltaLonRad / 2);
double c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
return earthRadiusKm * c;
}
private RiskLevel determineRiskLevel(int score) {
if (score >= 60) return RiskLevel.HIGH;
if (score >= 30) return RiskLevel.MEDIUM;
return RiskLevel.LOW;
}
}
record LoginAttemptContext(
Long userId,
String ipAddress,
String deviceFingerprint,
String userAgent,
Instant timestamp
) {}
record RiskAssessment(
int score,
RiskLevel level,
List<RiskFactor> factors
) {}
record RiskFactor(
String code,
String description,
int contribution
) {}
record GeoLocation(
double latitude,
double longitude,
String country,
String city
) {}
enum RiskLevel {
LOW, MEDIUM, HIGH
}
For high risk logins, MFA should be required even if the user does not have MFA enabled. For medium risk, the user should be required to authenticate through email link. Low risk logins are handled normally.
4. Production Monitoring and Alerting
Authentication is a high-value target. Effective monitoring detects attacks in progress and provides forensic data for incident response.
import io.micrometer.core.instrument.Counter;
import io.micrometer.core.instrument.MeterRegistry;
import io.micrometer.core.instrument.Timer;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import java.time.Duration;
import java.time.Instant;
import java.util.concurrent.atomic.AtomicLong;
@Service
public class AuthenticationMetricsService {
private static final Logger logger = LoggerFactory.getLogger(AuthenticationMetricsService.class);
private final Counter loginSuccessCounter;
private final Counter loginFailureCounter;
private final Counter accountLockoutCounter;
private final Counter mfaChallengeCounter;
private final Counter mfaSuccessCounter;
private final Counter passwordResetCounter;
private final Timer loginDurationTimer;
private final AtomicLong failuresLastHour = new AtomicLong(0);
private final AtomicLong successesLastHour = new AtomicLong(0);
private final LoginAttemptRepository loginAttemptRepository;
public AuthenticationMetricsService(MeterRegistry registry,
LoginAttemptRepository loginAttemptRepository) {
this.loginAttemptRepository = loginAttemptRepository;
this.loginSuccessCounter = Counter.builder("auth.login.success")
.description("Successful login attempts")
.register(registry);
this.loginFailureCounter = Counter.builder("auth.login.failure")
.description("Failed login attempts")
.register(registry);
this.accountLockoutCounter = Counter.builder("auth.lockout")
.description("Account lockouts triggered")
.register(registry);
this.mfaChallengeCounter = Counter.builder("auth.mfa.challenge")
.description("MFA challenges issued")
.register(registry);
this.mfaSuccessCounter = Counter.builder("auth.mfa.success")
.description("Successful MFA verifications")
.register(registry);
this.passwordResetCounter = Counter.builder("auth.password.reset")
.description("Password reset requests")
.register(registry);
this.loginDurationTimer = Timer.builder("auth.login.duration")
.description("Login processing time")
.register(registry);
// Register gauges for hourly metrics
registry.gauge("auth.failures.hourly", failuresLastHour);
registry.gauge("auth.successes.hourly", successesLastHour);
}
public void recordAuthenticationAttempt(String username, String ipAddress,
boolean success, Duration duration) {
if (success) {
loginSuccessCounter.increment();
} else {
loginFailureCounter.increment();
}
loginDurationTimer.record(duration);
logger.debug("Auth attempt: user={}, ip={}, success={}, duration={}ms",
anonymize(username), anonymizeIp(ipAddress), success, duration.toMillis());
}
public void recordAccountLockout(String username) {
accountLockoutCounter.increment();
logger.warn("Account locked: user={}", anonymize(username));
}
public void recordMfaChallenge(String username) {
mfaChallengeCounter.increment();
}
public void recordMfaSuccess(String username) {
mfaSuccessCounter.increment();
}
public void recordPasswordReset(String email) {
passwordResetCounter.increment();
logger.info("Password reset requested: email={}", anonymize(email));
}
@Scheduled(fixedRate = 3600000) // Every hour
public void updateHourlyMetrics() {
Instant oneHourAgo = Instant.now().minus(Duration.ofHours(1));
long failures = loginAttemptRepository.countFailuresSince(oneHourAgo);
long successes = loginAttemptRepository.countSuccessesSince(oneHourAgo);
failuresLastHour.set(failures);
successesLastHour.set(successes);
logger.info("Hourly auth metrics: failures={}, successes={}", failures, successes);
}
private String anonymize(String value) {
if (value == null || value.length() < 4) {
return "***";
}
return value.substring(0, 2) + "***" + value.substring(value.length() - 2);
}
private String anonymizeIp(String ip) {
if (ip == null) return "unknown";
int lastDot = ip.lastIndexOf('.');
return lastDot > 0 ? ip.substring(0, lastDot) + ".xxx" : ip;
}
}
Alerting Rules
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import java.time.Duration;
import java.time.Instant;
@Service
public class AuthenticationAlertService {
private static final Logger logger = LoggerFactory.getLogger(AuthenticationAlertService.class);
private final AlertingService alertingService;
private final LoginAttemptRepository attemptRepository;
public AuthenticationAlertService(AlertingService alertingService,
LoginAttemptRepository attemptRepository) {
this.alertingService = alertingService;
this.attemptRepository = attemptRepository;
}
@Scheduled(fixedRate = 300000) // Every 5 minutes
public void checkForAnomalies() {
checkHighFailureVolume();
checkSuccessRateDrop();
checkDistributedAttack();
}
private void checkHighFailureVolume() {
Instant fiveMinutesAgo = Instant.now().minus(Duration.ofMinutes(5));
long failures = attemptRepository.countFailuresSince(fiveMinutesAgo);
if (failures > 100) {
alertingService.sendAlert(
AlertLevel.WARNING,
"High authentication failure volume",
String.format("%d failed attempts in last 5 minutes", failures)
);
}
}
private void checkSuccessRateDrop() {
Instant oneHourAgo = Instant.now().minus(Duration.ofHours(1));
double successRate = calculateSuccessRate(oneHourAgo);
if (successRate < 0.5) {
alertingService.sendAlert(
AlertLevel.CRITICAL,
"Authentication success rate critical",
String.format("Success rate dropped to %.1f%%", successRate * 100)
);
}
}
private void checkDistributedAttack() {
Instant fiveMinutesAgo = Instant.now().minus(Duration.ofMinutes(5));
long uniqueIps = attemptRepository.countUniqueFailingIpsSince(fiveMinutesAgo);
if (uniqueIps > 50) {
alertingService.sendAlert(
AlertLevel.WARNING,
"Possible distributed attack detected",
String.format("Failures from %d unique IPs in 5 minutes", uniqueIps)
);
}
}
private double calculateSuccessRate(Instant since) {
long successes = attemptRepository.countSuccessesSince(since);
long failures = attemptRepository.countFailuresSince(since);
long total = successes + failures;
if (total == 0) return 1.0;
return (double) successes / total;
}
}
enum AlertLevel {
INFO, WARNING, CRITICAL
}
interface AlertingService {
void sendAlert(AlertLevel level, String title, String message);
}
Key alerts to configure: high failure volume, where more than 100 failure events in 5 minutes indicate something is wrong and warrants an investigation, success rate reduction, where a rapid reduction in success rates, e.g., from 95% to 50%, indicates something is wrong, which might mean an attack or a problem, distributed failure, where many different IP addresses failing might indicate that there is a credential stuffing problem with a botnet, and unusual lockouts, where an unusual number of lockouts might indicate an attack or an application problem.
Wrapping Part 3
In the third part, we have discussed the advanced security features that complement the authentication process.
Credential Storage. Passwords should never be stored in plaintext, nor should they be stored using fast hashes. bcrypt (with cost factor 12+) or Argon2id should be used. These algorithms were specifically created to be slow, making brute-force attacks impractical. Opportunistic migration of old hashes is acceptable during the login process; old hashes cannot be decrypted, but they can be upgraded when the user provides correct credentials.
Session Management. Regenerate session identifiers during authentication. Set cookie attributes as HttpOnly, Secure, and SameSite. Set idle timeouts as well as absolute timeouts. Revoke sessions after security-related events, e.g., changing passwords.
Defense in Depth. CAPTCHA provides bot detection after repeated failures—not on every login. Risk-based authentication detects impossible travel and new devices, which trigger step-up verification.
Monitoring and Alerting. Monitor authentication activity (e.g., success rate, failure counts, and lockouts). Alert on abnormal activity to detect ongoing attack activity. Log information to support forensic analysis without compromising user privacy.
These controls all work together. None of the defense measures is sufficient in itself; all the defense measures working together in the right manner provide the system.






