Complete Java Login Risk Control: From Brute-Force Detection to Adaptive Authentication
This article details a complete Java implementation of login risk control, covering rule-based risk scoring, device fingerprinting, impossible travel detection, adaptive multi-factor authentication, and a two-phase login API that dynamically adjusts security friction based on real-time risk signals like new devices, geo-anomalies, and recent security events.
Authentication Success ≠ Login Security
The author recounts a scenario where a user with correct password and TOTP logged in from a new Windows device in a distant region at 3 AM, far from their usual Tokyo location. This highlighted that authentication (proving knowledge of credentials) and login security (assessing behavioral normality) are distinct concerns.
Password answers: Do you know the password?
TOTP answers: Do you possess the second factor?
Trusted device answers: Does this client hold a long-lived credential issued after strong authentication?
Login risk control answers: Even with valid credentials, does this login behavior look normal?
Risk Engine v1: Rules First, No Machine Learning
For most Java business systems, the author advises against starting with ML. Valuable login signals are limited and expressible as rules:
Consecutive failed login count per account
Whether the current device has appeared before
Whether the IP is new
Country/region change
Time since last successful login
Short-term cross-region login (impossible travel)
Presence of trusted device token
Abnormal login time
IP reputation risk
The first version is a plain Java service:
public interface LoginRiskService { RiskResult evaluate(LoginRiskContext context); }public record LoginRiskContext( Long userId, String ip, String country, String region, String deviceId, String userAgent, boolean trustedDevice, Instant loginTime ) {}public record RiskResult( int score, RiskLevel level, List reasons, RiskAction action ) {}No AI, no complex models — just clear, explainable signals.
Risk Scoring: Explainable Over Precise
Risk levels and actions:
public enum RiskLevel { LOW, MEDIUM, HIGH, CRITICAL } public enum RiskAction { ALLOW, REQUIRE_MFA, REQUIRE_REAUTHENTICATION, BLOCK }Simple threshold mapping:
0-29 LOW → ALLOW 30-59 MEDIUM → REQUIRE_MFA 60-79 HIGH → REQUIRE_MFA + security notification 80+ CRITICAL → BLOCKExample signal weights:
NEW_DEVICE +20 NEW_COUNTRY +30 ABNORMAL_TIME +10 IMPOSSIBLE_TRAVEL +40 TRUSTED_DEVICE -20 RECENT_PASSWORD_FAILURES +20A score of 70 yields HIGH → REQUIRE_MFA. The score is not a scientific constant; it combines signals. Crucially, every score increase and resulting action must be explainable to avoid opaque "model says risky" responses.
Brute-Force Detection: Account + IP Dimensions
The most common attack is password guessing. OWASP Authentication Cheat Sheet emphasizes tying failure counts to the account, not just IP, because attackers rotate proxies, mobile networks, cloud servers.
Two counters are maintained:
auth:failed:account:{userId} auth:failed:ip:{ip}public void recordFailedLogin(Long userId, String ip) { failureCounter.incrementAccount(userId); failureCounter.incrementIp(ip); }Signals:
Same account: 5 failures in 5 minutes → risk increase
Same IP: attacks 100 different accounts in 5 minutes → risk increase
These behaviors differ in nature and both must be tracked.
Avoid Hard Lockouts: Progressive Cost
Locking an account after N failures enables denial-of-service: an attacker who knows an admin username can lock them out. Instead, escalate friction:
1-3 failures → normal response 4-5 failures → brief delay 6-10 failures → require CAPTCHA / stronger verification sustained anomaly → temporarily block password auth, but allow account recoveryIf Passkey or MFA is enabled, steer the user to stronger auth.
Login Error Messages: Don't Leak Account Existence
Returning "account not found" vs "password wrong" enables username enumeration. Unify external message to "account or password incorrect". Internal logs can distinguish USER_NOT_FOUND, PASSWORD_INVALID, ACCOUNT_DISABLED, but these details need not reach anonymous login pages.
Device ID as Risk Signal, Not Credential
Device IDs (generated during multi-device login) are not security credentials but excellent risk signals. Example: user historically uses D001 (Mac Chrome) and D002 (iPhone). Sudden D003 (Windows Edge) adds NEW_DEVICE +20. Combined with NEW_COUNTRY +30 → score 50 → require TOTP. If the device holds a valid Trusted Device Token (trustedDevice=true), apply TRUSTED_DEVICE -20, possibly lowering risk enough to allow without extra step.
OWASP recommends IP, geolocation, device fingerprint, remembered-device cookie as risk context to decide additional auth, not as standalone authentication replacements.
Geo-Based Risk: Beyond Raw IP Comparison
Comparing IP strings fails because home broadband, mobile networks, VPNs, corporate proxies, cloud desktops change IPs frequently. Instead use coarse granularity: country, region, ASN, network type.
Tokyo → Kanagawa (same country/region) → low risk
Tokyo → Germany within 10 minutes → high risk (impossible travel)
Impossible Travel Detection
Calculate approximate speed between consecutive successful logins:
double distanceKm = geoService.distance(previous.location(), current.location()); long minutes = Duration.between(previous.loginAt(), current.loginAt()).toMinutes(); double speed = distanceKm / (minutes / 60.0);If speed is implausible (e.g., 8000 km/h), add IMPOSSIBLE_TRAVEL +40. This signal should raise risk and trigger additional auth, not outright block, because VPNs can mimic it.
Signals ≠ Conclusions
VPN use ≠ attacker. 3 AM login ≠ compromise. New computer ≠ stolen account. These only indicate deviation from history. Risk system should combine signals additively:
NEW_DEVICE +20 NEW_COUNTRY +30 ABNORMAL_TIME +10 TRUSTED_DEVICE -20Not hard-coded blocks like if (vpn) block(); which would break legitimate corporate VPN, travel, device changes.
Trusted Device Lowers Risk, Doesn't Bypass Engine
A valid Trusted Device Token should reduce risk score, not skip risk evaluation. Example: NEW_COUNTRY +30, TRUSTED_DEVICE -20 → net 10 → allow. But if IMPOSSIBLE_TRAVEL +40 and RECENT_PASSWORD_CHANGE +30 stack, risk may still exceed threshold, requiring re-verification even on a trusted device.
Multi-Level Risk Actions (Adaptive Authentication)
Binary ALLOW/BLOCK is too coarse. Use at least four levels:
public enum RiskAction { ALLOW, REQUIRE_MFA, REQUIRE_STRONG_AUTH, BLOCK }LOW → ALLOW
MEDIUM → REQUIRE_MFA (e.g., TOTP)
HIGH → REQUIRE_STRONG_AUTH (Passkey + TOTP or recovery email confirmation)
CRITICAL → BLOCK
Goal: higher risk demands stronger authentication, aligning with adaptive authentication — low friction for low risk, step-up for high risk.
Two-Phase Login API
Phase 1: POST /api/auth/login performs first-factor auth + risk evaluation.
Low risk:
{ "status": "AUTHENTICATED", "accessToken": "...", "refreshToken": "..." }MFA needed:
{ "status": "ADDITIONAL_AUTH_REQUIRED", "challengeToken": "...", "methods": ["TOTP", "PASSKEY"] }Blocked: { "status": "LOGIN_REJECTED" } Phase 2: POST /api/auth/challenges/{id}/verify completes additional auth. This decouples risk policy changes from login endpoint structure.
Challenge Binding to Original Context
A challenge must capture the original login context:
public record LoginChallenge( String challengeId, Long userId, String ip, String deviceId, RiskLevel riskLevel, Instant createdAt, Instant expiresAt ) {}If challenge created from IP A but TOTP submitted from IP B, policy must decide whether to accept. Prevent unrelated clients from hijacking a challenge.
Persist Risk Context in Session
Store risk details in session for downstream step-up auth:
public record SessionSecurityContext( int loginRiskScore, RiskLevel loginRiskLevel, Set reasons, AuthStrength authStrength, Instant strongAuthenticatedAt ) {}Example:
loginRiskScore=45, riskLevel=MEDIUM, reasons={NEW_DEVICE, NEW_COUNTRY}, authStrength=MFA, strongAuthenticatedAt=08:21. Critical operations (password change, Passkey deletion, TOTP disable, recovery code generation, admin permission changes) can check securityContext.strongAuthenticationExpired() and throw StepUpRequiredException, linking login risk control with step-up authentication.
Security Events Increase Risk
Recent security events elevate risk for subsequent logins:
Password changed (via forgot-password flow)
TOTP disabled
Passkey removed
Phone number changed
Recovery email changed
Define SecurityEventType enum. If now - passwordChangedAt < 30 min, add RECENT_PASSWORD_CHANGE +20. Combined with NEW_DEVICE + NEW_COUNTRY, easily pushes into high risk. This simple rule often outperforms complex models.
Login Success Notifications
For high-risk successful logins, send notification: "New device login detected. Device: Chrome on Windows. Time: 2026-11-05 08:32. Region: xxx. If not you, change password immediately." Via email, in-app, push. User is best judge of legitimacy.
Notification Content: Limit Sensitivity
Don't expose full IP, full device fingerprint, internal risk score, rule IDs. Show device type, approximate region, login time. Internal logs retain full details:
riskScore=70, reasons=[NEW_DEVICE, NEW_COUNTRY, IMPOSSIBLE_TRAVEL], action=REQUIRE_MFAfor audit.
Rule Engine: Independent, Composable Rules
Avoid monolithic if-else chains. Define LoginRiskRule interface:
public interface LoginRiskRule { RiskSignal evaluate(LoginRiskContext context); }Example implementations:
@Component public class NewDeviceRiskRule implements LoginRiskRule { @Override public RiskSignal evaluate(LoginRiskContext context) { boolean known = deviceRepository.exists(context.userId(), context.deviceId()); if (known) return RiskSignal.none(); return new RiskSignal(RiskReason.NEW_DEVICE, 20); } }@Component public class NewCountryRiskRule implements LoginRiskRule { @Override public RiskSignal evaluate(LoginRiskContext context) { boolean seenBefore = loginHistoryRepository.hasCountry(context.userId(), context.country()); if (seenBefore) return RiskSignal.none(); return new RiskSignal(RiskReason.NEW_COUNTRY, 30); } }Aggregate:
int score = rules.stream() .map(rule -> rule.evaluate(context)) .mapToInt(RiskSignal::score) .sum();Externalize Rule Weights, Version Policies
Hardcoded weights require redeployment for tuning. Move to config:
security: login-risk: rules: new-device: 20 new-country: 25 impossible-travel: 40 trusted-device: -20 recent-password-change: 25 thresholds: medium: 30 high: 60 critical: 80Java implements rule logic; weights adjustable at runtime. Include policyVersion in risk result and logs for audit:
{ "score": 65, "level": "HIGH", "policyVersion": "2026-11-05-v3" }Async Logging to Keep Login Fast
Login is high-frequency. Don't synchronously write user table, login history, risk details, device info, security events, emails, notifications. Only synchronously complete: password verification, risk calculation, MFA decision, session creation. Emit LoginSucceededEvent for async consumers to handle history, audit, notifications.
Rich Login History Schema
Old schema ( user_id, ip, success, created_at) insufficient. Store:
userId, deviceId, deviceType, ip, country, region, authMethod, riskScore, riskLevel, riskReasons, result, createdAtExample log:
User:10086, Device:NEW_DEVICE, Region:Germany, Auth:PASSWORD+TOTP, Risk:70/HIGH, Reasons:NEW_COUNTRY+IMPOSSIBLE_TRAVEL, Result:SUCCESS_AFTER_MFA. Invaluable for manual investigation and rule optimization.
Prevent False Positives: Prefer MFA Over Block
"Better false positive than false negative" leads to user frustration: users disable MFA, contact support, share accounts — making system less secure. For suspicious-but-uncertain signals, use REQUIRE_MFA not BLOCK. Example: new device + new country → require Passkey/TOTP. Only block with strong evidence.
Risk System Doesn't Prove Attacker Identity
Risk engine cannot know "this is an attacker". It only knows "behavior deviates from history". Response: "Okay, verify once more." This is risk-based authentication:
Low risk: password/Passkey → direct access
Medium: add TOTP
High: require Passkey or stronger + security notification
Clear attack: block
Security strength adapts dynamically, not uniformly strict.
Complete Login Chain
First factor (PASSWORD, SMS, PASSKEY, QR) → "Who are you?"
Login Risk Engine → "Is this login anomalous?"
Step-up auth (TOTP, Passkey, Recovery Code) when needed
LoginSession → "Which device owns this session?"
Access Token + Refresh Token → business access & silent renewal
Trusted Device → reduce repeated step-up friction on known devices
Flow: User login → first factor → Risk Engine → (low risk: direct; medium/high: step-up MFA) → LoginSession → Access+Refresh Tokens.
Start with simple, explainable rules: recent failures? new device? new region? impossible travel? recent password/MFA change? trusted device credential? These signals already far surpass if (passwordCorrect) login();. Never treat risk signals as identity proof — IP, device fingerprint, location cannot prove identity; they only indicate behavioral similarity. When identity uncertain, demand real authentication.
Signed-in readers can open the original source through BestHub's protected redirect.
This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactand we will review it promptly.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
