Secure Authentication Best Practices: A Tested Checklist - NextGenBeing
Back to discoveries

Secure Authentication Best Practices: A Tested Checklist

Secure authentication checklist for 2026 per NIST SP 800-63B Rev 4 and OWASP, with tested code: argon2id timings, session cookies, CSRF, TOTP replay protection, login throttling.

Operating Systems Premium Content 15 min read
Bekzod Erkinov

Bekzod Erkinov

May 2, 2026 • 100 views
Secure Authentication Best Practices: A Tested Checklist
Photo by Andrey Matveev on Unsplash
Size:
Height:
15 min read 6,633 words Focus mode: Eye care:

Listen to Article

Loading...
0:00 / 0:00
0:00 0:00
Low High
0% 100%
⏸ Paused ▶️ Now playing... Ready to play ✓ Finished

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

Join 10,000+ developers who love our premium content

Bekzod Erkinov

Bekzod Erkinov

Author

Founder of NextGenBeing. Software engineer working with Laravel, Python, and cloud infrastructure. Writes about patterns that actually hold up in production. Based in Tashkent, Uzbekistan.

Free guide

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

Related Articles

Don't miss the next deep dive

Get one well-researched tutorial in your inbox each week. No spam, unsubscribe anytime.