Authentication, Passwords & MFA

Cyber Security · Lesson 19 of 31 8 min read Updated

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:

PythonRun it yourself
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"))
Output
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:

AlgorithmMinimum settings recommended by OWASP
Argon2id19 MiB memory, 2 iterations, parallelism 1
scryptN = 2^17, r = 8, p = 1 (or equivalent, such as N = 2^14, r = 8, p = 5)
bcryptWork factor 10 or more; inputs longer than 72 bytes are cut off
PBKDF2-HMAC-SHA256600,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):

Python
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)
Output
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:

PythonRun it yourself
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)}")
Output
'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

AttackWhat happensDefences
Brute forceMany guesses against one accountRate limiting, increasing delays, MFA
Password sprayingOne common password tried against many accountsBlocklists, monitoring failed logins across accounts
Credential stuffingEmail and password pairs leaked from another siteMFA, breached-password checks, bot detection
PhishingThe user types the password into a fake sitePhishing-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:

PythonRun it yourself
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))
Output
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 ==. Use hmac.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
Python
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?

2. According to NIST SP 800-63B-4, what is the minimum length for a password used as the only factor?

3. Which MFA method is phishing-resistant?

4. Which is a recommended password hashing algorithm?

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