Bekzod Erkinov
Listen to Article
Loading...Secure Authentication Best Practices: A Tested Checklist
You will be able to audit your login against NIST SP 800-63B Rev 4 and OWASP, and fix each gap with code you can paste: password policy, argon2id settings, sessions, cookie flags, CSRF, TOTP, passkeys, throttling and reset. The failures it targets are quiet ones: a hash library that throws on long passphrases, a logout that clears the cookie but leaves the session alive, a TOTP check that accepts the same code twice.
The scripts ran on Python 3.12.10 (argon2-cffi 25.1.0, bcrypt 5.0.0, PyOTP 2.10.0), Node 24.19.0 and curl 8.21.0, on an Intel Core i7-8550U laptop running Windows 11. The passkey and nginx snippets are the exceptions and are labelled untested; CSRF has no code, only a rule from the cheat sheet.
Password policy: length, blocklist, no composition rules
Pitfall: "one uppercase, one symbol" rules. Rule: length plus a breach check. NIST SP 800-63B Rev 4, section 3.1.1.2 (I checked the text there) says verifiers SHALL require at least 15 characters when the password is the only factor, and at least 8 when it is used only as part of MFA. It says verifiers SHOULD permit a maximum length of at least 64 characters, SHALL NOT impose other composition rules, and SHALL NOT require periodic changes. It also requires a check against a blocklist of commonly used, expected or compromised passwords.
The blocklist is the part with real work. The Pwned Passwords range API is free, needs no API key, and takes only the first 5 hex characters of the SHA-1 hash (k-anonymity), so the password never leaves your server. Add-Padding: true pads each response to a random 800 to 1,000 records so its size doesn't hint at the prefix searched; padded rows have a count of 0 (padding docs).
# policy.py
import hashlib, urllib.error, urllib.request
def pwned_count(pw: str) -> int:
"""How many times pw appears in Pwned Passwords. Only the first 5 hex chars of the SHA-1 leave your server."""
sha1 = hashlib.sha1(pw.encode()).hexdigest().upper()
prefix, suffix = sha1[:5], sha1[5:]
req = urllib.request.Request(
f"https://api.pwnedpasswords.com/range/{prefix}",
headers={"Add-Padding": "true", "User-Agent": "auth-checklist-demo"},
)
with urllib.request.urlopen(req, timeout=3) as r:
body = r.read().decode()
for line in body.splitlines():
s, _, n = line.partition(":")
if s == suffix:
return int(n) # padded rows have count 0
return 0
def check_password(pw: str, has_mfa: bool) -> str | None:
n = len(pw) # Unicode code points, not bytes
if n < (8 if has_mfa else 15):
return "too short"
if n > 256: # NIST: SHOULD permit >= 64; the 256 cap is my own choice
return "too long"
try:
if pwned_count(pw) > 0:
return "found in breach corpus"
except OSError as e:
print(f" [warn] breach check unavailable ({type(e).__name__}); accepting on length alone")
return None
if __name__ == "__main__": # the demo needs network access for the breach lookups
for pw, mfa in [("short", True), ("password1234567", False),
("correct horse battery staple", False),
("x" * 300, False),
("Zq7!vN2#kP9$wR4&mT6", False)]:
print(f"{pw[:28]!r:34} mfa={mfa!s:5} -> {check_password(pw, mfa)}")
# simulated outage: make the API call fail the way a dead network does
def down(_): raise urllib.error.URLError("simulated outage")
pwned_count = down # check_password looks the name up at call time
print("outage:", check_password("Zq7!vN2#kP9$wR4&mT6", False))
'short' mfa=True -> too short
'password1234567' mfa=False -> found in breach corpus
'correct horse battery staple' mfa=False -> found in breach corpus
'xxxxxxxxxxxxxxxxxxxxxxxxxxxx' mfa=False -> too long
'Zq7!vN2#kP9$wR4&mT6' mfa=False -> None
[warn] breach check unavailable (URLError); accepting on length alone
outage: None
Why fail open: the length rule still runs, and a third-party outage shouldn't stop every signup. The last output line is a simulated outage (I swapped pwned_count for a function raising URLError): the password is accepted on length alone. Re-run pwned_count for passwords chosen during an outage at the user's next login.
Password hashing: argon2id settings
Pitfall: picking parameters from a blog post instead of your own hardware. Rule: the OWASP rows are floors, not targets. Measure on your production box, then take the most expensive row that fits your login latency budget. About 250 ms is my own budget, not a standard; if yours is tighter, take a cheaper row, but never go below the OWASP floor.
OWASP's Password Storage cheat sheet gives argon2id as 19 MiB, 2 iterations, 1 lane, plus equivalent trade-offs (46 MiB/t=1, 12 MiB/t=3, 9 MiB/t=4, 7 MiB/t=5). Bcrypt is for legacy systems only, at cost 10 or higher.
The benchmark (memory_cost is in KiB; one warm-up call, then n=31 timed runs for every row, standard deviation printed):
# hash_bench.py (pip install argon2-cffi bcrypt)
import platform, statistics, time
import bcrypt
from argon2 import PasswordHasher, Type
PW = "correct horse battery staple"
def bench(label, fn, n):
fn() # warm-up, not timed
ts = []
for _ in range(n):
t = time.perf_counter(); fn(); ts.append((time.perf_counter() - t) * 1000)
print(f"{label:26s} median {statistics.median(ts):6.1f} sd {statistics.stdev(ts):5.1f} min {min(ts):6.1f} max {max(ts):6.1f} (n={n})")
print(platform.processor(), "| python", platform.python_version())
for m_mib, t, p in [(7, 5, 1), (9, 4, 1), (12, 3, 1), (19, 2, 1), (46, 1, 1), (64, 3, 4), (128, 3, 4)]:
ph = PasswordHasher(time_cost=t, memory_cost=m_mib * 1024, parallelism=p, type=Type.ID)
bench(f"argon2id m={m_mib}MiB t={t} p={p}", lambda: ph.hash(PW), 31)
for c in (10, 11, 12):
bench(f"bcrypt cost={c}", lambda: bcrypt.hashpw(PW.encode(), bcrypt.gensalt(c)), 31)
ph = PasswordHasher(time_cost=2, memory_cost=19456, parallelism=1, type=Type.ID)
h = ph.hash("hunter2")
print(h)
print("verify ok:", ph.verify(h, "hunter2")) # raises VerifyMismatchError on a wrong password
print("needs rehash under m=64MiB,t=3,p=4:",
PasswordHasher(time_cost=3, memory_cost=65536, parallelism=4).check_needs_rehash(h))
try:
bcrypt.hashpw(b"A" * 73, bcrypt.gensalt(4))
except ValueError as e:
print("bcrypt", bcrypt.__version__, "->", e)
Intel64 Family 6 Model 142 Stepping 10, GenuineIntel | python 3.12.10
argon2id m=7MiB t=5 p=1 median 58.1 sd 10.0 min 51.4 max 104.2 (n=31)
argon2id m=9MiB t=4 p=1 median 67.6 sd 11.5 min 58.9 max 108.0 (n=31)
argon2id m=12MiB t=3 p=1 median 66.1 sd 11.7 min 57.1 max 118.4 (n=31)
argon2id m=19MiB t=2 p=1 median 74.0 sd 8.8 min 66.7 max 104.8 (n=31)
argon2id m=46MiB t=1 p=1 median 101.9 sd 12.1 min 89.8 max 139.2 (n=31)
argon2id m=64MiB t=3 p=4 median 160.6 sd 30.8 min 133.5 max 269.6 (n=31)
argon2id m=128MiB t=3 p=4 median 321.0 sd 49.4 min 262.4 max 436.9 (n=31)
bcrypt cost=10 median 124.8 sd 8.5 min 110.0 max 142.1 (n=31)
bcrypt cost=11 median 254.6 sd 30.9 min 231.0 max 371.7 (n=31)
bcrypt cost=12 median 525.4 sd 28.3 min 481.3 max 591.0 (n=31)
$argon2id$v=19$m=19456,t=2,p=1$rrMYWd9Tkz2hew7IQ4Wy4g$/TUiPv3DSPf8Ju4tdq7sKDo0nyvmZlPm3jX+XJKDbsg
verify ok: True
needs rehash under m=64MiB,t=3,p=4: True
bcrypt 5.0.0 -> password cannot be longer than 72 bytes, truncate manually if necessary (e.g. my_password[:72])
Picking a row. Every OWASP row lands between 58 and 102 ms, far under a 250 ms budget, so here you would go above the floor: 64 MiB / t=3 / p=4 (not an OWASP profile) fits at 161 ms median, 270 ms worst case; 128 MiB at 321 ms does not. Then check memory: 2 GB of headroom allows 2048 / 64 = 32 simultaneous logins at that row, about 107 at 19 MiB (arithmetic, not a measurement). If 32 is too few for your peak, drop a row; p=4 also wants four free cores per login.
The security side. An attacker with a stolen hash pays the same memory and time per guess, so every row you skip is cheaper for them. The 64 MiB row takes 161 / 74 = 2.2x the wall-clock time of the 19 MiB row and 64 / 19 = 3.4x the memory per parallel guess (ratios, not an attack benchmark). Rows 7 to 12 MiB are within 10 ms of each other with sd around 10 ms, so this post cannot rank them. Parameters live in the encoded hash (m=19456,t=2,p=1), so check_needs_rehash lets you upgrade users at their next login, as the handler below does.
Bcrypt gotcha: bcrypt only uses the first 72 bytes, and bcrypt 5.0.0 raises ValueError on longer input instead of truncating silently (last line of the output). I reproduced this in hashpw only, not through a web framework; whether it becomes a 500 depends on yours, so test a 100-character passphrase against your login endpoint. Argon2id has no such limit.
Sessions vs JWT: which to use
Pitfall: storing a token in localStorage because the tutorial did. Rule: for a browser app talking to your own backend, use an opaque server-side session in a cookie.
Use a short-lived signed JWT only when another service must verify without calling you (it can't be revoked before expiry).
Unlock Premium Content
You've read 30% of this article
What's in the full article
- Complete step-by-step implementation guide
- Working code examples you can copy-paste
- Advanced techniques and pro tips
- Common mistakes to avoid
- Real-world examples and metrics
Don't have an account? Start your free trial
Join 10,000+ developers who love our premium content
Keep reading
Production-Ready Authentication: A Deep Dive into JWT, OAuth2, and Session Security
21 min · 1,331 views
Artificial IntelligenceAdvanced Security Measures for Protecting Against Sophisticated Threats
18 min · 1,300 views
Web DevelopmentMastering Multi-Tenant SaaS Architecture Patterns for Enterprise Applications
27 min · 966 views
Bekzod Erkinov
AuthorFounder of NextGenBeing. Software engineer working with Laravel, Python, and cloud infrastructure. Writes about patterns that actually hold up in production. Based in Tashkent, Uzbekistan.
Get the AI-Assisted Developer's Field Guide
The workflow, prompts, and tools I use to ship faster with AI — free when you subscribe. Plus new deep-dives in your inbox. No spam, unsubscribe anytime.
Comments (0)
Please log in to leave a comment.
Log In