ЁЯПл The SchoolтА║ЁЯЫбя╕П SecurityтА║ЁЯФС рдзрдбрд╛ 04 тАФ Authentication: рддреБрдореНрд╣реА рдХреЛрдг рдЖрд╣рд╛рдд рд╣реЗ рд╕рд┐рджреНрдз рдХрд░рдгреЗ
ЁЯЦ╝я╕П See the drawing + lab ЁЯПа Course home ЁЯМ┐ Branch on GitHub тЬПя╕П View source
ЁЯЦ╝я╕П рдЖрдХреГрддреА рдЖрдгрд┐ labThe drawing + lab рдкреВрд░реНрдг рдкрд╛рдирд╛рд╡рд░ рдЙрдШрдбрд╛ тЖЧOpen full page тЖЧ

ЁЯФС рдзрдбрд╛ 04 тАФ Authentication: рддреБрдореНрд╣реА рдХреЛрдг рдЖрд╣рд╛рдд рд╣реЗ рд╕рд┐рджреНрдз рдХрд░рдгреЗ

ЁЯУН рддреБрдореНрд╣реА рдЗрдереЗ рдЖрд╣рд╛рдд: 16 рдкреИрдХреА рдзрдбрд╛ 04 ┬╖ рдорд╛рдЧреЗ: lesson-03-browser-server ┬╖ рдкреБрдвреЗ: lesson-05-oauth-oidc


ЁЯУж рдпрд╛ рдмреНрд░рдБрдЪрдордзреНрдпреЗ рдХрд╛рдп рдЖрд╣реЗ

рдзрдбреЗ 01тАУ03, рдЖрдгрд┐ рдУрд│рдЦрдкрддреНрд░ рдЦрд┐рдбрдХреАрдЪрд╛ рдкрд╣рд┐рд▓рд╛ рдзрдбрд╛. рд╢рд╛рд│реЗрдиреЗ рдЕрд╕реЗ рдХрд╛рд╣реАрддрд░реА рд╕рд╛рдард╡рд▓реЗ рдкрд╛рд╣рд┐рдЬреЗ рдЬреНрдпрд╛рдиреЗ рддреА password рддрдкрд╛рд╕реВ рд╢рдХреЗрд▓ password рди рд╕рд╛рдард╡рддрд╛. рддреБрдореНрд╣реА password рд▓рд╛ salt рдЖрдгрд┐ рд╣рд│реВ key-derivation function (PBKDF2) рдиреЗ hash рдХрд░рддрд╛, рдмрд░реЛрдмрд░ рдЖрдгрд┐ рдЪреБрдХреАрдЪреЗ login рддрдкрд╛рд╕рддрд╛, рдЖрдгрд┐ рдЪреЛрд░рд▓реЗрд▓рд╛ hash рдХрд┐рддреА рд╡реЗрдЧрд╛рдиреЗ рдУрд│рдЦрддрд╛ рдпреЗрддреЛ рддреНрдпрд╛рдЪреА рддреБрд▓рдирд╛ рдХрд░рддрд╛. рдордЧ MFA, passkeys, рдЖрдгрд┐ sessions рд╡рд┐рд░реБрджреНрдз tokens. sec/demo.py рдордзрд▓реЗ passwords() рд╣реЗ рджрд╛рдЦрд╡рддреЗ.

ЁЯзТ 5 рд╡рд░реНрд╖рд╛рдВрдЪреНрдпрд╛ рдореБрд▓рд╛рд▓рд╛ рд╕рдордЬрд╛рд╡рд▓реНрдпрд╛рд╕рд╛рд░рдЦреЗ

рдУрд│рдЦрдкрддреНрд░ рдЦрд┐рдбрдХреАрд▓рд╛ рджрд░рд░реЛрдЬ рд╕рдХрд╛рд│реА рдХрддрд░рд┐рдирд╛рдЪрд╛ рдЧреБрдкреНрдд рд╢рдмреНрдж рддрдкрд╛рд╕рд╛рд╡рд╛ рд▓рд╛рдЧрддреЛ. рдкрдг рддрд┐рдиреЗ рддреЛ рд╢рдмреНрдж рдХрдзреАрд╣реА рд▓рд┐рд╣реВрди рдареЗрд╡рддрд╛ рдХрд╛рдорд╛ рдирдпреЗ тАФ рдЪреЛрд░ рд╡рд╣реА рдЪреЛрд░реВ рд╢рдХрддреЛ.

рдореНрд╣рдгреВрди рдЦрд┐рдбрдХреА рдПрдХ рд╣реБрд╢рд╛рд░ рдЧреЛрд╖реНрдЯ рдХрд░рддреЗ. рддреА рдХрддрд░рд┐рдирд╛рдЪрд╛ рд╢рдмреНрдж рдШреЗрддреЗ, рдлрдХреНрдд рддрд┐рдЪреНрдпрд╛рд╕рд╛рдареАрдЪреЗ рдПрдХ random sticker рдЬреЛрдбрддреЗ (salt), рдЖрдгрд┐ рджреЛрдиреНрд╣реА рдПрдХрд╛ рдЦреВрдк рд╣рд│реВ рджрд│рдгрд╛рд▒реНрдпрд╛ рдпрдВрддреНрд░рд╛рдд рдШрд╛рд▓рддреЗ. рдпрдВрддреНрд░ handle 200,000 рд╡реЗрд│рд╛ рдлрд┐рд░рд╡рддреЗ рдЖрдгрд┐ рдПрдХ рдбрд╛рдЧ рджреЗрддреЗ. рдЦрд┐рдбрдХреА sticker рдЖрдгрд┐ рдбрд╛рдЧ рд▓рд┐рд╣реВрди рдареЗрд╡рддреЗ.

рдЙрджреНрдпрд╛ рдХрддрд░рд┐рдирд╛ рдкреБрдиреНрд╣рд╛ рддрд┐рдЪрд╛ рд╢рдмреНрдж рд╕рд╛рдВрдЧрддреЗ. рдЦрд┐рдбрдХреА рддрд┐рдЪреЗ sticker рдЬреЛрдбрддреЗ, рдкреБрдиреНрд╣рд╛ рджрд│рддреЗ, рдЖрдгрд┐ рдбрд╛рдЧрд╛рдВрдЪреА рддреБрд▓рдирд╛ рдХрд░рддреЗ. рддреЛрдЪ рдбрд╛рдЧ тЖТ рддреЛрдЪ рд╢рдмреНрдж.

рд╡рд╣реА рдЪреЛрд░рдгрд╛рд▒реНрдпрд╛ рдЪреЛрд░рд╛рд▓рд╛ рдлрдХреНрдд stickers рдЖрдгрд┐ рдбрд╛рдЧ рджрд┐рд╕рддрд╛рдд. рд╢рдмреНрдж рд╢реЛрдзрдгреНрдпрд╛рд╕рд╛рдареА рддреНрдпрд╛рд▓рд╛ рдЕрдВрджрд╛рдЬ рдХрд░рд╛рд╡рд╛ рд▓рд╛рдЧрддреЛ, 200,000 рд╡реЗрд│рд╛ рджрд│рд╛рд╡реЗ рд▓рд╛рдЧрддреЗ, рддреБрд▓рдирд╛ рдХрд░рд╛рд╡реА рд▓рд╛рдЧрддреЗ тАФ рдкреНрд░рддреНрдпреЗрдХ рдЕрдВрджрд╛рдЬрд╛рд╕рд╛рдареА, рдкреНрд░рддреНрдпреЗрдХ рд╡рд┐рджреНрдпрд╛рд░реНрдереНрдпрд╛рд╕рд╛рдареА, рдХрд╛рд░рдг рдкреНрд░рддреНрдпреЗрдХрд╛рдЪреЗ sticker рд╡реЗрдЧрд│реЗ рдЕрд╕рддреЗ. рд╣реЗ рдореБрджреНрджрд╛рдордЪ рд╣рд│реВ рдЖрд╣реЗ.

рдЖрдгрд┐ рдорд╣рддреНрддреНрд╡рд╛рдЪреНрдпрд╛ рджрд╛рд░рд╛рдВрд╕рд╛рдареА рдЦрд┐рдбрдХреА рджреБрд╕рд░реА рдЧреЛрд╖реНрдЯрд╣реА рдорд╛рдЧрддреЗ тАФ рдХрддрд░рд┐рдирд╛рдЪреНрдпрд╛ phone рд╡рд░рдЪрд╛ code, рдХрд┐рдВрд╡рд╛ рддрд┐рдЪреА passkey. рдлрдХреНрдд рдЪреЛрд░рд▓реЗрд▓рд╛ рд╢рдмреНрдж рдкреБрд░реЗрд╕рд╛ рдирд╕рддреЛ.

ЁЯЧ║я╕П рдЖрдХреГрддреА

flowchart LR
    pw["ЁЯФд password"] --> kdf["ЁЯРв slow KDF<br/>PBKDF2-SHA256 ├Ч 200,000<br/>(Argon2id in production)"]
    salt["ЁЯО▓ random salt<br/>16 bytes, per user"] --> kdf
    kdf --> store["ЁЯУТ stored: salt + iterations + hash<br/>never the password"]
    login["ЁЯФд login attempt"] --> kdf2["ЁЯРв same KDF, same salt"]
    store --> kdf2
    kdf2 --> cmp{"constant-time<br/>compare"}
    cmp -->|"match"| mfa["ЁЯУ▒ second factor<br/>TOTP ┬╖ passkey"]
    mfa --> sess["ЁЯНк new session id"]

ЁЯЧ║я╕П рдХрд╛рдврд▓реЗрд▓реА рдЖрд╡реГрддреНрддреА + рдПрдХ lab: https://school-edh.pages.dev/security/lesson-diagrams.html#l04

тЭУ рдХрд╛рдп

ЁЯдФ рдХрд╛

рдХрд╛рд░рдг password databases leak рд╣реЛрддрд╛рдд тАФ SQL injection (рдзрдбрд╛ 01), рд╣рд░рд╡рд▓реЗрд▓рд╛ backup, рдХрд┐рдВрд╡рд╛ рдирд┐рд╖реНрдХрд╛рд│рдЬреА log рдордзреВрди. рддреЗ leak рдЭрд╛рд▓реЗ рдХреА рдЪреЛрд░ рдЖрдгрд┐ рддреБрдордЪреНрдпрд╛ users рдЪреА accounts тАФ рдЖрдгрд┐ рддреНрдпрд╛рдВрдиреА password рдкреБрдиреНрд╣рд╛ рд╡рд╛рдкрд░рд▓реЗрд▓реНрдпрд╛ рдЗрддрд░ рд╕рдЧрд│реНрдпрд╛ sites тАФ рдпрд╛рдВрдЪреНрдпрд╛рдордзреНрдпреЗ рдлрдХреНрдд hash рдХрд┐рддреА рд╣рд│реВ рдЖрд╣реЗ рдПрд╡рдвреЗрдЪ рдЙрд░рддреЗ. GPU рд╡рд░ рдЬрд▓рдж hash рдЪреЗ рд╕реЗрдХрдВрджрд╛рд▓рд╛ рдЕрдмреНрдЬрд╛рд╡рдзреА рдЕрдВрджрд╛рдЬ рд╣реЛрддрд╛рдд. рд╣рд│реВ, salted hash рдЪреЗ рдлрдХреНрдд рд╣рдЬрд╛рд░реЛ. MFA рдЖрдгрд┐ passkeys рдореБрд│реЗ рдУрд│рдЦрд▓реЗрд▓рд╛ password рдПрдХрдЯрд╛ рдкреБрд░реЗрд╕рд╛ рдард░рдд рдирд╛рд╣реА.

ЁЯФз рдХрд╕реЗ (рдпрд╛ repo рдордзреНрдпреЗ)

sec/identity.py рдордзреНрдпреЗ: hash_password(password, salt=None, iterations=200_000) random 16-byte salt рдмрдирд╡рддреЗ рдЖрдгрд┐ (salt, iterations, pbkdf2_hmac("sha256", тАж)) рдкрд░рдд рджреЗрддреЗ. verify_password(password, stored) рд╕рд╛рдард╡рд▓реЗрд▓реНрдпрд╛ salt рдЖрдгрд┐ iterations рдиреЗ рдкреБрдиреНрд╣рд╛ рдЧрдгрди рдХрд░рддреЗ рдЖрдгрд┐ hmac.compare_digest рдиреЗ рддреБрд▓рдирд╛ рдХрд░рддреЗ. guesses_per_second(kind) рдордзреНрдпреЗ рдПрдХрд╛ GPU рд╕рд╛рдареА рдЙрджрд╛рд╣рд░рдгрд╛рджрд╛рдЦрд▓ рдЧреЛрд▓ рдЖрдХрдбреЗ рдЖрд╣реЗрдд, рдлрд░рдХ рдХрд┐рддреА рдореЛрдард╛ рдЖрд╣реЗ рддреЗ рджрд╛рдЦрд╡рдгреНрдпрд╛рд╕рд╛рдареА тАФ рд╣рд╛ benchmark рдирд╛рд╣реА.

ЁЯзк рдХрд░реВрди рдкрд╛рд╣рд╛

python3 sec/demo.py passwords
python3 - <<'EOF'
import sys, time; sys.path.insert(0, "sec"); from identity import hash_password, verify_password
a = hash_password("Summer2026!"); b = hash_password("Summer2026!")
print("same password, two users тЖТ same hash?", a[2] == b[2], "┬╖ salts differ?", a[0] != b[0])
print("right password тЖТ", verify_password("Summer2026!", a), "┬╖ one letter off тЖТ", verify_password("summer2026!", a))
for n in (1_000, 200_000, 600_000):
    t = time.perf_counter(); hash_password("Summer2026!", salt=b"s" * 16, iterations=n)
    print(f"{n:>7,} iterations тЖТ {(time.perf_counter() - t) * 1000:6.1f} ms per guess")
EOF

тЬЕ рддрдкрд╛рд╕рд╛ тАФ рддреБрдореНрд╣рд╛рд▓рд╛ рдХрд╛рдп рджрд┐рд╕рд╛рдпрд▓рд╛ рд╣рд╡реЗ

passwords рд╣реЗ print рдХрд░рддреЗ:

тФАтФА stored: salt + 200,000 PBKDF2 rounds тЖТ 7f2c954f85f5934bde900ac7тАж (never the password itself)
   login with the right password тЖТ True ┬╖ wrong тЖТ False
тФАтФА how fast can a stolen hash be guessed? (one GPU, illustrative)
   md5 (unsalted)             50,000,000,000 guesses/s
   sha256 (salted)            10,000,000,000 guesses/s
   pbkdf2 200k iterations             50,000 guesses/s
   use Argon2id / bcrypt / scrypt / PBKDF2 with a salt ┬╖ add MFA ┬╖ rate-limit logins ┬╖ check breached passwords

рддреБрдордЪрд╛ snippet рд╣реЗ print рдХрд░рддреЛ (milliseconds рддреБрдордЪреНрдпрд╛ machine рд╡рд░ рдЕрд╡рд▓рдВрдмреВрди тАФ рд╣реЗ рдПрдХрд╛ laptop рд╡рд░рдЪреЗ рдЖрд╣реЗрдд):

same password, two users тЖТ same hash? False ┬╖ salts differ? True
right password тЖТ True ┬╖ one letter off тЖТ False
  1,000 iterations тЖТ    0.2 ms per guess
200,000 iterations тЖТ   48.9 ms per guess
600,000 iterations тЖТ  137.0 ms per guess

ЁЯПБ рддреБрдореНрд╣реА рдЖрддреНрддрд╛рдЪ рдХрд╛рдп рд╕рд┐рджреНрдз рдХреЗрд▓реЗ

рдПрдХрд╛рдЪ password рдЪреЗ рджреЛрди рд╡реЗрдЧрд╡реЗрдЧрд│реЗ hashes рдмрдирддрд╛рдд, рдХрд╛рд░рдг рдкреНрд░рддреНрдпреЗрдХ user рдЪреЗ рд╕реНрд╡рддрдГрдЪреЗ salt рдЕрд╕рддреЗ. рдПрдХ рдЕрдХреНрд╖рд░ рдмрджрд▓рд▓реЗ рддрд░реА fail рд╣реЛрддреЗ. рдЖрдгрд┐ рдЦрд░реНрдЪ iterations рд╕реЛрдмрдд рд╡рд╛рдврддреЛ: 600,000 рдЪрд╛ рдЦрд░реНрдЪ 200,000 рдЪреНрдпрд╛ рд╕рд╛рдзрд╛рд░рдг рддрд┐рдкреНрдкрдЯ рдЖрд╣реЗ. User login рд╡реЗрд│реА рдПрдХрджрд╛рдЪ 0.1 seconds рдерд╛рдВрдмрддреЛ. рдЪреЛрд░ рддреА рдХрд┐рдВрдордд рдкреНрд░рддреНрдпреЗрдХ рдЕрдВрджрд╛рдЬрд╛рд╕рд╛рдареА рднрд░рддреЛ тАФ рдЖрдгрд┐ demo рдЪреНрдпрд╛ рдЖрдХрдбреНрдпрд╛рдВрдиреБрд╕рд╛рд░, рд╣рд│реВ KDF рд╡рд░ рд╣рд▓реНрд▓рд╛ рдХрд░рдгреЗ salt рдирд╕рд▓реЗрд▓реНрдпрд╛ MD5 рдкреЗрдХреНрд╖рд╛ рджрд╣рд╛ рд▓рд╛рдЦ рдкрдЯ рд╣рд│реВ рдЖрд╣реЗ.

тЪая╕П рдиреЗрд╣рдореАрдЪреНрдпрд╛ рдЪреБрдХрд╛

ЁЯПн рдкреНрд░рддреНрдпрдХреНрд╖ рд╡рд╛рдкрд░рд╛рдд

рдЦрд▒реНрдпрд╛ account рд╡рд░ тАФ library рд╡рд╛рдкрд░рд╛; KDF рдХрдзреАрд╣реА рд╕реНрд╡рддрдГ рд▓рд┐рд╣реВ рдирдХрд╛. Python рдордзреНрдпреЗ Argon2id (argon2-cffi; рддреНрдпрд╛рдЪреЗ defaults RFC 9106 рдкрд╛рд│рддрд╛рдд):

from argon2 import PasswordHasher
ph = PasswordHasher()
stored = ph.hash(password)                    # "$argon2id$v=19$m=65536,t=3,p=4$тАж" тАФ salt included
ph.verify(stored, attempt)                    # raises VerifyMismatchError when wrong
if ph.check_needs_rehash(stored):             # the cost was raised since this hash was made
    stored = ph.hash(attempt)

Node.js рдордзреНрдпреЗ bcrypt:

import bcrypt from 'bcrypt';
const stored = await bcrypt.hash(password, 12);        // cost 12
const ok = await bcrypt.compare(attempt, stored);

Django тАФ Argon2 рдкрд╣рд┐рд▓рд╛ рдареЗрд╡рд╛; рдЬреБрдиреЗ hashes рдкреБрдврдЪреНрдпрд╛ login рд╡реЗрд│реА upgrade рд╣реЛрддрд╛рдд:

PASSWORD_HASHERS = [
    "django.contrib.auth.hashers.Argon2PasswordHasher",
    "django.contrib.auth.hashers.PBKDF2PasswordHasher",
    "django.contrib.auth.hashers.BCryptSHA256PasswordHasher",
]

Login рд╡реЗрд│реА рдирд╡рд╛ session id рджреНрдпрд╛ (express-session рд╕рд╣ Express):

req.session.regenerate(err => { if (err) return next(err); req.session.userId = user.id; res.redirect('/'); });

ЁЯПн рдкреНрд░рддреНрдпрдХреНрд╖ рд╡рд╛рдкрд░рд╛рдд рд╣реЗ рдХрд╛ рдорд╣рддреНрддреНрд╡рд╛рдЪреЗ: рд╢рдХреНрдп рдЕрд╕реЗрд▓ рддрд░ passwords рдЕрдЬрд┐рдмрд╛рдд рд╕рд╛рдард╡реВ рдирдХрд╛ тАФ passkeys рдЖрдгрд┐ MFA рджреЗрдгрд╛рд░рд╛ managed identity provider (рдзрдбрд╛ 05) рд╡рд╛рдкрд░рд╛. рд╕рд╛рдард╡рд╛рд╡реЗрдЪ рд▓рд╛рдЧрд▓реЗ рддрд░, library рд╕рд╣ Argon2id рд╡рд╛рдкрд░рд╛, login route rate-limit рдХрд░рд╛, рдЖрдгрд┐ рдирд╡реЗ passwords breached-password рдпрд╛рджреАрд╢реА рддрдкрд╛рд╕рд╛ (рдЙрджрд╛рд╣рд░рдгрд╛рд░реНрде Have I Been Pwned рдЪреА k-anonymity range API).

тПня╕П рдкреБрдвреЗ

рдЕрдиреЗрдХ apps рдиреА passwords рддрдкрд╛рд╕реВрдЪ рдирдпреЗрдд. рддреА рдореНрд╣рдгрддрд╛рдд "school account рдиреЗ login рдХрд░рд╛" рдЖрдгрд┐ token рдШреЗрддрд╛рдд. рдкреБрдвреЗ: OAuth 2 рдЖрдгрд┐ OpenID Connect тАФ рдЖрдгрд┐ token рдиреЗ рдкрд╛рд░ рдХрд░рд╛рд╡реНрдпрд╛ рд▓рд╛рдЧрдгрд╛рд▒реНрдпрд╛ рд╕рдЧрд│реНрдпрд╛ рддрдкрд╛рд╕рдгреНрдпрд╛.

git checkout lesson-05-oauth-oidc

ЁЯФС Lesson 04 тАФ Authentication: proving who you are

ЁЯУН You are here: Lesson 04 of 16 ┬╖ Previous: lesson-03-browser-server ┬╖ Next: lesson-05-oauth-oidc


ЁЯУж What's in this branch

Lessons 01тАУ03, plus the first lesson of the ID desk. The school must store something that lets it check a password without storing the password. You hash a password with a salt and a slow key-derivation function (PBKDF2), check a right and a wrong login, and compare how fast a stolen hash can be guessed. Then MFA, passkeys, and sessions vs tokens. passwords() in sec/demo.py shows it.

ЁЯзТ Explain like I'm 5

The ID desk must check Katrina's secret word every morning. But it must never write the word down тАФ a thief could steal the notebook.

So the desk does something clever. It takes Katrina's word, adds a random sticker only for her (the salt), and puts both into a very slow grinding machine. The machine turns the handle 200,000 times and gives out a smudge. The desk writes down the sticker and the smudge.

Tomorrow Katrina says her word again. The desk adds her sticker, grinds it again, and compares the smudges. Same smudge тЖТ same word.

A thief who steals the notebook sees only stickers and smudges. To find a word, he must guess, grind 200,000 turns, compare тАФ for every guess, for every pupil, because everyone's sticker is different. That is slow on purpose.

And for important doors, the desk asks for a second thing too тАФ a code from Katrina's phone, or her passkey. A stolen word alone is not enough.

ЁЯЧ║я╕П Diagram

flowchart LR
    pw["ЁЯФд password"] --> kdf["ЁЯРв slow KDF<br/>PBKDF2-SHA256 ├Ч 200,000<br/>(Argon2id in production)"]
    salt["ЁЯО▓ random salt<br/>16 bytes, per user"] --> kdf
    kdf --> store["ЁЯУТ stored: salt + iterations + hash<br/>never the password"]
    login["ЁЯФд login attempt"] --> kdf2["ЁЯРв same KDF, same salt"]
    store --> kdf2
    kdf2 --> cmp{"constant-time<br/>compare"}
    cmp -->|"match"| mfa["ЁЯУ▒ second factor<br/>TOTP ┬╖ passkey"]
    mfa --> sess["ЁЯНк new session id"]

ЁЯЧ║я╕П Drawn version + a lab: https://school-edh.pages.dev/security/lesson-diagrams.html#l04

тЭУ What

ЁЯдФ Why

Because password databases leak тАФ through SQL injection (lesson 01), a lost backup, or a careless log. When they leak, the only thing between the thief and your users' accounts тАФ and every other site where they reused the password тАФ is how slow the hash is. A fast hash on a GPU is guessed billions of times a second. A slow, salted one only thousands. MFA and passkeys make a guessed password not enough on its own.

ЁЯФз How (in this repo)

In sec/identity.py: hash_password(password, salt=None, iterations=200_000) makes a random 16-byte salt and returns (salt, iterations, pbkdf2_hmac("sha256", тАж)). verify_password(password, stored) recomputes with the stored salt and iterations and compares with hmac.compare_digest. guesses_per_second(kind) holds illustrative round numbers for one GPU, to show the size of the gap тАФ not a benchmark.

ЁЯзк Try it

python3 sec/demo.py passwords
python3 - <<'EOF'
import sys, time; sys.path.insert(0, "sec"); from identity import hash_password, verify_password
a = hash_password("Summer2026!"); b = hash_password("Summer2026!")
print("same password, two users тЖТ same hash?", a[2] == b[2], "┬╖ salts differ?", a[0] != b[0])
print("right password тЖТ", verify_password("Summer2026!", a), "┬╖ one letter off тЖТ", verify_password("summer2026!", a))
for n in (1_000, 200_000, 600_000):
    t = time.perf_counter(); hash_password("Summer2026!", salt=b"s" * 16, iterations=n)
    print(f"{n:>7,} iterations тЖТ {(time.perf_counter() - t) * 1000:6.1f} ms per guess")
EOF

тЬЕ Verify тАФ what you should see

passwords prints:

тФАтФА stored: salt + 200,000 PBKDF2 rounds тЖТ 7f2c954f85f5934bde900ac7тАж (never the password itself)
   login with the right password тЖТ True ┬╖ wrong тЖТ False
тФАтФА how fast can a stolen hash be guessed? (one GPU, illustrative)
   md5 (unsalted)             50,000,000,000 guesses/s
   sha256 (salted)            10,000,000,000 guesses/s
   pbkdf2 200k iterations             50,000 guesses/s
   use Argon2id / bcrypt / scrypt / PBKDF2 with a salt ┬╖ add MFA ┬╖ rate-limit logins ┬╖ check breached passwords

Your snippet prints (the milliseconds depend on your machine тАФ these are from one laptop):

same password, two users тЖТ same hash? False ┬╖ salts differ? True
right password тЖТ True ┬╖ one letter off тЖТ False
  1,000 iterations тЖТ    0.2 ms per guess
200,000 iterations тЖТ   48.9 ms per guess
600,000 iterations тЖТ  137.0 ms per guess

ЁЯПБ What you just proved

The same password gives two different hashes, because each user has their own salt. A single changed letter fails. And the cost grows with the iterations: 600,000 costs about three times 200,000. A user waits 0.1 seconds once, at login. A thief pays that price for every guess тАФ and in the demo's numbers, a slow KDF is a million times slower to attack than an unsalted MD5.

тЪая╕П Common mistakes

ЁЯПн In production

On a real account тАФ use a library; never write the KDF yourself. Argon2id in Python (argon2-cffi; its defaults follow RFC 9106):

from argon2 import PasswordHasher
ph = PasswordHasher()
stored = ph.hash(password)                    # "$argon2id$v=19$m=65536,t=3,p=4$тАж" тАФ salt included
ph.verify(stored, attempt)                    # raises VerifyMismatchError when wrong
if ph.check_needs_rehash(stored):             # the cost was raised since this hash was made
    stored = ph.hash(attempt)

bcrypt in Node.js:

import bcrypt from 'bcrypt';
const stored = await bcrypt.hash(password, 12);        // cost 12
const ok = await bcrypt.compare(attempt, stored);

Django тАФ put Argon2 first; older hashes are upgraded at the next login:

PASSWORD_HASHERS = [
    "django.contrib.auth.hashers.Argon2PasswordHasher",
    "django.contrib.auth.hashers.PBKDF2PasswordHasher",
    "django.contrib.auth.hashers.BCryptSHA256PasswordHasher",
]

Issue a fresh session id at login (Express with express-session):

req.session.regenerate(err => { if (err) return next(err); req.session.userId = user.id; res.redirect('/'); });

ЁЯПн Why this matters in production: if you can, do not store passwords at all тАФ use a managed identity provider (lesson 05) that offers passkeys and MFA. If you must store them, use Argon2id with a library, rate-limit the login route, and check new passwords against a breached-password list (for example the k-anonymity range API of Have I Been Pwned).

тПня╕П Next

Many apps should not check passwords at all. They say "log in with the school account" and receive a token. Next: OAuth 2 and OpenID Connect тАФ and every check a token must pass.

git checkout lesson-05-oauth-oidc
тЖР Previousbrowser serverNext тЖТoauth oidc

This page is the lesson's README from the lesson-04-authentication branch, shown here so the whole School stays on one site. Code files open on GitHub at the same branch.