🔐 Authentication Models and Workflows
Every serious application eventually asks the same question: who are you, and can I trust you? This lesson maps the landscape of authentication — from passwords and server sessions to stateless tokens, refresh-token rotation, multi-factor prompts, and social login — so you can pick the right model on purpose instead of by habit.
🎯 Learning Objectives
By the end of this lesson, you will be able to:
- Distinguish authentication from authorization and name the three authentication factor categories
- Store and verify passwords safely with a modern hashing algorithm and per-user salt
- Contrast session-based and token-based authentication and explain their trade-offs
- Describe the refresh-token pattern and why short access-token lifetimes matter
- Recognize when to add MFA and social login, and the security pitfalls of each
Estimated Time: 45–60 minutes • Difficulty: Intermediate
Hands-on: Build a secure register/login flow in Express with hashed passwords and protected routes.
In This Lesson
What Authentication Really Is
Authentication is the process of verifying that users are who they claim to be — the digital equivalent of checking someone's ID at the door. It is easy to confuse with its close cousin, authorization, but they answer different questions.
📖 Two words that get mixed up
Authentication — "Who are you?" Proving identity (logging in).
Authorization — "What are you allowed to do?" Enforcing permissions (can this user delete that post?).
You always authenticate first, then authorize. A valid login does not automatically grant admin rights.
Every authentication system, no matter how it is built, has to answer three questions:
- Identity: Who is the user claiming to be?
- Proof: How do they prove that claim?
- Persistence: How do we remember they proved it, so they aren't asked on every click?
💡 A useful analogy: Authentication is like entering a country. Your passport establishes your identity, the immigration officer verifies it's really you (proof), and the entry stamp lets you move around without being re-checked at every corner (persistence). The rest of this lesson is really about that last part — how different systems issue and honor the "stamp."
Authentication Factors & MFA
An authentication factor is a category of proof. Security professionals group them into four families, and combining families is what makes an account genuinely hard to break into.
| Factor | Meaning | Examples |
|---|---|---|
| Something you know | Secret knowledge | Password, passphrase, PIN |
| Something you have | A possessed device | Phone with an authenticator app, hardware key (YubiKey), passkey |
| Something you are | A biometric trait | Fingerprint, face, iris |
| Somewhere you are | A location signal | GPS, IP range, geofencing |
Multi-factor authentication (MFA) requires proof from two different families — the classic example is an ATM: your card (something you have) plus your PIN (something you know). Two passwords are not MFA, because they belong to the same family.
The most common second factor today is TOTP (Time-based One-Time Password) — the six-digit code your authenticator app rotates every 30 seconds. Here is how a server issues and verifies one:
// Node.js — TOTP setup and verification with otplib
const { authenticator } = require('otplib');
const QRCode = require('qrcode');
// 1. During MFA setup: generate a secret unique to this user
async function beginMfaSetup(user) {
const secret = authenticator.generateSecret(); // store this (encrypted!) on the user
const otpauth = authenticator.keyuri(user.email, 'MyApp', secret);
const qrDataUrl = await QRCode.toDataURL(otpauth); // user scans this in their app
return { secret, qrDataUrl };
}
// 2. On every login attempt after MFA is enabled: verify the 6-digit code
function verifyMfa(secret, userSuppliedCode) {
// otplib allows a small time window to tolerate clock drift
return authenticator.check(userSuppliedCode, secret); // true / false
}
⚠️ SMS codes are the weakest second factor
Text-message codes can be intercepted through SIM-swapping attacks. They are still far better than no MFA, but prefer authenticator apps, push approvals, or — best of all — passkeys (WebAuthn), which are phishing-resistant by design.
Password-Based Authentication
Despite decades of predictions of their death, passwords remain the most common credential. The danger is almost never the password itself — it's how the server stores it.
⚠️ The one rule you must never break
Never store passwords in plain text, and never store them with reversible encryption. Always run them through a slow, one-way password-hashing function with a unique salt per user.
Think of hashing like a meat grinder: you can turn a steak into ground beef, but you can never reconstruct the steak. A stolen database of properly hashed passwords is far less useful to an attacker than one of plaintext passwords.
bcrypt / argon2] C --> D[Stored hash] E[(Database)] --> D
Modern libraries such as bcrypt generate the salt for you and embed it inside the output, so you store a single string:
const bcrypt = require('bcrypt');
const SALT_ROUNDS = 12; // higher = slower = harder to brute-force
// Hash at registration time
async function hashPassword(plain) {
return bcrypt.hash(plain, SALT_ROUNDS); // salt is generated and embedded automatically
}
// Compare at login time — bcrypt extracts the salt from the stored hash
async function verifyPassword(plain, storedHash) {
return bcrypt.compare(plain, storedHash); // resolves to true or false
}
💡 bcrypt vs. argon2
bcrypt is battle-tested and available everywhere. argon2id won the 2015 Password Hashing Competition and resists GPU attacks even better. Either is an excellent choice; what matters is that you use a purpose-built password hash, never a fast general hash like MD5 or SHA-256 on its own.
Checking password strength
Length beats complexity. Rather than forcing arbitrary "one uppercase, one symbol" rules, estimate real-world guessability and reject weak choices. The zxcvbn library scores a password from 0 (terrible) to 4 (strong):
const zxcvbn = require('zxcvbn');
function validateStrength(password, userInputs = []) {
// Pass known context (email, username) so those can't be reused as the password
const result = zxcvbn(password, userInputs);
if (result.score < 3) {
return { ok: false, message: result.feedback.warning || 'Password is too weak',
suggestions: result.feedback.suggestions };
}
return { ok: true };
}
You should also check candidate passwords against known-breach lists (for example, the "Have I Been Pwned" range API) so users cannot pick a password that has already leaked.
Session-Based Authentication
In the session model the server remembers who is logged in. After verifying credentials, the server creates a random session ID, stores the associated data server-side (in Redis, a database, or memory), and hands the client only the ID inside a cookie.
Because the cookie is sent automatically by the browser, an HttpOnly session cookie is invisible to JavaScript — which blunts XSS token theft. Here is a hardened Express setup backed by Redis:
const express = require('express');
const session = require('express-session');
const { RedisStore } = require('connect-redis');
const { createClient } = require('redis');
const app = express();
const redisClient = createClient({ url: 'redis://localhost:6379' });
redisClient.connect().catch(console.error);
app.use(session({
store: new RedisStore({ client: redisClient }),
secret: process.env.SESSION_SECRET, // never hard-code this
resave: false,
saveUninitialized: false,
cookie: {
secure: process.env.NODE_ENV === 'production', // HTTPS only in prod
httpOnly: true, // JS cannot read it
sameSite: 'lax', // CSRF mitigation
maxAge: 1000 * 60 * 60 * 24 // 24 hours
}
}));
app.post('/login', async (req, res) => {
const user = await validateUser(req.body.username, req.body.password);
if (!user) return res.status(401).json({ error: 'Invalid credentials' });
// Regenerate the session ID on login to prevent session fixation
req.session.regenerate((err) => {
if (err) return res.status(500).json({ error: 'Authentication error' });
req.session.userId = user.id;
req.session.role = user.role;
res.json({ message: 'Login successful' });
});
});
app.post('/logout', (req, res) => {
req.session.destroy(() => {
res.clearCookie('connect.sid');
res.json({ message: 'Logged out' });
});
});
✅ Strengths
- The server can revoke a session instantly — just delete it from the store.
- Nothing sensitive lives on the client; the cookie is an opaque ID.
- Excellent fit for traditional server-rendered apps.
⚠️ Costs
- Every logged-in user consumes server-side storage.
- Horizontal scaling needs a shared session store across instances.
- Cookies invite CSRF attacks, so you need
SameSiteand/or anti-CSRF tokens.
The mental model: a session cookie is like a concert wristband. You show your ticket once at the gate, get a wristband, and come and go freely — but the venue can cut the band off at any time.
Token-Based Authentication
In the token model the server holds no per-user state. After login it hands the client a signed token containing the user's identity. The client sends that token on every request, and the server trusts it because the signature proves it wasn't tampered with.
Authorization: Bearer <token> S->>S: Verify signature, decode payload S-->>U: Protected resource
The dominant token format is the JWT (JSON Web Token): three base64url segments — header, payload, signature — joined by dots. The next lesson dissects JWTs in depth; here is the shape of an Express implementation:
const jwt = require('jsonwebtoken');
const bcrypt = require('bcrypt');
const JWT_SECRET = process.env.JWT_SECRET;
app.post('/api/login', async (req, res) => {
const user = await findUserByUsername(req.body.username);
if (!user || !(await bcrypt.compare(req.body.password, user.passwordHash))) {
return res.status(401).json({ error: 'Invalid credentials' });
}
const token = jwt.sign(
{ userId: user.id, role: user.role }, // payload (claims)
JWT_SECRET,
{ expiresIn: '15m' } // keep access tokens short
);
res.json({ token });
});
// Middleware that protects routes
function authenticateToken(req, res, next) {
const header = req.headers['authorization'];
const token = header && header.split(' ')[1]; // "Bearer <token>"
if (!token) return res.status(401).json({ error: 'Authentication required' });
jwt.verify(token, JWT_SECRET, { algorithms: ['HS256'] }, (err, decoded) => {
if (err) return res.status(403).json({ error: 'Invalid or expired token' });
req.user = decoded;
next();
});
}
app.get('/api/profile', authenticateToken, (req, res) => {
res.json({ userId: req.user.userId, role: req.user.role });
});
📖 Where should the client keep a token?
localStorage is easy but readable by any script, so it's exposed to XSS. An HttpOnly cookie is safe from JavaScript but exposed to CSRF. A common compromise: keep the short-lived access token in a JavaScript variable (memory) and the long-lived refresh token in an HttpOnly, SameSite cookie.
⚠️ The catch with tokens
Because the server keeps no state, a valid token cannot be revoked before it expires — short of maintaining a blacklist, which reintroduces the very state you gave up. This is exactly why access tokens are kept short-lived and paired with refresh tokens.
Analogy: a signed token is a printed ID badge with your photo and access level. Any guard can inspect it without phoning HQ — but if you lose it, it can't be remotely switched off; you just wait for it to expire.
The Refresh-Token Pattern
The refresh-token pattern resolves the tension between "tokens should be short-lived" and "users shouldn't log in every 15 minutes." You issue two tokens: a short-lived access token used on every request, and a long-lived refresh token whose only job is to mint new access tokens.
const crypto = require('crypto');
app.post('/api/login', async (req, res) => {
const user = await validateUser(req.body.username, req.body.password);
if (!user) return res.status(401).json({ error: 'Invalid credentials' });
const accessToken = jwt.sign({ userId: user.id, role: user.role },
process.env.ACCESS_SECRET, { expiresIn: '15m' });
// Refresh token: an opaque random string, stored hashed server-side
const refreshToken = crypto.randomBytes(40).toString('hex');
await storeRefreshToken({
tokenHash: sha256(refreshToken), // never store it raw
userId: user.id,
expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
userAgent: req.headers['user-agent']
});
res.json({ accessToken, refreshToken });
});
app.post('/api/refresh', async (req, res) => {
const stored = await findRefreshToken(sha256(req.body.refreshToken));
if (!stored || new Date() > stored.expiresAt) {
return res.status(401).json({ error: 'Invalid or expired refresh token' });
}
// Rotation: invalidate the used token and issue a fresh pair
await removeRefreshToken(stored.id);
const newRefresh = crypto.randomBytes(40).toString('hex');
await storeRefreshToken({ tokenHash: sha256(newRefresh), userId: stored.userId,
expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000) });
const accessToken = jwt.sign({ userId: stored.userId },
process.env.ACCESS_SECRET, { expiresIn: '15m' });
res.json({ accessToken, refreshToken: newRefresh });
});
✅ Refresh-token hardening checklist
- Store hashed, never raw — treat it like a password.
- Rotate: issue a new refresh token every time one is used, and revoke the old one.
- Detect reuse: if a already-rotated token appears again, assume theft and revoke the whole family.
- Bind to device/IP metadata and enforce an absolute maximum lifetime.
- Provide a "log out everywhere" that clears all of a user's refresh tokens.
Analogy: your access token is a hotel key card that expires each day; the refresh token is your reservation confirmation, which lets the front desk cut you a fresh card without checking you in again.
Hands-on Exercise
🏋️ Build a minimal secure auth flow
Objective: Implement register and login endpoints that hash passwords and protect a route — the foundation every later lesson builds on.
Instructions:
- Create an Express app with
express.json()and an in-memoryusersarray (a real DB comes later). - Add
POST /registerthat hashes the password with bcrypt before storing the user. - Add
POST /loginthat looks up the user and usesbcrypt.compare. On success, respond with a short-lived JWT. - Write an
authenticateTokenmiddleware and protectGET /meso it returns the current user's id. - Test the whole flow with
curlor a REST client: register, login, then call/mewith and without the token.
💡 Hint
Registration and login share the same shape of work: validate input, then hash (register) or compare (login). For the middleware, read req.headers.authorization, split off the part after "Bearer ", and pass it to jwt.verify with an explicit algorithms option.
✅ Sample solution
const express = require('express');
const bcrypt = require('bcrypt');
const jwt = require('jsonwebtoken');
const app = express();
app.use(express.json());
const SECRET = process.env.JWT_SECRET || 'dev-only-secret';
const users = []; // { id, username, passwordHash }
app.post('/register', async (req, res) => {
const { username, password } = req.body;
if (!username || !password) return res.status(400).json({ error: 'Missing fields' });
if (users.some(u => u.username === username))
return res.status(409).json({ error: 'Username taken' });
const passwordHash = await bcrypt.hash(password, 12);
const user = { id: users.length + 1, username, passwordHash };
users.push(user);
res.status(201).json({ id: user.id, username });
});
app.post('/login', async (req, res) => {
const { username, password } = req.body;
const user = users.find(u => u.username === username);
if (!user || !(await bcrypt.compare(password, user.passwordHash)))
return res.status(401).json({ error: 'Invalid credentials' });
const token = jwt.sign({ userId: user.id }, SECRET, { expiresIn: '15m' });
res.json({ token });
});
function authenticateToken(req, res, next) {
const token = (req.headers.authorization || '').split(' ')[1];
if (!token) return res.status(401).json({ error: 'Authentication required' });
try {
req.user = jwt.verify(token, SECRET, { algorithms: ['HS256'] });
next();
} catch {
return res.status(403).json({ error: 'Invalid or expired token' });
}
}
app.get('/me', authenticateToken, (req, res) => res.json({ userId: req.user.userId }));
app.listen(3000, () => console.log('Auth demo on http://localhost:3000'));
Try it: curl -X POST localhost:3000/register -H "Content-Type: application/json" -d '{"username":"ray","password":"correct horse battery staple"}', then login, then call /me with the returned token in an Authorization: Bearer header.
Best Practices
| ✅ Do | ❌ Don't |
|---|---|
| Hash passwords with bcrypt or argon2id and a per-user salt | Store passwords in plain text or with fast hashes like MD5/SHA-256 alone |
| Serve everything over HTTPS | Send tokens or cookies over plain HTTP |
| Rate-limit login endpoints and lock accounts after repeated failures | Allow unlimited login attempts (invites brute force and credential stuffing) |
| Keep access tokens short-lived; pair with rotating refresh tokens | Issue long-lived access tokens you can't revoke |
Set HttpOnly, Secure, SameSite on session/refresh cookies | Put JWTs in localStorage without weighing the XSS risk |
| Return the same "Invalid credentials" message for bad user or bad password | Reveal whether the username exists (helps attackers enumerate accounts) |
A quick rule of thumb for choosing a model:
- Server-rendered web app? Session cookies are simple and revocable.
- SPA or mobile app talking to an API? Access token in memory + refresh token in an HttpOnly cookie.
- Service-to-service? No user is involved — use client credentials (an OAuth flow, coming soon).
- Any sensitive account? Add MFA, and prefer passkeys where you can.
// Rate-limit the login route to blunt brute-force attacks
const rateLimit = require('express-rate-limit');
const loginLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 5, // 5 attempts per window per IP
standardHeaders: true,
legacyHeaders: false,
message: { error: 'Too many login attempts. Try again in 15 minutes.' }
});
app.post('/api/login', loginLimiter, loginHandler);
🎯 Quick Quiz
Question 1: Which pair is genuine multi-factor authentication?
Question 2: What is the main drawback of stateless token-based authentication compared to server sessions?
Question 3: Why should you hash passwords with bcrypt or argon2 instead of SHA-256?
Summary & Quiz
🎉 Key Takeaways
- Authentication proves identity; authorization grants permissions. Authenticate first, then authorize.
- Passwords must always be stored as salted, slow hashes (bcrypt / argon2) — never plaintext or reversible encryption.
- Sessions keep state server-side (revocable, cookie-based); tokens are stateless (scalable, hard to revoke).
- The refresh-token pattern gives you short, safe access tokens without constant re-logins — rotate and hash the refresh token.
- MFA and social login raise security and lower friction; prefer authenticator apps or passkeys over SMS.
📚 Further Reading
🚀 What's Next?
You've seen where JWTs fit in the bigger picture. Next we go deep on JWT Authentication Implementation — dissecting the header, payload, and signature, and building a complete, production-shaped JWT system in Node.js.
🎉 Well done!
You now have a map of the whole authentication landscape. Time to build the token engine that powers most of it.
Social Login
Social authentication lets users sign in with an account they already have — Google, GitHub, Apple. Under the hood it uses OAuth 2.0 (covered two lessons from now); your app never sees the user's password, only a token and profile from the provider.
In Node, Passport.js wraps the provider-specific details behind a common strategy interface:
💡 Benefits and trade-offs
Benefits: no password for you to store or reset, lower signup friction, and you inherit the provider's security (including their MFA).
Trade-offs: a dependency on a third party, privacy considerations, and the need to handle account linking when the same person signs in with different providers or the same email.