Skip to content
Complete Java Login Risk Control: From Brute-Force Detection to Adaptive Authentication

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.

LuTiao Programming
LuTiao Programming
LuTiao Programming

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  → BLOCK

Example signal weights:

NEW_DEVICE        +20 NEW_COUNTRY       +30 ABNORMAL_TIME     +10 IMPOSSIBLE_TRAVEL +40 TRUSTED_DEVICE    -20 RECENT_PASSWORD_FAILURES +20

A 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 recovery

If 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 -20

Not 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_MFA

for 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: 80

Java 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, createdAt

Example 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.

Original Source

Signed-in readers can open the original source through BestHub's protected redirect.

Sign in to view source
Republication Notice

This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactadmin@besthub.devand we will review it promptly.

JavaDevice Fingerprintingsecurity architectureLogin SecurityBrute Force ProtectionAdaptive MFAImpossible Travel DetectionRisk-Based Authentication
Reader feedback

How this landed with the community

Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.