Series: Building Backarch — Engineering decisions from building backarch.com
Your login endpoint probably has two failure cases: the email doesn't exist, and the email exists but the password is wrong. If these two cases take measurably different amounts of time to respond, an attacker can tell them apart from the outside — without ever seeing an error message, without touching a log file, without any access to your database.
This is a timing attack. It's not exotic. It's not theoretical. It's a direct consequence of how password hashing works, and it's present by default in most naive implementations.
Why the two failure cases take different amounts of time
A typical login flow:
- Look up the user by email
- If not found → return 401
- If found → verify the password → return 401 or 200
Step 3 is where bcrypt runs. Bcrypt is intentionally slow — at cost factor 12, it takes roughly 250ms on a modern server. That's the whole point: it makes offline dictionary attacks expensive.
But it also means that "email not found" returns in ~1ms (a fast database query, no hash computation), while "wrong password" returns in ~251ms (same database query plus 250ms of bcrypt). An attacker measuring response times can enumerate valid email addresses by looking for the fast responses.

This matters more than it sounds. A list of confirmed valid emails is a precondition for targeted phishing, credential stuffing, and account takeover at scale. The timing difference turns your login endpoint into a public email oracle.
The fix: always run the hash, even when there's no user
The standard solution is to perform a hash computation on every login attempt, regardless of whether the user was found:
# app/core/security.py
_DUMMY_HASH = bcrypt.hashpw(b"dummy", bcrypt.gensalt(12)).decode()
def verify_password(plain: str, hashed: str | None) -> bool:
if hashed is None:
# No user found — run bcrypt anyway to equalise timing
bcrypt.checkpw(plain.encode(), _DUMMY_HASH.encode())
return False
return bcrypt.checkpw(plain.encode(), hashed.encode())_DUMMY_HASH is computed once at module import time, not on every request. When hashed is None (no user found), we still call bcrypt.checkpw against the dummy hash — consuming the same ~250ms — then return False. The total time for "not found" and "wrong password" is now approximately equal.

The login service uses it like this:
async def login(db: AsyncSession, email: str, password: str) -> User:
user = await user_repo.get_by_email(db, email)
# verify_password handles the None case internally
if not user or not verify_password(password, user.password_hash):
raise UnauthorizedException("Invalid credentials")
return userBoth failure cases raise the same exception with the same message. The HTTP response is a 401 with a body that says "Invalid credentials" — not "User not found" or "Wrong password." The timing is approximately equal. There is no oracle.
Why "approximately equal" is good enough
You might notice the word "approximately." Network jitter, database query variance, and garbage collection will introduce some noise into response times even with the dummy hash approach. That noise is your friend — it makes statistical timing analysis much harder.
A precise timing attack requires many measurements and statistical analysis to distinguish a true signal from noise. The dummy hash approach raises the floor for both failure cases to ~250ms and buries the true signal in that noise. It doesn't eliminate timing channels entirely (true constant-time string comparison is a deeper topic), but it eliminates the bcrypt timing difference that makes email enumeration straightforward.
For a web application behind a network, this level of protection is sufficient. The remaining timing channels are too small relative to network jitter to exploit reliably over a standard internet connection.
The passlib trap worth knowing about
Most Python examples use passlib.context.CryptContext to wrap bcrypt. Passlib is a widely-used library — but it broke unexpectedly when bcrypt dropped the __about__ attribute in version 4.0.0. Any project using passlib with bcrypt >= 4.0.0 started raising AttributeError: module 'bcrypt' has no attribute '__about__' at import time.
We ran into this in Backarch while upgrading dependencies — the AttributeError fails at import, which means auth is entirely broken before a single request is served, with no helpful indication that the bcrypt version is the culprit.
Using bcrypt directly sidesteps this:
import bcrypt
_BCRYPT_ROUNDS = 12
def hash_password(plain: str) -> str:
return bcrypt.hashpw(plain.encode(), bcrypt.gensalt(_BCRYPT_ROUNDS)).decode()One fewer dependency, no version-conflict risk. If you're currently using passlib, check your bcrypt version — and consider whether the wrapper is actually buying you anything the direct library doesn't already provide.
Validating input before hashing
Bcrypt has a maximum input length of 72 bytes. Passwords longer than 72 bytes are silently truncated — two passwords that share the first 72 characters are treated as identical by bcrypt. This is a bcrypt property, not a bug, but it surprises people.
A field validator on the signup schema handles this before the password ever reaches the hash function:
@field_validator("password")
@classmethod
def password_strength(cls, v: str) -> str:
if len(v) < 8:
raise ValueError("Password must be at least 8 characters")
if len(v) > 72:
raise ValueError("Password must be at most 72 characters")
if not re.search(r"[a-zA-Z]", v):
raise ValueError("Password must contain at least one letter")
if not re.search(r"[\d\W]", v):
raise ValueError("Password must contain a digit or special character")
return vA malformed password returns a 422 before bcrypt runs — no 250ms wait, no wasted compute, and no silent truncation surprises later.
The dummy-hash pattern is one of those things that isn't in most auth tutorials because tutorials focus on "it works," not "it's secure." We added it to Backarch's login endpoint from the start because user email enumeration is a real attack vector, and the fix is three lines.
Timing attacks are quiet. They don't show up in error logs. They don't trigger rate limiting if the attacker is patient. The defence is cheap and the risk of not defending is a list of confirmed user emails in someone else's hands.
A timing attack doesn't announce itself. It just quietly costs you nothing to prevent and everything to ignore.
Next: RDS SSL and the connection string parameter that changed names between drivers — a bug that passes every test and only appears on your first production deploy.
