What Is Authentication?#

Authentication answers one question: who are you? It is the process of verifying that a claimed identity is genuine. Authentication is distinct from authorisation (what you can do), but a failure in authentication makes authorisation meaningless.


Password Storage#

The Golden Rule#

Never store passwords in plaintext. Never store them encrypted (encryption is reversible). Always store a one-way hash with a unique salt per password.

How Password Hashing Works#

User password:  "correct horse battery staple"
        │
        ▼
┌───────────────────┐
│  Hashing function  │  ← bcrypt, argon2, scrypt
│  + unique salt     │
│  + cost factor     │
└────────┬──────────┘
         ▼
Stored hash: "$2b$12$LJ3m4ys3Lg0Yf7KlZ8K0VePXdRkSEqGNmFCS.sVGBHby/RASY7Y/G"

Algorithm Comparison#

AlgorithmTypeMemory-HardRecommendedNotes
Argon2idKDFYes✅ Best choiceWinner of Password Hashing Competition (2015)
bcryptKDFNo✅ ExcellentBattle-tested since 1999, 72-byte password limit
scryptKDFYes✅ GoodMemory-hard, harder to tune than argon2
PBKDF2KDFNo⚠️ AcceptableSHA-256 variant only; GPU-friendly
SHA-256HashNo❌ NeverNo salt, no cost factor, GPU-crackable
MD5HashNo❌ NeverBroken, collisions found, GPU-crackable

Implementation#

Python (argon2):

from argon2 import PasswordHasher

ph = PasswordHasher(
    time_cost=3,        # iterations
    memory_cost=65536,  # 64 MB
    parallelism=4,
)

# Hash
hashed = ph.hash("user_password")

# Verify
try:
    ph.verify(hashed, "user_password")
except argon2.exceptions.VerifyMismatchError:
    raise AuthenticationError("Invalid credentials")

Java (bcrypt via Spring Security):

import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;

BCryptPasswordEncoder encoder = new BCryptPasswordEncoder(12); // cost factor 12

// Hash
String hashed = encoder.encode("user_password");

// Verify
boolean matches = encoder.matches("user_password", hashed);

Node.js (bcrypt):

import bcrypt from 'bcrypt';

const SALT_ROUNDS = 12;

// Hash
const hashed = await bcrypt.hash('user_password', SALT_ROUNDS);

// Verify
const matches = await bcrypt.compare('user_password', hashed);

Go (bcrypt):

import "golang.org/x/crypto/bcrypt"

// Hash
hashed, err := bcrypt.GenerateFromPassword([]byte("user_password"), 12)

// Verify
err = bcrypt.CompareHashAndPassword(hashed, []byte("user_password"))
if err != nil {
    // authentication failed
}

Cost Factor Tuning#

The cost factor should make hashing take 250ms–1s on your authentication server. This is slow enough to deter brute force but fast enough for normal login.

import time
from argon2 import PasswordHasher

for time_cost in range(1, 6):
    ph = PasswordHasher(time_cost=time_cost, memory_cost=65536, parallelism=4)
    start = time.monotonic()
    ph.hash("benchmark_password")
    elapsed = time.monotonic() - start
    print(f"time_cost={time_cost}: {elapsed:.3f}s")

Session Management#

After authentication, the server must track the user’s identity across requests. This is done via session tokens.

Session Token Requirements#

PropertyRequirement
Randomness128+ bits of cryptographic randomness
UnpredictableGenerated by CSPRNG, not sequential
ExpirationShort-lived (hours, not months)
RotationIssue new token after login, privilege change
InvalidationServer can revoke at any time
Set-Cookie: session_id=a8f5f167f44f4964e6c998dee827110c;
  HttpOnly;
  Secure;
  SameSite=Lax;
  Path=/;
  Max-Age=3600
AttributePurpose
HttpOnlyPrevents JavaScript access (document.cookie) — mitigates XSS
SecureCookie only sent over HTTPS
SameSite=LaxPrevents cross-site sending (mitigates CSRF)
Path=/Scopes the cookie to the entire site
Max-AgeExpiration in seconds

Session Fixation Prevention#

Always regenerate the session ID after successful login:

# Python (Flask)
from flask import session

@app.route('/login', methods=['POST'])
def login():
    user = authenticate(request.form['username'], request.form['password'])
    if user:
        session.regenerate()  # New session ID
        session['user_id'] = user.id
        return redirect('/dashboard')
// Java (Spring Security) — handled automatically by default
// Manual regeneration:
request.changeSessionId();

Multi-Factor Authentication (MFA)#

MFA requires two or more independent authentication factors:

FactorCategoryExamples
Something you knowKnowledgePassword, PIN, security question
Something you havePossessionPhone (TOTP app), hardware key (YubiKey), SMS code
Something you areInherenceFingerprint, face recognition, voice

TOTP (Time-Based One-Time Password)#

The most common second factor for web applications. Based on RFC 6238.

┌─────────────┐          ┌─────────────┐
│  Server      │          │  User Phone  │
│              │          │  (Auth App)  │
│  Shared      │          │              │
│  Secret  ────┼──────────┼── Shared     │
│              │          │  Secret      │
│  HMAC-SHA1(  │          │              │
│   secret,    │          │  HMAC-SHA1(  │
│   time/30)   │          │   secret,    │
│  = 6 digits  │          │   time/30)   │
│              │          │  = 6 digits  │
└──────┬───────┘          └──────┬───────┘
       │                         │
       └─── Must match ──────────┘

Python (pyotp):

import pyotp

# Generate shared secret (store securely, display as QR code)
secret = pyotp.random_base32()

# Generate provisioning URI for QR code
uri = pyotp.totp.TOTP(secret).provisioning_uri(
    name="user@example.com",
    issuer_name="MyApp"
)

# Verify OTP from user
totp = pyotp.TOTP(secret)
is_valid = totp.verify(user_provided_code, valid_window=1)

WebAuthn / Passkeys#

The modern replacement for passwords. Uses public-key cryptography with hardware authenticators.

PropertyPasswordTOTPWebAuthn / Passkey
Phishing resistant❌❌✅
Replay resistant❌✅ (30s window)✅
No shared secret on server❌❌✅ (public key only)
User experience⚠️⚠️✅ (biometric)

Brute Force Protection#

Rate Limiting Login Attempts#

Limit by both IP address and username to handle distributed attacks:

# Pseudocode — rate limiting strategy
def check_rate_limit(username: str, ip: str) -> bool:
    ip_attempts = redis.incr(f"login:ip:{ip}", ex=900)       # 15-minute window
    user_attempts = redis.incr(f"login:user:{username}", ex=900)

    if ip_attempts > 20:       # 20 attempts per IP per 15 min
        return False
    if user_attempts > 5:      # 5 attempts per username per 15 min
        return False
    return True

Account Lockout vs Throttling#

StrategyProsCons
Hard lockout (lock after N failures)SimpleDoS vector — attacker locks legitimate users
Progressive delay (increase wait time)No lockout DoSSlow but not blocking
CAPTCHA after N failuresStops botsDegrades UX
IP-based throttlingTransparent to usersBypassed by distributed attacks

Best practice: combine progressive delay + CAPTCHA + IP throttling.


Credential Stuffing#

Attackers use leaked username/password pairs from breached databases to log in to unrelated services. This works because users reuse passwords.

Prevention#

ControlEffect
MFAStolen password alone is insufficient
Breached password checksReject passwords found in known breaches (HaveIBeenPwned API)
Rate limitingSlow down automated attempts
Bot detectionDevice fingerprinting, behavioural analysis
Credential monitoringAlert users when their credentials appear in breach dumps

Checking HaveIBeenPwned (k-anonymity API):

import hashlib
import httpx

def is_password_breached(password: str) -> bool:
    sha1 = hashlib.sha1(password.encode()).hexdigest().upper()
    prefix, suffix = sha1[:5], sha1[5:]

    resp = httpx.get(f"https://api.pwnedpasswords.com/range/{prefix}")
    return any(line.startswith(suffix) for line in resp.text.splitlines())

Secure Authentication Checklist#

✅Control
☐Passwords hashed with argon2id or bcrypt (cost-tuned to ≥250ms)
☐Unique salt per password (handled by argon2/bcrypt automatically)
☐Session tokens: 128+ bit CSPRNG, HttpOnly, Secure, SameSite
☐Session regenerated after login and privilege changes
☐MFA available (TOTP or WebAuthn)
☐Login rate limiting (per-IP and per-username)
☐Breached password check on registration and password change
☐Generic error messages (“Invalid credentials” — never “User not found”)
☐Password minimum length ≥ 8, no maximum below 128
☐No password composition rules (they reduce entropy via predictable patterns)

Key Takeaways#

  • Never store passwords in plaintext or with reversible encryption — use argon2id (preferred) or bcrypt with a cost factor tuned to ≥250ms.
  • Session tokens must be cryptographically random, short-lived, and sent in cookies with HttpOnly, Secure, and SameSite attributes.
  • Multi-factor authentication is the single most effective control against credential theft — TOTP is the baseline; WebAuthn/passkeys are the future.
  • Rate limit login attempts by both IP address and username to mitigate brute force and credential stuffing attacks.
  • Check passwords against breach databases (HaveIBeenPwned k-anonymity API) at registration and password change to block known-compromised credentials.
  • Generic error messages on login failure (“Invalid credentials”) prevent username enumeration.