Spring Boot Trusted Device Login: Why Device ID Isn't Enough for 30-Day Remember Me
This article details a secure implementation of 'remember this device for 30 days' in Spring Boot, explaining why device IDs and fingerprints are insufficient, and demonstrating server-signed trusted device tokens with hashed storage, rotation, absolute expiration, MFA versioning, and revocation mechanisms to properly bypass 2FA without compromising security.
01 The Credential Must Be a Revocable, Expirable, Server-Verifiable Long-Lived Token
The author initially considered two naive approaches: a frontend trusted=true flag (trivial to bypass via localStorage), and browser fingerprinting (mutable and forgeable). The conclusion: a trusted device must be a server-issued credential that can be revoked, expires, and is verifiable solely by the server — part of the authentication system, not a frontend toggle.
02 Why deviceId Alone Almost Shipped
An existing deviceId (random UUID stored in localStorage) was used for multi-device login. The quick design: after password + TOTP, set trusted=true on the record; next login with same userId + deviceId skips TOTP. The flaw: the value originates from the browser; an attacker who copies d8f3a... can impersonate the trusted device. Rule established: deviceId only identifies devices for display, never acts as a security credential.
03 Server Signs the Key, Stores Only Its Hash
On first full authentication with "trust this device" checked, the server generates a Trusted Device Token : 32 bytes from SecureRandom, Base64 URL encoded without padding, formatted as TD_<deviceKey>.<secret> (e.g., TD_8e2f1c.k3Pl8sV...). The secret is never stored; only its SHA-256 hash is persisted. Database schema trusted_device includes trusted_device_id, user_id, device_id, device_name, secret_hash, status (ACTIVE/REVOKED/EXPIRED), created_at, last_used_at, expires_at, revoked_at, and mfa_version.
private static final SecureRandom RANDOM = new SecureRandom();
public TrustedDeviceIssue issue(Long userId, String deviceId, String deviceName) {
byte[] bytes = new byte[32];
RANDOM.nextBytes(bytes);
String secret = Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
String trustedDeviceId = "TD_" + HexFormat.of().formatHex(randomBytes(4));
// secret appears only here; DB receives hash
repo.insert(trustedDeviceId, userId, deviceId, deviceName, sha256(secret));
return new TrustedDeviceIssue(trustedDeviceId + "." + secret);
}04 Why Not a 30-Day JWT?
A signed JWT with trusted:true and 30-day expiry was considered but rejected because it cannot be revoked. If a user revokes trust in the security center, the JWT remains valid until expiry — a "looks effective but isn't" state more dangerous than no feature. The token only locates the record and proves possession of the secret; all state (ACTIVE/REVOKED/EXPIRED) lives server-side, enabling true revocation, admin cleanup, password-change invalidation, and risk-based invalidation.
05 Where the Key Lives Matters More Than How It's Generated
Web: stored in a cookie with HttpOnly; Secure; SameSite=Lax; Max-Age=2592000; Path=/. HttpOnly prevents script access; Secure enforces HTTPS; SameSite mitigates CSRF; Max-Age matches 30 days. Mobile apps use Keychain/Keystore. The principle: long-lived credentials must be treated as authentication secrets, not UI configuration.
06 Next Morning: Server Asks Four Questions
On subsequent login, after password succeeds, the server checks for the cookie. It splits TD_8e2f1c.k3Pl8sV... at the dot, queries by device key, then verifies four conditions sequentially:
Record exists.
Record belongs to the current userId.
Status is ACTIVE. expires_at is in the future.
SHA-256 of presented secret matches stored secret_hash (using MessageDigest.isEqual for constant-time comparison).
All four must pass; otherwise fall back to full password + TOTP flow.
public Optional<TrustedDevice> verify(Long userId, String token) {
int dot = token.indexOf('.');
if (dot <= 0) return Optional.empty();
String deviceKey = token.substring(0, dot);
String secret = token.substring(dot + 1);
TrustedDevice d = repo.findByKey(deviceKey).orElse(null);
if (d == null) return Optional.empty();
if (!d.getUserId().equals(userId)) return Optional.empty();
if (!"ACTIVE".equals(d.getStatus())) return Optional.empty();
if (d.getExpiresAt().isBefore(Instant.now())) return Optional.empty();
if (!MessageDigest.isEqual(sha256(secret).getBytes(UTF_8), d.getSecretHash().getBytes(UTF_8))) return Optional.empty();
return Optional.of(d);
}07 Trusted Device Only Skips Daily Login, Not Step-Up Auth
Product alignment: 30-day trust bypasses routine login TOTP, but sensitive actions (password change, disable TOTP, change phone, delete passkeys, view recovery codes, modify payment info) still require step-up authentication (TOTP or passkey). Code distinguishes daily login authentication from step-up authentication ; conflating them causes security incidents.
08 Device Fingerprint Is Only a Yellow Signal
Collecting UA, OS, language, resolution, timezone, etc., to build a fingerprint is tempting but flawed: browser upgrades change UA; monitor swaps change resolution; privacy windows shift multiple signals; all values are forgeable. Fingerprint answers "does this login look anomalous compared to usual?" not "is this the exact same machine?" Trust is proven only by the token; device characteristics are background context.
09 IP Change ≠ Different Person
IPs change due to mobile tower handoff, corporate egress, broadband redial, VPN, proxies. Treating IP change as immediate distrust breaks usability. Instead, assess risk: token valid + familiar environment + usual region = LOW; token valid but region jump (Tokyo to Europe) + UA change + short interval = HIGH; token valid + region change only = MEDIUM. Environment changes are signals, not verdicts.
public Risk assess(TrustedDevice d, String ip, String userAgent, String region) {
boolean ipChanged = !Objects.equals(d.getLastIp(), ip);
boolean uaChanged = !Objects.equals(d.getLastUserAgent(), userAgent);
boolean regionChanged = d.getLastRegion() != null && !d.getLastRegion().equals(region);
if (regionChanged && uaChanged) return Risk.HIGH;
if (regionChanged) return Risk.MEDIUM;
return ipChanged ? Risk.MEDIUM : Risk.LOW;
}10 Rotate the Token on Every Use; Replay Detection Built In
To limit exposure if a token is stolen, rotate on each successful verification (like refresh tokens). Current token T1 is invalidated, new secret T2 generated, cookie updated, DB hash replaced atomically. If T1 reappears later, the DB hash no longer matches — detected as replay. Response: revoke the device and force full TOTP. Low-risk backends may skip; high-security systems should implement.
@Transactional
public String rotate(TrustedDevice d, String oldSecret) {
String next = randomSecret(32);
int updated = repo.rotateSecret(d.getId(), sha256(oldSecret), sha256(next));
if (updated == 0) {
// DB hash already changed: normal rotation or replay attack
repo.revoke(d.getUserId(), d.getTrustedDeviceId());
throw new TrustedDeviceReplayException();
}
return d.getTrustedDeviceId() + "." + next;
}11 "30 Days" Must Not Become Forever via Sliding Expiration
Extending expires_at on every use turns 30 days into lifetime. Solution: two timestamps — absolute expires_at fixed at issuance + 30 days (immutable), and last_used_at for activity tracking. Optional idle window (e.g., 15 days of inactivity revokes early), but absolute ceiling remains.
12 Password Change Revokes All Trusted Devices
On password change (including forgot-password reset and admin reset), all LoginSession, RefreshSession, and TrustedDevice records are revoked. Passkey and TOTP secret are untouched — they are independent factors with separate lifecycles.
13 Disable/Re-enable TOTP Invalidates Old Devices via Version Bump
Scenario: user trusts three devices, disables TOTP, later re-enables. Old records must not persist. Add mfa_version to sys_user (default 1) and trusted_device. On TOTP disable, re-bind, security recovery, or admin MFA reset:
UPDATE sys_user SET mfa_version = mfa_version + 1 WHERE id = ?. Trusted device verification compares stored version with current user version; mismatch = auto-revocation.
if (d.getMfaVersion() != user.getMfaVersion()) {
return Optional.empty(); // version changed, all prior trusts invalid
}14 User-Visible Device List with Ownership-Checked Revocation
Security center lists device name, issued at, last used, expires at, and current flag. Revoke endpoint DELETE /api/security/trusted-devices/{id} requires both id and user_id in SQL WHERE clause to prevent cross-user revocation. Status flips from ACTIVE to REVOKED.
15 "Revoke Trust" ≠ "Logout"
Revoke trust affects future logins (next login requires TOTP); current session continues. Logout kills current LoginSession immediately. UI should separate them; a combined "logout and revoke" action can be offered additionally.
16 Admin One-Click Revocation via Version Bump
Admin endpoint POST /admin/users/{id}/revoke-trusted-devices increments mfa_version (invalidating all trusted devices), revokes all login sessions and refresh tokens. No per-device iteration needed.
@PostMapping("/admin/users/{id}/revoke-trusted-devices")
public void revokeAll(@PathVariable Long id) {
userMapper.bumpMfaVersion(id); // mfa_version = mfa_version + 1
sessionService.revokeAll(id);
refreshTokenService.revokeAll(id);
}17 Granular Internal States, Minimal External Exposure
Frontend needs clear states, not security details. Password correct but second factor needed → SECOND_FACTOR_REQUIRED with challenge token and available methods (TOTP, RECOVERY_CODE). Trusted device verified → AUTHENTICATED with access token. Internal distinctions (expired, revoked, version mismatch) map to a single user message: "For security, please verify your identity again." Over-explaining aids attackers.
public record LoginResult(String status, String challengeToken, List<String> availableMethods) {
public static LoginResult secondFactor(String token) {
return new LoginResult("SECOND_FACTOR_REQUIRED", token, List.of("TOTP", "RECOVERY_CODE"));
}
public static LoginResult authenticated(String accessToken) {
return new LoginResult("AUTHENTICATED", accessToken, List.of());
}
}Final Architecture Summary
Entry factors: SMS, password, QR scan, passkey — establish identity. LoginSession binds login to a device.
Access token + refresh token handle API access and silent renewal.
TOTP provides second factor for high-privilege accounts.
Trusted device eliminates daily TOTP repetition for known machines.
Flow: first factor → MFA enabled? → trusted device present? → if needed, challenge TOTP → create LoginSession → issue access + refresh tokens. Each layer handles one concern.
The author's mindset shifted from "add a checkbox on the login page" to "how does the server cryptographically verify the browser still holds the key issued after full verification?" Device ID equality or fingerprint similarity are insufficient; only a device-exclusive long-lived secret plus server-controlled mutable state provides real assurance.
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.
