Improving Login UX by Exposing Lockout TTLs

Problem
Our customers were experiencing a UX issue during login. When an account was temporarily locked due to repeated failed attempts, the API returned a vague message about when the user could try again.
Login failed 5 times. Please try again 5 minutes later.
The problem was that this message did not update over time. Users had no way of knowing exactly when they were allowed to retry, which led to repeated failed attempts and frustration.
Solution
Our team came up with a feature to show the exact time in which users can try again. The API server would return remaining seconds from when the user can attempt again and the frontend would calculate the time according to the user’s region
You have failed to login 5 times. Please try again at 3:15 PM.
The goal was not to change authentication behavior, but to expose accurate lockout timing information without modifying the authentication server.

ASIS
The lockout logic (5 failures → 5-minute wait) was handled entirely by the authentication server.
The current authentication process was:
User attempts login
Application server processes business logic
On success application server requests authentication from auth server
Login success
However, if a user inputs an incorrect ID or password, the authentication server raises an error. The server would count up to 5 times and if user fails 5 times it would return an error that the user must wait 5 minutes before the next attempt.
TOBE
Since our team did not control the authentication server, we introduced a cache layer inside the application server.
This cache stores a short-lived lockout key with a TTL and allows us to return the remaining lockout time to the client.
If user fails 5 times put key and time-to-live (ttl) in the cache. Return ttl.
On next attempt if key exists in cache return ttl
If user keeps failing return to step 1.
If we had control over the authentication server, an alternative solution would have been to store and return the lockout expiration timestamp directly from its database.
Why This Isn’t Rate Limiting
At first glance, this problem looked like a classic rate-limiting use case. After all, we were restricting repeated login attempts within a short period of time. However, login lockout and rate limiting solve fundamentally different problems.
Rate limiting is a traffic-control mechanism. It protects services from abuse by limiting how frequently a client can make requests, usually based on IP address or request rate.
Login lockout, on the other hand, is a business rule. It is account-based, stateful, and directly tied to authentication outcomes. The lockout is triggered by failed login attempts—not by request volume. Additionally, our requirement was not to count attempts locally, but to persist a cooldown period based on an error returned by the authentication server.
For these reasons, we chose a simpler and more appropriate solution that explicitly models login lockout as application-level logic rather than infrastructure-level rate limiting.
Work
Helper Functions
LOCKOUT_PREFIX = "login:locked:"
LOCKOUT_DURATION_SECONDS = 300
def _get_lock_key(email: str) -> str:
return f"{LOCKOUT_PREFIX}{email}"
# Value is irrelevant; Redis is used as a time-bound lock flag
async def set_login_lockout(redis, email: str) -> None:
await redis.setex(_get_lock_key(email), LOCKOUT_DURATION_SECONDS, 1)
async def clear_login_lockout(redis, email: str) -> None:
await redis.delete(_get_lock_key(email))
async def is_login_locked(redis, email: str) -> bool:
return await redis.exists(_get_lock_key(email)) == 1
# Redis TTL semantics:
# > 0 : seconds remaining
# -1 : key exists but has no expiration
# -2 : key does not exist
# We normalize non-positive values to 0 to keep the API contract simple.
async def get_lockout_ttl(redis, email: str) -> int:
ttl = await redis.ttl(_get_lock_key(email))
return ttl if ttl > 0 else 0
Login Function
def login(...):
# Step 1: pre-check lockout
if is_login_locked(...):
ttl = get_lockout_ttl(...)
raise LockoutException(ttl)
try:
# Step 2: authenticate against auth server
auth_server.login(...)
clear_login_lockout(...)
except AuthServerException as e:
if e == FailedFiveTimesException:
set_login_lockout(...)
ttl = get_lockout_ttl(...)
raise LockoutException(ttl)
raise
return LoginSuccess(...)
Summary
This change started as a small UX improvement, but it ended up shaping how we think about authentication behavior across services.
By introducing a short-lived lockout key in Redis, we were able to expose accurate retry timing without modifying the authentication server or adding unnecessary complexity. The solution remained stateless, scalable, and easy to reason about, while significantly improving the user experience.
This approach allowed us to keep authentication behavior explicit, predictable, and user-friendly—without introducing unnecessary infrastructure complexity.



