# Storing Passwords and Building Safe Login — Hashing, Salts, Rate Limits, MFA and Sessions

Source: https://skills101.in/learn/web-security/password-hashing-authentication — Skills101 free tutorial (Web Security (OWASP Top 10)). Updated 2026-11-09.

Login is the front door. **A07 Authentication Failures** and **A04 Cryptographic Failures** in the OWASP Top 10:2025 cover the classic mistakes: storing passwords badly, allowing unlimited guesses, and weak session handling.

## 1. Never store passwords — store a slow, salted hash

| Method | What happens if the database leaks | Verdict |
|---|---|---|
| Plain text | everyone's password is readable | never |
| Encrypted (AES) | whoever has the key (or steals it) can decrypt all | no — you never need to decrypt a password |
| Fast hash (MD5, SHA-256) | attackers try billions of guesses per second on a GPU | no |
| **Salted slow hash** (bcrypt, scrypt, Argon2id, PBKDF2) | each guess is deliberately expensive and unique per user | **yes** |

A **hash** is a one-way function: easy to compute, practically impossible to reverse. At login you hash what the user typed and compare it with the stored hash.

## 2. Why "salt"?

Without a salt, everyone with the password `password123` has the **same** hash, and attackers use precomputed tables (rainbow tables) of common passwords.

A **salt** is a random value generated per user and stored next to the hash. Same password → different hash for every user.

```python
import hashlib

def fast_hash(pw):
    return hashlib.sha256(pw.encode()).hexdigest()[:16]

print("asha :", fast_hash("password123"))
print("ravi :", fast_hash("password123"))        # identical -> one guess cracks both

def salted(pw, salt):
    return hashlib.sha256((salt + pw).encode()).hexdigest()[:16]

print("asha :", salted("password123", "9f2c"))
print("ravi :", salted("password123", "a71e"))  # different hashes for the same password
```

```text
asha : ef92b778bafe771e
ravi : ef92b778bafe771e
asha : e315bdaa54f3c4b0
ravi : e5a89109f28f14d0
```

![Password storage: the user's password plus a random per-user salt go through a slow hash function many thousands of times; the database stores the algorithm, cost, salt and hash, never the password](https://skills101.in/learn-img/web-security/password-hashing.svg)

## 3. Why "slow"?

SHA-256 is designed to be **fast** — great for files, terrible for passwords. Password hashes are designed to be **slow and tunable**: they repeat work many times (and Argon2/scrypt also use lots of memory) so each guess costs the attacker real time.

Python's standard library includes **PBKDF2** and **scrypt**. A complete, correct pattern:

```python
import hashlib, hmac, os

ITERATIONS = 600_000          # tune so one hash takes ~0.1–0.5 s on your server

def hash_password(password, salt=None):
    salt = salt or os.urandom(16)                     # random 16-byte salt per user
    digest = hashlib.pbkdf2_hmac("sha256", password.encode(), salt, ITERATIONS)
    return f"pbkdf2_sha256${ITERATIONS}${salt.hex()}${digest.hex()}"

def verify_password(password, stored):
    algo, iterations, salt_hex, digest_hex = stored.split("$")
    digest = hashlib.pbkdf2_hmac("sha256", password.encode(), bytes.fromhex(salt_hex), int(iterations))
    return hmac.compare_digest(digest.hex(), digest_hex)

stored = hash_password("Asha@2024!")
print(stored.split("$")[0], stored.split("$")[1], "salt length:", len(stored.split("$")[2]) // 2, "bytes")
print("correct password:", verify_password("Asha@2024!", stored))
print("wrong password:  ", verify_password("asha@2024!", stored))
print("same password, new salt -> different hash:", hash_password("Asha@2024!") != stored)
```

```text
pbkdf2_sha256 600000 salt length: 16 bytes
correct password: True
wrong password:   False
same password, new salt -> different hash: True
```

Notice we store the **algorithm and iteration count** with the hash. That lets you raise the cost later and upgrade old hashes when users log in (Skills101 did exactly this when moving students from WordPress's old hash format).

In real projects, use your framework's built-in or a maintained library: **Argon2id** (`argon2-cffi`) or **bcrypt** are the usual recommendations; Django, Spring Security and Laravel ship good defaults.

## 4. Stop guessing attacks

| Attack | What it is | Defence |
|---|---|---|
| **Brute force** | many passwords against one account | rate limit per account + per IP, slowing down after failures |
| **Credential stuffing** | email/password pairs leaked from *other* sites tried on yours | rate limits, MFA, check passwords against breach lists, bot detection |
| **Password spraying** | one common password against many accounts | rate limit per IP, block very common passwords |
| **Account enumeration** | "user not found" vs "wrong password" tells attackers which emails exist | same message for both: *"Incorrect email or password"* |

A simple sliding-window rate limiter:

```python
from collections import defaultdict, deque

attempts = defaultdict(deque)

def allow_login(key, now, limit=5, window=300):
    q = attempts[key]
    while q and now - q[0] > window:     # forget attempts older than 5 minutes
        q.popleft()
    if len(q) >= limit:
        return False
    q.append(now)
    return True

results = [allow_login("asha@example.com", t) for t in range(0, 70, 10)]
print(results)
print("after 6 minutes:", allow_login("asha@example.com", 400))
```

```text
[True, True, True, True, True, False, False]
after 6 minutes: True
```

**Password rules that actually help** (current NIST guidance): minimum 8 characters (allow long passphrases up to 64+), block common and breached passwords, **no** forced special-character rules or forced periodic changes (they make people choose predictable passwords like `Summer@2025!`).

## 5. Multi-factor authentication (MFA)

Something you **know** (password) + something you **have** (phone app, security key) or **are** (fingerprint).

| Factor | Strength | Note |
|---|---|---|
| SMS OTP | okay | SIM-swap and phishing risk, but far better than nothing |
| Authenticator app (TOTP) | good | 6-digit code that changes every 30 s |
| Passkeys / security keys (WebAuthn) | best | phishing-resistant; the key only works on the real domain |

Offer MFA to everyone; **require** it for admins and staff.

## 6. Sessions after login

- Generate a **new random session ID** after login (prevents *session fixation*).
- Cookie flags: `HttpOnly; Secure; SameSite=Lax`.
- Expire idle sessions; let users **log out of all devices**; kill sessions after a password change.
- Store a hash of the session token in the database, so a DB leak doesn't reveal live sessions.
- Password reset links: random, single-use, expire in minutes to an hour, and don't reveal whether the email exists.

## 7. Case study: the 2-line fix

A college fest site stored passwords as `md5(password)`. A SQL injection bug leaked the table; within an hour most hashes were cracked with a public wordlist, and many students reused those passwords on email.

**Fixes:** parameterised queries (lesson 3), and Argon2id/bcrypt hashes with per-user salts — upgrading each old hash on the user's next login. Combined with rate limits and MFA for organisers.

## Common mistakes

- "Encrypting" passwords so they can be emailed back on "forgot password". Never send a password; send a reset link.
- Using a single global salt (that's a "pepper"; useful extra, but not a replacement for per-user salts).
- Comparing hashes with `==` in high-risk code instead of a constant-time compare.
- Different error messages for "no such user" and "wrong password".

## Quick recap

- Store **salted, slow** hashes (Argon2id, bcrypt, scrypt, PBKDF2) — never plain, encrypted or fast hashes.
- Defend logins with **rate limits**, generic error messages, breached-password checks and **MFA**.
- Sessions: new random ID at login, secure cookie flags, expiry, logout everywhere.


Practice quiz and free certificate: https://skills101.in/learn/web-security/password-hashing-authentication