Authentication, Passwords & MFA
Passwords are still the front door of most systems, and attackers know it. Stolen and guessed passwords are behind a large share of breaches, from "credential stuffing" with passwords leaked from other sites to simple guessing of Admin@123. Good authentication combines safe password storage, sensible password rules and a second factor.
In this lesson you will see why fast hashes are wrong for passwords, how salts and slow key-derivation functions fix that, what current NIST guidance says about password rules, how to build a password strength checker, and how one-time codes and passkeys work.
Factors of authentication
Authentication factors fall into three groups:
- Something you know: a password, PIN or security answer.
- Something you have: a phone receiving an OTP, an authenticator app, a hardware security key.
- Something you are: a fingerprint or face scan.
Multi-factor authentication (MFA) requires factors from at least two different groups. A password plus a security question is still one factor (two things you know).
Never store passwords as plain hashes
A system should never store passwords in a form it can reverse. It stores a hash and compares hashes at login. But a plain, fast hash like SHA-256 has two problems. Identical passwords give identical hashes, and attackers can test billions of guesses per second on graphics cards. Run this to see the first problem and how a salt (a random value stored next to each hash) fixes it:
import hashlib
def sha256(text):
return hashlib.sha256(text.encode()).hexdigest()[:20]
print("Unsalted, two users with 'Mumbai@123':")
print(" aarav:", sha256("Mumbai@123"))
print(" isha: ", sha256("Mumbai@123"))
salts = {"aarav": "9f2c71aa", "isha": "41d0e6b3"} # real salts are random, at least 16 bytes
print("Salted:")
for user, salt in salts.items():
print(f" {user + ':':<6}", sha256(salt + "Mumbai@123"))Unsalted, two users with 'Mumbai@123': aarav: e2276600d5128c8e2f5a isha: e2276600d5128c8e2f5a Salted: aarav: 94189fa9e7657a539c19 isha: 3b6721bbc5c47447c4dd
Salts stop precomputed tables (rainbow tables) and hide which users share a password. They do not slow down guessing. For that you need a deliberately slow, memory-hard password hashing function. OWASP's current recommendations, in order of preference:
| Algorithm | Minimum settings recommended by OWASP |
|---|---|
| Argon2id | 19 MiB memory, 2 iterations, parallelism 1 |
| scrypt | N = 2^17, r = 8, p = 1 (or equivalent, such as N = 2^14, r = 8, p = 5) |
| bcrypt | Work factor 10 or more; inputs longer than 72 bytes are cut off |
| PBKDF2-HMAC-SHA256 | 600,000 iterations (when FIPS compliance is required) |
Python's standard library includes scrypt (it needs Python built with OpenSSL, which normal installs have, but the in-browser editor does not, so run this on your computer):
import hashlib
import hmac
import os
def hash_password(password):
salt = os.urandom(16)
key = hashlib.scrypt(password.encode(), salt=salt, n=2**14, r=8, p=5, dklen=32)
return salt + key
def check_password(password, stored):
salt, key = stored[:16], stored[16:]
attempt = hashlib.scrypt(password.encode(), salt=salt, n=2**14, r=8, p=5, dklen=32)
return hmac.compare_digest(attempt, key)
stored = hash_password("chai-and-cricket-at-7")
print("Stored bytes:", len(stored))
print("Right password:", check_password("chai-and-cricket-at-7", stored))
print("Wrong password:", check_password("chai-and-cricket-at-8", stored))
print("Same password, new hash differs:", hash_password("chai-and-cricket-at-7") != stored)Stored bytes: 48 Right password: True Wrong password: False Same password, new hash differs: True
In real applications, use your framework's password hasher (PHP's password_hash(), Django's hashers, Spring Security's encoders) or a maintained Argon2 library. They choose safe settings, store the salt and parameters with the hash, and let you upgrade settings later.
Password rules that actually help
NIST's digital identity guidelines (SP 800-63B, revision 4, 2025) changed many old habits. For systems following it:
- Passwords used as the only factor must be at least 15 characters; passwords used with MFA at least 8.
- Allow at least 64 characters, spaces and all Unicode characters, so people can use passphrases.
- No composition rules such as "one uppercase, one number, one symbol". They push people towards
Password@1. - No forced periodic changes. Require a change only when there is evidence of compromise.
- Check new passwords against a blocklist of common, expected and breached passwords.
- Allow paste and password managers.
A strength checker that follows these ideas looks at length and the blocklist first:
import math
import string
BLOCKLIST = {"password", "123456789", "qwerty123", "india@123", "admin@123", "iloveyou"}
def check(password, with_mfa=False):
minimum = 8 if with_mfa else 15
if password.lower() in BLOCKLIST:
return "REJECT: commonly used or breached password"
if len(password) < minimum:
return f"REJECT: use at least {minimum} characters"
pools = [string.ascii_lowercase, string.ascii_uppercase, string.digits, string.punctuation + " "]
size = sum(len(p) for p in pools if any(c in p for c in password))
bits = len(password) * math.log2(size)
return f"OK: about {bits:.0f} bits if it were random"
for pw in ["India@123", "Tr0ub&dor", "monsoon rain on marine drive", "kochi-backwaters-2026"]:
print(f"{pw!r:<32} {check(pw)}")
print(f"{'Tr0ub&dor (with MFA)':<32} {check('Tr0ub&dor', with_mfa=True)}")'India@123' REJECT: commonly used or breached password 'Tr0ub&dor' REJECT: use at least 15 characters 'monsoon rain on marine drive' OK: about 165 bits if it were random 'kochi-backwaters-2026' OK: about 128 bits if it were random Tr0ub&dor (with MFA) OK: about 59 bits if it were random
The "bits" figure assumes every character was chosen randomly, which human passwords never are, so it overstates their strength. Real checkers such as the zxcvbn library also look for words, keyboard patterns and dates. Large services also check passwords against breach corpuses, often using a k-anonymity API that sends only the first few characters of the password's hash, so the password itself never leaves the server.
Attacks on login and their defences
| Attack | What happens | Defences |
|---|---|---|
| Brute force | Many guesses against one account | Rate limiting, increasing delays, MFA |
| Password spraying | One common password tried against many accounts | Blocklists, monitoring failed logins across accounts |
| Credential stuffing | Email and password pairs leaked from another site | MFA, breached-password checks, bot detection |
| Phishing | The user types the password into a fake site | Phishing-resistant MFA (passkeys, security keys) |
Hard account lockouts after a few failures can let attackers lock out real users on purpose, so many systems prefer growing delays plus alerts. In your lab, DVWA's Brute Force page shows a login with no rate limiting at the low level and protections at higher levels.
MFA options compared
- SMS OTP: widely used in India for banking and UPI. Better than nothing, but vulnerable to SIM-swap fraud and to phishing pages that ask for the OTP.
- Authenticator apps (TOTP): the app and server share a secret and compute a new 6-digit code every 30 seconds from the current time. No network is needed, but codes can still be phished.
- Push approval: the user taps "Approve". Attackers spam requests hoping for an accidental tap (MFA fatigue), so good apps ask the user to type a number shown on screen.
- Passkeys and security keys (FIDO2/WebAuthn): the device signs a challenge with a private key tied to the real website's domain, so a look-alike phishing site gets nothing usable. These are phishing-resistant and the strongest common option.
TOTP is simple enough to write with the standard library. This follows RFC 6238; the fixed times let you compare the results with the RFC's published test values:
import hashlib
import hmac
import struct
def totp(secret, unix_time, step=30, digits=6):
counter = unix_time // step
mac = hmac.new(secret, struct.pack(">Q", counter), hashlib.sha1).digest()
offset = mac[-1] & 0x0F
code = struct.unpack(">I", mac[offset:offset + 4])[0] & 0x7FFFFFFF
return str(code % 10 ** digits).zfill(digits)
secret = b"12345678901234567890" # the RFC's test secret, never use in real life
for t in [59, 1111111109, 1234567890]:
print(t, totp(secret, t), totp(secret, t, digits=8))59 287082 94287082 1111111109 081804 07081804 1234567890 005924 89005924
A real server also accepts the code from one step before or after (to allow for clock drift), refuses a code that was already used, and limits attempts, because a 6-digit code has only a million possibilities.
Sessions after login
Authentication does not end at the login form. Generate a new session ID right after login (to prevent session fixation), set Secure, HttpOnly and SameSite on the session cookie, expire idle sessions, and make logout destroy the session on the server. Ask for the password or MFA again before sensitive actions such as changing the email address or adding a payee.
Common mistakes
- Using MD5, SHA-1 or a single SHA-256 for passwords. Use Argon2id, scrypt, bcrypt or PBKDF2 with strong settings.
- Error messages that reveal which part was wrong ("no such user"). Say "Wrong email or password".
- Forcing monthly password changes and symbol rules. They produce weaker, predictable passwords.
- Treating SMS OTP as phishing-proof. Offer passkeys or security keys for important accounts.
- Comparing secrets with
==. Usehmac.compare_digest()to avoid timing leaks.
Key points
- Authentication factors are something you know, have or are; MFA needs two different kinds.
- Salts make each hash unique; slow functions (Argon2id, scrypt, bcrypt, PBKDF2) make guessing expensive.
- NIST SP 800-63B-4: 15+ characters for password-only login, no composition rules, no forced rotation, blocklist checks.
- Defend logins with rate limiting, breached-password checks and MFA.
- TOTP codes come from a shared secret and the time; passkeys are phishing-resistant.
Exercise
Extend the strength checker so that it also rejects passwords that contain the user's own name or email name. Add a username parameter, and test it with the user "farhan" and the passwords "Farhan-loves-biryani-2026" and "spicy-biryani-in-lucknow".
Show solution
BLOCKLIST = {"password", "india@123", "admin@123"}
def check(password, username, with_mfa=False):
minimum = 8 if with_mfa else 15
if password.lower() in BLOCKLIST:
return "REJECT: commonly used or breached password"
if username.lower() in password.lower():
return "REJECT: do not include your user name"
if len(password) < minimum:
return f"REJECT: use at least {minimum} characters"
return "OK"
for pw in ["Farhan-loves-biryani-2026", "spicy-biryani-in-lucknow"]:
print(pw, "->", check(pw, "farhan"))The first password is rejected because it contains the user name (attackers try names first); the second passes. Comparing in lower case catches Farhan as well as farhan.
Quick Quiz
1. What does a salt protect against?
Salts make each hash unique; a slow hash function is what makes guessing expensive.
2. According to NIST SP 800-63B-4, what is the minimum length for a password used as the only factor?
Revision 4 requires 15 characters for single-factor passwords and 8 when used with MFA.
3. Which MFA method is phishing-resistant?
Passkeys sign a challenge bound to the real domain, so a fake site cannot use the response.
4. Which is a recommended password hashing algorithm?
Argon2id is slow and memory-hard, designed for password storage.
Learn Cyber Security with a mentor
Free lessons get you started. Our Ethical Hacking & Cyber Security training adds real projects and code reviews from working developers.
- Project-based learning
- Mentor code reviews
- Free counselling call
