Instant Account Freeze in Java: One Version Bump Revokes All Tokens & Sessions
This article details a Java implementation for one-click account freezing that uses a security version counter to instantly invalidate all access tokens, refresh tokens, sessions, and trusted devices across multiple clients without maintaining token blacklists, covering distributed caching, audit logging, and recovery flows.
Don't Try to Find Every User JWT
The initial idea was a token blacklist: on freeze, write all access token JTIs to Redis and check on each request. But the server doesn't know how many valid access tokens exist — a user may have rotated tokens across Mac (A1→A2→A3), Windows (A4→A5), and iPhone (A6). Maintaining a list of all unexpired tokens contradicts the stateless JWT goal.
One securityVersion Splits All Old Login State into "Previous Generation"
Add a security_version BIGINT NOT NULL DEFAULT 1 column to sys_user. When creating a LoginSession, store the version:
public record LoginSession(
String sessionId,
Long userId,
long securityVersion,
SessionStatus status,
Instant createdAt
) {}Embed the same version in the access token as claim sv:
{
"sub": "10086",
"sid": "S10001",
"sv": 12,
"exp": 1793952000
}Refresh sessions and trusted devices also store version 12. All credentials belong to "generation 12". On freeze, execute a single atomic update:
UPDATE sys_user
SET security_version = security_version + 1,
security_status = 'USER_FROZEN',
frozen_at = NOW()
WHERE id = ?
AND security_status = 'ACTIVE';Version becomes 13; every credential with sv=12 is now obsolete.
Why a Still-Valid JWT Is Rejected
Verification now checks the current security state:
public Authentication verify(String jwt) {
JwtClaims claims = jwtService.parse(jwt);
Long userId = claims.userId();
long tokenVersion = claims.securityVersion();
UserSecurityState state = securityStateService.get(userId);
if (state.status() != SecurityStatus.ACTIVE) {
throw new AccountUnavailableException();
}
if (state.securityVersion() != tokenVersion) {
throw new TokenRevokedException();
}
return createAuthentication(claims);
}Even with a valid signature and unexpired exp, a token with sv=12 fails because the server's version is 13. Token validity depends on external state, not just the token itself.
Caching Security State in Redis
To avoid a database hit per request, store security state in Redis:
auth:user:10086:security → {"version": 13, "status": "USER_FROZEN"}Or compressed: 13:USER_FROZEN. Authentication flow: JWT verify → check exp → read security state → compare version → check status. The key requirement: all service instances must see the same state promptly.
Never Rely on JVM-Local Cache Alone
With three instances (app-1, app-2, app-3), a freeze on app-1 would leave app-2/3 serving stale state if only a ConcurrentHashMap is used. Use Redis, database, or distributed cache with message propagation. If "invalidate within one second" is required, propagation latency becomes a security metric.
Refresh Tokens Must Also Expire or the Account "Revives"
After freeze, old access tokens return 401. The client's auto-refresh would call POST /api/auth/refresh. If the refresh token is still valid, a new access token is issued, effectively unfreezing the account. Therefore refresh sessions also store securityVersion and are checked on refresh:
public TokenResponse refresh(String refreshToken) {
RefreshSession session = refreshSessionRepository.get(refreshToken);
UserSecurityState state = securityStateService.get(session.userId());
if (state.status() != SecurityStatus.ACTIVE) {
throw new AccountUnavailableException();
}
if (session.securityVersion() != state.securityVersion()) {
throw new RefreshTokenRevokedException();
}
return rotate(session);
}Trusted Devices Must Expire Too
Trusted device records (e.g., "trust this device for 30 days") store securityVersion. On verification:
if (trustedDevice.securityVersion() != userSecurityState.securityVersion()) {
return TrustedDeviceResult.INVALID;
}After freeze (version 13), all version-12 trusted devices become invalid. User must re-authenticate with TOTP/Passkey and re-establish trust after recovery.
LoginSession Status: Async Update for UI/Audit
From an auth perspective, version mismatch already blocks the session. But for device management UI and audit, asynchronously mark old sessions REVOKED via an event:
applicationEventPublisher.publishEvent(new AccountFrozenEvent(userId));
// async consumer:
sessionRepository.revokeAll(userId);Core freeze logic remains synchronous and atomic; async tasks handle display, notifications, and audit.
The Real Security Boundary Should Be One SQL Statement
Avoid multi-step updates (user status, then sessions, then tokens) that can leave a "half-frozen" state. The single conditional UPDATE above is the atomic boundary. It also handles duplicate clicks: first call affects 1 row, second affects 0 because status is no longer ACTIVE.
Distinguish "Account Frozen" from "Admin Disabled"
Replace a single enabled flag with an explicit enum:
public enum SecurityStatus {
ACTIVE,
USER_FROZEN,
ADMIN_DISABLED,
RISK_LOCKED
}Each state has different recovery permissions: user can self-recover from USER_FROZEN after strong verification; ADMIN_DISABLED requires admin action; RISK_LOCKED may auto-recover after time or step-up auth.
Freeze Entry Must Exist Outside Logged-In Session
If an attacker changed the password, the victim cannot log in to reach the freeze button. Provide an account recovery entry (verified phone, recovery email, Passkey, support) that grants a limited "recovery challenge" allowing only freeze account or start recovery.
Don't Delete Passkeys and TOTP on Freeze
Passkeys and TOTP are authentication factors needed for recovery. Deleting them on freeze could lock the legitimate owner out permanently. Keep them; let the recovery flow decide which credentials to rotate.
Recovery Codes Can Be Invalidated More Aggressively
Recovery codes are long-lived backup credentials. If freeze is due to suspected credential leak, invalidate old recovery codes (via a recoveryCodeVersion or marking unused codes revoked) and issue new ones after recovery.
Client Must Distinguish 401 Types and Stop Retrying
Return distinct error codes: TOKEN_EXPIRED, SESSION_REVOKED, ACCOUNT_FROZEN, ACCOUNT_DISABLED. On ACCOUNT_FROZEN, the client must stop auto-refresh, clear local tokens, and redirect to recovery. Token expiry means "refresh"; account freeze means "trust relationship terminated".
Unfreeze Is More Dangerous Than Freeze
Freeze is one version increment. Unfreeze must require stronger verification than normal login (Passkey, TOTP+recovery email, or full account recovery). On unfreeze, set status to ACTIVE but do not decrement securityVersion . Version stays at 13; new sessions/tokens are generation 13. Old generation-12 credentials never become valid again.
Two-Level Versioning: Session vs. Security Version
Use session status for single-device logout ( ACTIVE → REVOKED). Use security version for global invalidation (logout all devices, user freeze, admin disable, risk lock). This cleanly separates "revoke one login" from "revoke all historical logins".
Password Change Reuses the Same Mechanism
In the same transaction as updating password_hash, increment security_version. All old tokens, sessions, and trusted devices become previous generation. Admin password reset, risk-based forced logout, and user "logout all devices" all share this single mechanism.
Immediate Revocation Requires Server-Side State
If the product demands "all old access tokens invalid within 1 second", the server must know the current security state on every request. Pure stateless JWT (signature + exp = allow) cannot achieve immediate revocation; you must accept a stateful check (Redis, DB, or local cache with invalidation). If 5-minute token expiry and 5-minute revocation delay are acceptable, stateless works.
Local Cache with Explicit Invalidation
A short-lived local cache (Caffeine, 5-10s TTL) reduces Redis load. But "immediate" then means "wait up to TTL". For faster propagation, broadcast a UserSecurityStateChangedEvent via Redis Pub/Sub or message queue to evict the specific user's cache entry on all instances.
Security Audit Logging
Log rich context for each security state change:
userId = 10086
action = ACCOUNT_FROZEN
source = USER
reason = SUSPECTED_ACCOUNT_COMPROMISE
securityVersion = 12 → 13
ip = xxx
device = Chrome on macOS
time = 2026-11-06 08:32Generic "user frozen" logs are useless for later investigation.
The Complete Freeze Flow Is Surprisingly Simple
Core synchronous path: DB updates securityStatus: ACTIVE → USER_FROZEN and securityVersion: 12 → 13. Instantly, all old access tokens (version mismatch), refresh tokens (version mismatch), trusted devices (version mismatch), and login sessions (version mismatch) are rejected. Async tasks update session statuses, write audit logs, send notifications, refresh device lists. Recovery: user proves identity via Passkey/TOTP/recovery flow → status back to ACTIVE, version stays 13 → new login creates generation-13 credentials. Generation-12 credentials are permanently dead.
The question "how to delete all user JWTs after compromise?" is wrong. Stateless tokens can't be recalled. Better question: "Is there a current account security state that all past credentials must depend on?" If yes, freeze just means "this account entered the next security generation; all previous-generation credentials are now untrusted."
A single BIGINT security_version unifies logout-all, password change, admin disable, user freeze, risk lock, and trusted-device revocation. It consolidates scattered "delete token" logic into one clear business rule: old version, no longer trusted.
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.
LuTiao Programming
LuTiao Programming is a friendly community offering free programming lessons. We inspire learners to explore new ideas and technologies and quickly acquire job-ready skills.
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.
