Authentication in System Design: Passwords, Sessions, JWTs, OAuth2 & MFA (Visualized)
Authentication is the process of verifying that a user or service is who it claims to be. This guide covers password hashing, session cookies vs stateless JWTs, OAuth2 delegated login, multi-factor authentication, and the most dangerous attacks โ with live animations of each flow.
Authentication is the act of verifying the identity of a principal โ a user, device, or service โ before granting any access to a system. It answers the question "Who are you?", and is the mandatory first gate before authorization (which answers "What are you allowed to do?"). Every product that stores user data, handles money, or exposes an API must implement authentication correctly, because every flaw here directly translates to account takeovers and data breaches.
Authentication is not a single mechanism but a family of techniques that differ in where state is stored, what credentials the user presents, and how the server verifies them. Getting it wrong โ storing plain-text passwords, issuing non-expiring tokens, skipping second factors โ is one of the most common causes of catastrophic security incidents. This post walks through every major approach used in production systems today.
Authentication vs Authorization
These two words are frequently confused. Authentication (AuthN) establishes identity โ the server confirms you are alice@example.com by checking a credential. Authorization (AuthZ) establishes permission โ the server checks whether alice@example.com is allowed to read a particular resource. Authentication always happens first: you cannot sensibly enforce permissions until you know who is asking. In system-design interviews and code reviews, always be explicit about which concern you are addressing.
| Authentication (AuthN) | Authorization (AuthZ) | |
|---|---|---|
| Question answered | Who are you? | What can you do? |
| Happens | First โ at login | After identity is confirmed |
| Credential examples | Password, OTP, biometric | Role, permission, ACL entry |
| Failure response | 401 Unauthorized | 403 Forbidden |
| Example systems | bcrypt hash check, OAuth2 | RBAC, ABAC, IAM policies |
Storing Passwords Safely: Salted Slow Hashes
Passwords must never be stored in plain text or encrypted (encryption is reversible). The correct approach is a one-way cryptographic hash, but not a fast general-purpose hash like SHA-256 โ those can be brute-forced at billions of guesses per second on a GPU. Instead, use a slow, adaptive, salted password hashing function. The two production-grade choices are bcrypt and argon2id.
A salt is a random value generated uniquely per user and concatenated with the password before hashing. This means two users with the same password get completely different hash values, defeating rainbow-table precomputation attacks. The salt is stored openly alongside the hash โ its secrecy is not required; its uniqueness is what matters. bcrypt embeds the salt in the output string itself. The cost factor (or work factor) controls how many iterations the algorithm runs; increasing it as hardware speeds up keeps brute-force impractical.
import bcrypt
# Registering a new user โ hash password before storing
def register(email: str, plaintext_password: str) -> None:
salt = bcrypt.gensalt(rounds=12) # cost factor 12 (~300 ms on modern CPU)
hashed = bcrypt.hashpw(plaintext_password.encode(), salt)
db.users.insert({"email": email, "password_hash": hashed})
# Login โ compare supplied password to stored hash
def login(email: str, plaintext_password: str) -> bool:
user = db.users.find_one({"email": email})
if user is None:
bcrypt.checkpw(b"dummy", b"$2b$12$invalidhash................") # constant-time dummy
return False
return bcrypt.checkpw(plaintext_password.encode(), user["password_hash"])Note the dummy checkpw call when the user is not found. Without it, a timing attack can distinguish "user does not exist" from "wrong password" by measuring response time, leaking which email addresses are registered. Always perform the hash comparison regardless of whether the user was found.
The Login Flow: Hashing, Verifying, and Issuing a Credential
At registration the server hashes the password and stores only the hash. At login the server re-hashes the supplied password with the stored salt and compares byte-for-byte. If they match, the server issues a credential โ either a session cookie or a token โ that the client presents on every subsequent request, so the user does not re-enter their password for each page load.
Session Cookies vs Stateless JWTs
Once identity is confirmed, the server must give the client something to carry โ a proof of login. Two architecturally different approaches dominate: session cookies (stateful) and JSON Web Tokens (stateless).
In session-cookie auth, the server generates a random opaque session ID (e.g. a 128-bit UUID), stores the session data (user ID, roles, expiry) in a server-side store (Redis, a database), and sends the session ID to the browser as a cookie. On every request the browser sends the cookie back, the server looks up the session ID in its store, and retrieves the session data. The server is the source of truth โ a session can be invalidated instantly by deleting the record.
In JWT (JSON Web Token) auth, the server issues a self-contained token that encodes the user's claims (user ID, roles, expiry) and signs them with a secret or private key. On every subsequent request the client sends the token, and the server validates the cryptographic signature โ no database lookup needed. This makes JWTs naturally horizontally scalable, but there is a trade-off: once a JWT is signed, you cannot revoke it before it expires unless you maintain a blocklist (which brings back state).
| Session Cookie | JWT (Stateless Token) | |
|---|---|---|
| State location | Server (Redis / DB) | Client (token itself) |
| Revocation | Instant โ delete session record | Hard โ must wait for expiry or maintain blocklist |
| Scalability | Requires shared session store | Any instance verifies independently |
| Token size | Small opaque ID (~128 bits) | Larger (header + payload + signature) |
| Best for | Traditional web apps, single cluster | Microservices, mobile apps, cross-domain APIs |
| Risk if stolen | Rotatable server-side | Valid until expiry โ keep TTL short (15 min) |
OAuth2 and OpenID Connect: Delegated Login
OAuth 2.0 is an authorization framework that allows a third-party application (the client) to obtain limited access to a resource on behalf of a user, without exposing the user's credentials to that third party. The flow most users know is "Sign in with Google": you log in to Google, Google issues an authorization code, your application exchanges the code for tokens. The application never sees your Google password.
OpenID Connect (OIDC) is a thin identity layer built on top of OAuth 2.0. While OAuth 2.0 only issues an access token (proving you can access a resource), OIDC additionally issues an ID token (a JWT that proves who the user is). OIDC is what you use when you want "log in with Google" in a secure, standardized way. Key roles: the Authorization Server (Google, Auth0, Okta) handles credentials and issues tokens; the Resource Server (your API) validates access tokens; the Client (your front-end or mobile app) drives the flow.
Multi-Factor Authentication (MFA)
A single password is a single point of failure. Multi-factor authentication requires the user to prove identity through two or more independent factors from different categories: something you know (password, PIN), something you have (phone TOTP app, hardware key), and something you are (fingerprint, face). An attacker who steals your password database still cannot log in as a user without that user's second factor.
The most widely deployed second factor is TOTP (Time-based One-Time Password), defined in RFC 6238. During enrollment, the server generates a shared secret and the user scans a QR code into an authenticator app (Google Authenticator, Authy). At login, the app computes TOTP = HMAC-SHA1(secret, floor(time/30)), producing a 6-digit code that changes every 30 seconds. The server computes the same value and compares. No network round-trip to a phone number; no SMS interception risk.
Common Attacks and Defenses
Authentication systems are the most attractive target in any application. Understanding the attack surface helps you build the right defenses from day one rather than retrofitting them after a breach.
| Attack | What happens | Defense |
|---|---|---|
| Credential stuffing | Attacker replays breached username/password pairs from other sites | Rate limiting, CAPTCHA, breach-password detection (HaveIBeenPwned API), MFA |
| Brute force | Attacker systematically tries all passwords | Account lockout, exponential back-off, slow hashes (bcrypt), MFA |
| Password spraying | One common password tried across many accounts to avoid lockout | Slow hashes, MFA, anomaly detection on login geography |
| Phishing | User tricked into entering credentials on a fake site | WebAuthn/passkeys (phishing-resistant), MFA, HTTPS-only |
| Session hijacking | Attacker steals a valid session cookie from network or XSS | HttpOnly + Secure + SameSite cookie flags, short session TTLs, CSP |
| JWT algorithm confusion | Attacker changes alg:RS256 to alg:none or HS256 and forges token | Whitelist accepted algorithms server-side; never trust alg header blindly |
| Rainbow table attack | Precomputed hash table maps common passwords to hashes | Per-user salt (bcrypt/argon2 include this automatically) |
| Replay attack | Stolen token reused after original session should be dead | Short JWT TTL + refresh tokens; session invalidation list |
Passkeys and WebAuthn: The Password-Free Future
WebAuthn / FIDO2 replaces passwords with public-key cryptography bound to the user's device. During registration, the device generates a public/private key pair; the public key is stored on the server. During login, the device signs a server-issued challenge with the private key, which never leaves the device. Because the credential is bound to the exact domain (the relying party), phishing is structurally impossible โ a fake domain would receive a signature the server cannot verify. Passkeys (the consumer-facing name) sync these keys across devices via iCloud Keychain or Google Password Manager, eliminating usability concerns. Large platforms (Apple, Google, Microsoft) already support passkeys and they are rapidly becoming the recommended primary authentication method.
Frequently Asked Questions
Should I use JWTs or session cookies?
It depends on your architecture. If you have a single backend cluster and need instant logout (e.g., banking, medical), use server-side sessions backed by Redis โ you can delete the session record and the user is immediately logged out. If you are building a stateless API consumed by multiple services, SPAs, or mobile apps, short-lived JWTs (15-minute access token + rotating refresh token) scale better because any service instance can verify the token without a shared store. Never store JWTs in localStorage; use HttpOnly cookies to prevent XSS theft.
Why is bcrypt better than SHA-256 for passwords?
SHA-256 is designed to be fast โ a modern GPU can compute billions of SHA-256 hashes per second, making brute-force trivial against a leaked hash database. bcrypt is deliberately slow: with a cost factor of 12, each hash takes roughly 250โ350 ms on a modern CPU, making brute-force 109ร harder. It also automatically generates and embeds a per-user salt, defeating rainbow tables. argon2id is the current OWASP recommendation as it adds memory hardness (resisting GPU attacks), but bcrypt remains perfectly acceptable for most applications.
What is the difference between OAuth2 and OpenID Connect?
OAuth 2.0 is an authorization framework: it issues access tokens that let an application act on a user's behalf (e.g., read their Google Drive files). It does not define how to prove who the user is. OpenID Connect extends OAuth 2.0 with an ID token โ a signed JWT that contains the user's identity claims (sub, email, name). Use OAuth 2.0 when you need to access a third-party API on a user's behalf; use OIDC when you need federated login ("Sign in with Google") โ you get both authentication and an access token in a single flow.
Authentication is not a feature you bolt on at the end โ it is the load-bearing wall of every user-facing system. Get the hashing, the token lifetime, and the second factor right from day one, and you remove an entire class of catastrophic failures.
โ alokknight Engineering
