Authentication vs Authorization
These two terms are frequently confused. Understanding the distinction is fundamental.
+--------------------+---------------------------------------------+
| Authentication | Authorization |
+--------------------+---------------------------------------------+
| WHO are you? | WHAT can you do? |
| Verifies identity | Verifies permissions |
| Login, credentials | Roles, policies, access control |
| Happens first | Happens after authentication |
| "Prove you are | "Are you allowed to |
| who you claim" | access this resource?" |
+--------------------+---------------------------------------------+
User --> [Authentication] --> Identity confirmed
|
v
[Authorization] --> Access granted/denied
Authentication answers "who are you?" through credentials — something you know (password), something you have (phone, hardware key), or something you are (biometrics). Authorization happens after and determines what the authenticated user is permitted to do.
This article focuses on authentication. See the Authorization Patterns article for the other half.
Password Hashing Overview
Never store passwords in plain text. Never store them encrypted (encryption is reversible). Always store them hashed with a password-specific hashing algorithm.
Why Not Regular Hash Functions?
General-purpose hash functions (SHA-256, MD5) are designed to be fast. This is a problem for password hashing — fast hashing enables fast brute-force attacks.
+------------------+------------------+-----------------------------------+
| Algorithm | Speed | Suitability for Passwords |
+------------------+------------------+-----------------------------------+
| MD5 | ~10 billion/sec | Never — broken, too fast |
| SHA-256 | ~1 billion/sec | Never — too fast |
| bcrypt | ~25,000/sec | Good — intentionally slow |
| scrypt | Configurable | Good — memory-hard |
| Argon2id | Configurable | Best — memory-hard, GPU resistant |
+------------------+------------------+-----------------------------------+
Hashing with bcrypt
const bcrypt = require('bcrypt');
// Hash a password (registration)
async function hashPassword(plaintext) {
const saltRounds = 12; // Cost factor — 2^12 iterations
const hash = await bcrypt.hash(plaintext, saltRounds);
return hash;
// Result: $2b$12$LJ3m4ys3Lg5Rfz7mJQhOae6EYjqMHx.dpUJqMMkYjnL1MoZxVkS8u
}
// Verify a password (login)
async function verifyPassword(plaintext, storedHash) {
const isMatch = await bcrypt.compare(plaintext, storedHash);
return isMatch;
}
// Usage in Express
app.post('/api/register', async (req, res) => {
const { email, password } = req.body;
// Validate password strength first
if (password.length < 8) {
return res.status(400).json({ error: 'Password too short' });
}
const hash = await hashPassword(password);
await db.users.create({ email, passwordHash: hash });
res.json({ success: true });
});
app.post('/api/login', async (req, res) => {
const { email, password } = req.body;
const user = await db.users.findByEmail(email);
if (!user) {
// Use same error message to prevent user enumeration
return res.status(401).json({ error: 'Invalid credentials' });
}
const valid = await verifyPassword(password, user.passwordHash);
if (!valid) {
return res.status(401).json({ error: 'Invalid credentials' });
}
// Create session or token
req.session.userId = user.id;
res.json({ success: true });
});
Key detail: the error message for "user not found" and "wrong password" must be identical. Different messages let attackers enumerate valid email addresses.
Hashing with Argon2
Argon2id is the current recommendation from the Password Hashing Competition. It is resistant to both GPU-based and side-channel attacks.
const argon2 = require('argon2');
async function hashPassword(plaintext) {
const hash = await argon2.hash(plaintext, {
type: argon2.argon2id, // Argon2id variant
memoryCost: 65536, // 64 MB memory
timeCost: 3, // 3 iterations
parallelism: 4, // 4 threads
});
return hash;
}
async function verifyPassword(plaintext, storedHash) {
return argon2.verify(storedHash, plaintext);
}
Multi-Factor Authentication (MFA)
MFA requires users to provide two or more verification factors from different categories:
+-------------------+---------------------+---------------------------+
| Factor Type | "Something you..." | Examples |
+-------------------+---------------------+---------------------------+
| Knowledge | Know | Password, PIN, security Q |
| Possession | Have | Phone, hardware key, card |
| Inherence | Are | Fingerprint, face, voice |
+-------------------+---------------------+---------------------------+
TOTP (Time-based One-Time Password)
The most common MFA method. Apps like Google Authenticator and Authy generate 6-digit codes that change every 30 seconds.
const speakeasy = require('speakeasy');
const QRCode = require('qrcode');
// Step 1: Generate secret for user
function generateMfaSecret(userEmail) {
const secret = speakeasy.generateSecret({
name: `MyApp (${userEmail})`,
issuer: 'MyApp',
length: 32,
});
return {
base32: secret.base32, // Store this in database
otpauthUrl: secret.otpauth_url, // Generate QR from this
};
}
// Step 2: Generate QR code for user to scan
async function generateQrCode(otpauthUrl) {
return QRCode.toDataURL(otpauthUrl);
}
// Step 3: Verify TOTP code
function verifyTotp(token, secret) {
return speakeasy.totp.verify({
secret: secret,
encoding: 'base32',
token: token,
window: 1, // Allow 1 step before/after (30 sec tolerance)
});
}
// MFA setup flow
app.post('/api/mfa/setup', async (req, res) => {
const user = await getAuthenticatedUser(req);
const { base32, otpauthUrl } = generateMfaSecret(user.email);
const qrCode = await generateQrCode(otpauthUrl);
// Store secret temporarily — confirm after user verifies
await db.users.update(user.id, { mfaPendingSecret: base32 });
res.json({ qrCode, manualCode: base32 });
});
app.post('/api/mfa/verify-setup', async (req, res) => {
const user = await getAuthenticatedUser(req);
const { token } = req.body;
const isValid = verifyTotp(token, user.mfaPendingSecret);
if (!isValid) {
return res.status(400).json({ error: 'Invalid code — try again' });
}
// Activate MFA
await db.users.update(user.id, {
mfaSecret: user.mfaPendingSecret,
mfaEnabled: true,
mfaPendingSecret: null,
});
// Generate backup codes
const backupCodes = generateBackupCodes();
await db.users.update(user.id, { backupCodes });
res.json({ success: true, backupCodes });
});
// Login flow with MFA
app.post('/api/login', async (req, res) => {
const { email, password, mfaToken } = req.body;
const user = await db.users.findByEmail(email);
if (!user || !(await verifyPassword(password, user.passwordHash))) {
return res.status(401).json({ error: 'Invalid credentials' });
}
if (user.mfaEnabled) {
if (!mfaToken) {
// Password correct, but MFA required
return res.status(200).json({ requiresMfa: true });
}
if (!verifyTotp(mfaToken, user.mfaSecret)) {
return res.status(401).json({ error: 'Invalid MFA code' });
}
}
req.session.userId = user.id;
res.json({ success: true });
});
Backup Codes
Users can lose access to their MFA device. Always provide one-time backup codes:
const crypto = require('crypto');
const bcrypt = require('bcrypt');
function generateBackupCodes(count = 10) {
return Array.from({ length: count }, () =>
crypto.randomBytes(4).toString('hex').toUpperCase()
);
// Returns: ['A1B2C3D4', 'E5F6A7B8', ...]
}
async function storeBackupCodes(userId, codes) {
// Hash each code before storing
const hashed = await Promise.all(
codes.map(code => bcrypt.hash(code, 10))
);
await db.users.update(userId, { backupCodes: hashed });
}
async function verifyBackupCode(userId, submittedCode) {
const user = await db.users.findById(userId);
for (let i = 0; i < user.backupCodes.length; i++) {
if (await bcrypt.compare(submittedCode, user.backupCodes[i])) {
// Remove used code
user.backupCodes.splice(i, 1);
await db.users.update(userId, { backupCodes: user.backupCodes });
return true;
}
}
return false;
}
JWT Tokens (JSON Web Tokens)
JWTs are self-contained tokens that encode user identity and claims. They are widely used in stateless authentication.
JWT Structure
Header Payload Signature
------ ------- ---------
{ { HMACSHA256(
"alg":"HS256", "sub":"user123", base64(header) + "." +
"typ":"JWT" "email":"[email protected]", base64(payload),
} "role":"admin", secret
"iat":1714000000, )
"exp":1714003600
}
Encoded: xxxxx.yyyyy.zzzzz
header.payload.signature
Creating and Verifying JWTs
const jwt = require('jsonwebtoken');
const JWT_SECRET = process.env.JWT_SECRET; // At least 256 bits
const ACCESS_TOKEN_EXPIRY = '15m';
const REFRESH_TOKEN_EXPIRY = '7d';
// Generate tokens
function generateTokens(user) {
const accessToken = jwt.sign(
{
sub: user.id,
email: user.email,
role: user.role,
},
JWT_SECRET,
{ expiresIn: ACCESS_TOKEN_EXPIRY }
);
const refreshToken = jwt.sign(
{ sub: user.id, type: 'refresh' },
JWT_SECRET,
{ expiresIn: REFRESH_TOKEN_EXPIRY }
);
return { accessToken, refreshToken };
}
// Verify access token middleware
function authenticateToken(req, res, next) {
const authHeader = req.headers.authorization;
const token = authHeader && authHeader.split(' ')[1]; // "Bearer <token>"
if (!token) {
return res.status(401).json({ error: 'No token provided' });
}
try {
const payload = jwt.verify(token, JWT_SECRET);
req.user = payload;
next();
} catch (err) {
if (err.name === 'TokenExpiredError') {
return res.status(401).json({ error: 'Token expired' });
}
return res.status(403).json({ error: 'Invalid token' });
}
}
// Refresh token endpoint
app.post('/api/auth/refresh', async (req, res) => {
const { refreshToken } = req.body;
if (!refreshToken) {
return res.status(401).json({ error: 'Refresh token required' });
}
try {
const payload = jwt.verify(refreshToken, JWT_SECRET);
if (payload.type !== 'refresh') {
return res.status(403).json({ error: 'Invalid token type' });
}
// Check if refresh token is in the allowlist (revocation support)
const isValid = await db.refreshTokens.exists(refreshToken);
if (!isValid) {
return res.status(403).json({ error: 'Token revoked' });
}
const user = await db.users.findById(payload.sub);
const tokens = generateTokens(user);
// Rotate refresh token
await db.refreshTokens.delete(refreshToken);
await db.refreshTokens.create(tokens.refreshToken, user.id);
res.json(tokens);
} catch (err) {
return res.status(403).json({ error: 'Invalid refresh token' });
}
});
JWT Security Considerations
+-------------------------------+------------------------------------------+
| Risk | Mitigation |
+-------------------------------+------------------------------------------+
| Token theft | Short expiry (15 min), httpOnly cookies |
| Algorithm confusion (none) | Always specify algorithm in verify() |
| Weak secret | Use 256+ bit random secret |
| No revocation | Refresh token rotation, deny-list |
| Payload readable | Never store sensitive data in payload |
| Token in localStorage | Prefer httpOnly cookies for storage |
+-------------------------------+------------------------------------------+
Algorithm confusion attack prevention:
// VULNERABLE — attacker can set alg: "none"
const decoded = jwt.verify(token, secret);
// SAFE — explicitly specify allowed algorithms
const decoded = jwt.verify(token, secret, {
algorithms: ['HS256'], // Only accept HS256
});
OAuth 2.0 and OpenID Connect
OAuth 2.0 is an authorization framework that lets users grant third-party apps limited access to their resources without sharing passwords. OpenID Connect (OIDC) adds an identity layer on top.
OAuth 2.0 Authorization Code Flow (with PKCE):
User Frontend App Auth Server Resource Server
---- ------------ ----------- ---------------
| | | |
|-- Click Login ->| | |
| | | |
| |-- Redirect ------>| |
| | /authorize? | |
| | response_type= | |
| | code& | |
| | code_challenge= | |
| | XXXX | |
| | | |
|<------- Login form from auth ------| |
| | |
|-- Enter credentials ------------->| |
| | |
|<-- Redirect to app with code -----| |
| /callback?code=AUTH_CODE | |
| | | |
| |-- POST /token --->| |
| | code=AUTH_CODE | |
| | code_verifier= | |
| | YYYY | |
| | | |
| |<-- access_token --| |
| | refresh_token | |
| | id_token | |
| | | |
| |-- GET /api/data --|-------------------->|
| | Authorization: | |
| | Bearer <token> | |
| | | |
| |<-- Data ----------|---------------------|
Implementing OAuth 2.0 with PKCE (Frontend)
PKCE (Proof Key for Code Exchange) prevents authorization code interception. It is required for public clients (SPAs, mobile apps).
// Generate PKCE challenge
async function generatePKCE() {
const codeVerifier = generateRandomString(128);
const encoder = new TextEncoder();
const data = encoder.encode(codeVerifier);
const digest = await crypto.subtle.digest('SHA-256', data);
const codeChallenge = base64UrlEncode(digest);
return { codeVerifier, codeChallenge };
}
function generateRandomString(length) {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~';
const values = crypto.getRandomValues(new Uint8Array(length));
return Array.from(values, v => chars[v % chars.length]).join('');
}
function base64UrlEncode(buffer) {
return btoa(String.fromCharCode(...new Uint8Array(buffer)))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '');
}
// Step 1: Redirect user to authorization server
async function startOAuthFlow() {
const { codeVerifier, codeChallenge } = await generatePKCE();
// Store verifier for later
sessionStorage.setItem('pkce_verifier', codeVerifier);
const params = new URLSearchParams({
response_type: 'code',
client_id: 'your-client-id',
redirect_uri: 'https://yourapp.com/callback',
scope: 'openid profile email',
state: generateRandomString(32),
code_challenge: codeChallenge,
code_challenge_method: 'S256',
});
window.location.href = `https://auth.provider.com/authorize?${params}`;
}
// Step 2: Handle callback — exchange code for tokens
async function handleOAuthCallback() {
const params = new URLSearchParams(window.location.search);
const code = params.get('code');
const codeVerifier = sessionStorage.getItem('pkce_verifier');
const response = await fetch('https://auth.provider.com/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'authorization_code',
code: code,
redirect_uri: 'https://yourapp.com/callback',
client_id: 'your-client-id',
code_verifier: codeVerifier,
}),
});
const tokens = await response.json();
// tokens = { access_token, refresh_token, id_token }
sessionStorage.removeItem('pkce_verifier');
return tokens;
}
Session-Based vs Stateless Authentication
+---------------------+----------------------------+----------------------------+
| | Session-Based | Stateless (JWT) |
+---------------------+----------------------------+----------------------------+
| Token type | Opaque session ID | Self-contained JWT |
| State stored on | Server (DB/Redis) | Client (token payload) |
| Scalability | Requires shared session | No shared state needed |
| | store across servers | |
| Revocation | Delete session from store | Must use deny-list or |
| | | wait for expiry |
| Size | Small (session ID only) | Larger (encoded claims) |
| Server load | DB lookup per request | CPU for signature verify |
| Best for | Traditional web apps | APIs, microservices |
+---------------------+----------------------------+----------------------------+
Session-Based Auth Implementation
const express = require('express');
const session = require('express-session');
const RedisStore = require('connect-redis').default;
const redis = require('redis');
const redisClient = redis.createClient({ url: process.env.REDIS_URL });
const app = express();
app.use(session({
store: new RedisStore({ client: redisClient }),
name: 'sid', // Cookie name (avoid 'connect.sid')
secret: process.env.SESSION_SECRET,
resave: false,
saveUninitialized: false,
cookie: {
httpOnly: true,
secure: true,
sameSite: 'strict',
maxAge: 3600 * 1000, // 1 hour
domain: '.yourapp.com',
},
}));
// Login
app.post('/api/login', async (req, res) => {
const { email, password } = req.body;
const user = await authenticateUser(email, password);
if (!user) {
return res.status(401).json({ error: 'Invalid credentials' });
}
// Regenerate session ID to prevent session fixation
req.session.regenerate((err) => {
if (err) return res.status(500).json({ error: 'Session error' });
req.session.userId = user.id;
req.session.role = user.role;
res.json({ success: true, user: { id: user.id, email: user.email } });
});
});
// Logout
app.post('/api/logout', (req, res) => {
req.session.destroy((err) => {
if (err) return res.status(500).json({ error: 'Logout failed' });
res.clearCookie('sid');
res.json({ success: true });
});
});
// Auth middleware
function requireAuth(req, res, next) {
if (!req.session.userId) {
return res.status(401).json({ error: 'Not authenticated' });
}
next();
}
Frontend Auth Token Storage
Where you store authentication tokens has major security implications.
+--------------------+-------------------+------------------+------------------+
| Storage | XSS Accessible? | CSRF Risk? | Recommendation |
+--------------------+-------------------+------------------+------------------+
| localStorage | Yes | No | Avoid for auth |
| sessionStorage | Yes | No | Avoid for auth |
| Cookie (httpOnly) | No | Yes (mitigatable) | Best option |
| Cookie (non-http) | Yes | Yes | Worst option |
| In-memory variable | No (unless XSS) | No | Good for SPAs |
+--------------------+-------------------+------------------+------------------+
Recommended: HttpOnly Cookie
// Server sets token in httpOnly cookie
app.post('/api/login', async (req, res) => {
const user = await authenticateUser(req.body.email, req.body.password);
const { accessToken, refreshToken } = generateTokens(user);
// Access token — short-lived
res.cookie('access_token', accessToken, {
httpOnly: true,
secure: true,
sameSite: 'strict',
maxAge: 15 * 60 * 1000, // 15 minutes
path: '/',
});
// Refresh token — longer-lived, restricted path
res.cookie('refresh_token', refreshToken, {
httpOnly: true,
secure: true,
sameSite: 'strict',
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
path: '/api/auth/refresh', // Only sent to refresh endpoint
});
res.json({ user: { id: user.id, email: user.email, role: user.role } });
});
// Server reads token from cookie
function authenticateRequest(req, res, next) {
const token = req.cookies.access_token;
if (!token) {
return res.status(401).json({ error: 'Not authenticated' });
}
try {
req.user = jwt.verify(token, JWT_SECRET, { algorithms: ['HS256'] });
next();
} catch {
return res.status(401).json({ error: 'Invalid token' });
}
}
In-Memory Storage for SPAs
For single-page applications, storing tokens in a JavaScript variable (module-scoped) provides XSS protection because the variable is not accessible from the DOM. However, the token is lost on page refresh.
// auth.js — Module-scoped token (not accessible from console/DOM)
let accessToken = null;
export function setAccessToken(token) {
accessToken = token;
}
export function getAccessToken() {
return accessToken;
}
// Use refresh token (in httpOnly cookie) to restore session on page load
export async function restoreSession() {
try {
const response = await fetch('/api/auth/refresh', {
method: 'POST',
credentials: 'include', // Send httpOnly refresh cookie
});
if (response.ok) {
const { accessToken: newToken } = await response.json();
setAccessToken(newToken);
return true;
}
} catch {
return false;
}
return false;
}
// Attach token to API requests
export async function authenticatedFetch(url, options = {}) {
const token = getAccessToken();
const response = await fetch(url, {
...options,
headers: {
...options.headers,
Authorization: `Bearer ${token}`,
},
});
// If 401, try refreshing
if (response.status === 401) {
const restored = await restoreSession();
if (restored) {
return fetch(url, {
...options,
headers: {
...options.headers,
Authorization: `Bearer ${getAccessToken()}`,
},
});
}
}
return response;
}
Secure Login Form (React)
import { useState, FormEvent } from 'react';
type LoginFormState = {
email: string;
password: string;
mfaToken: string;
error: string | null;
loading: boolean;
requiresMfa: boolean;
};
export function LoginForm() {
const [state, setState] = useState<LoginFormState>({
email: '',
password: '',
mfaToken: '',
error: null,
loading: false,
requiresMfa: false,
});
async function handleSubmit(e: FormEvent) {
e.preventDefault();
setState(s => ({ ...s, loading: true, error: null }));
try {
const response = await fetch('/api/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({
email: state.email,
password: state.password,
...(state.requiresMfa ? { mfaToken: state.mfaToken } : {}),
}),
});
const data = await response.json();
if (data.requiresMfa) {
setState(s => ({ ...s, requiresMfa: true, loading: false }));
return;
}
if (!response.ok) {
setState(s => ({ ...s, error: data.error, loading: false }));
return;
}
// Success — redirect or update app state
window.location.href = '/dashboard';
} catch {
setState(s => ({
...s,
error: 'Network error — try again',
loading: false,
}));
}
}
return (
<form onSubmit={handleSubmit} noValidate>
{state.error && <div role="alert">{state.error}</div>}
<label htmlFor="email">Email</label>
<input
id="email"
type="email"
value={state.email}
onChange={e => setState(s => ({ ...s, email: e.target.value }))}
autoComplete="email"
required
/>
<label htmlFor="password">Password</label>
<input
id="password"
type="password"
value={state.password}
onChange={e => setState(s => ({ ...s, password: e.target.value }))}
autoComplete="current-password"
required
/>
{state.requiresMfa && (
<>
<label htmlFor="mfaToken">Authentication Code</label>
<input
id="mfaToken"
type="text"
inputMode="numeric"
pattern="[0-9]{6}"
maxLength={6}
value={state.mfaToken}
onChange={e => setState(s => ({ ...s, mfaToken: e.target.value }))}
autoComplete="one-time-code"
required
/>
</>
)}
<button type="submit" disabled={state.loading}>
{state.loading ? 'Signing in...' : 'Sign In'}
</button>
</form>
);
}
Authentication Security Checklist
+------+----------------------------------------------+----------+
| # | Check | Status |
+------+----------------------------------------------+----------+
| 1 | Passwords hashed with bcrypt/argon2id | [ ] |
| 2 | Salt rounds >= 12 (bcrypt) or equivalent | [ ] |
| 3 | Same error for wrong user and wrong password | [ ] |
| 4 | Account lockout after N failed attempts | [ ] |
| 5 | MFA available (TOTP + backup codes) | [ ] |
| 6 | JWT secret >= 256 bits | [ ] |
| 7 | JWT algorithm explicitly set in verify() | [ ] |
| 8 | Access tokens short-lived (15 min) | [ ] |
| 9 | Refresh tokens rotated on use | [ ] |
| 10 | Tokens stored in httpOnly cookies | [ ] |
| 11 | Session IDs regenerated after login | [ ] |
| 12 | HTTPS enforced for all auth endpoints | [ ] |
| 13 | Password reset tokens single-use and expiring | [ ] |
| 14 | Rate limiting on login endpoint | [ ] |
| 15 | OAuth PKCE used for public clients | [ ] |
| 16 | CORS restricted on auth endpoints | [ ] |
+------+----------------------------------------------+----------+
Summary
Authentication is the gateway to your application. Mistakes here cascade into every other security domain.
Key takeaways:
- Hash passwords with Argon2id or bcrypt — never SHA-256, never plaintext.
- Offer MFA — TOTP with backup codes is the minimum bar.
- JWT access tokens should be short-lived (15 min). Use refresh token rotation for longer sessions.
- Store tokens in httpOnly cookies — not localStorage.
- Session-based auth is simpler to revoke; JWT is simpler to scale. Choose based on your architecture.
- OAuth 2.0 with PKCE is mandatory for public clients (SPAs, mobile).
- Prevent user enumeration — same error message for "user not found" and "wrong password."
- Rate-limit login endpoints — prevent brute-force attacks.
Authentication is the first lock on the door. Make it strong before moving to authorization.