Skip to main content

🔐 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."
flowchart LR A[User] -->|Presents credentials| B[Authentication System] B -->|Valid| C[Access granted] B -->|Invalid| D[Access denied] C -->|Session or token issued| E[Protected resources] E -->|Permissions checked| F[Authorization]

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.

FactorMeaningExamples
Something you knowSecret knowledgePassword, passphrase, PIN
Something you haveA possessed devicePhone with an authenticator app, hardware key (YubiKey), passkey
Something you areA biometric traitFingerprint, face, iris
Somewhere you areA location signalGPS, 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.

flowchart TD A[Login with password] --> B{Password valid?} B -->|No| C[Access denied] B -->|Yes| D[Request second factor] D --> E[TOTP code from authenticator app] D --> F[Push notification approval] D --> G[Passkey / hardware key] E --> H{Second factor valid?} F --> H G --> H H -->|No| C H -->|Yes| I[Access granted]

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.

flowchart LR A[Plain password] --> B[Combine with random salt] B --> C[Slow hash function
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.

sequenceDiagram participant U as Browser participant S as Server participant DB as Session store U->>S: POST /login (credentials) S->>DB: Verify user, create session DB-->>S: Session stored S-->>U: Set-Cookie: session_id (HttpOnly) Note over U,S: Subsequent requests U->>S: GET /profile (cookie sent automatically) S->>DB: Look up session_id DB-->>S: Valid, here is the user S-->>U: Protected resource

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 SameSite and/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.

sequenceDiagram participant U as Client participant S as Server U->>S: POST /login (credentials) S->>S: Verify, then sign a token S-->>U: Return token Note over U,S: Subsequent requests U->>S: GET /api/profile
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.

sequenceDiagram participant U as Client participant S as Server participant DB as Token store U->>S: Login S->>S: Issue access token (15m) S->>DB: Store refresh token (30d) S-->>U: Both tokens Note over U,S: Access token expires U->>S: Request with expired access token S-->>U: 401 Unauthorized U->>S: POST /refresh (refresh token) S->>DB: Validate + rotate refresh token DB-->>S: Valid S-->>U: New access token (+ new refresh token)
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.

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.

sequenceDiagram participant U as User participant A as Your app participant P as Social provider U->>A: Click "Sign in with Google" A->>P: Redirect to provider U->>P: Approve access P->>A: Redirect back with auth code A->>P: Exchange code for token + profile P-->>A: Access token & user info A->>A: Find or create local user A-->>U: Logged in

In Node, Passport.js wraps the provider-specific details behind a common strategy interface:

const passport = require('passport');
const GoogleStrategy = require('passport-google-oauth20').Strategy;

passport.use(new GoogleStrategy({
    clientID: process.env.GOOGLE_CLIENT_ID,
    clientSecret: process.env.GOOGLE_CLIENT_SECRET,
    callbackURL: '/auth/google/callback'
  },
  async (accessToken, refreshToken, profile, done) => {
    // Link to an existing account or create a new one
    let user = await findUserByProviderId('google', profile.id);
    if (!user) {
      user = await createUser({
        provider: 'google',
        providerId: profile.id,
        email: profile.emails[0].value,
        name: profile.displayName
      });
    }
    return done(null, user);
  }
));

app.get('/auth/google', passport.authenticate('google', { scope: ['profile', 'email'] }));
app.get('/auth/google/callback',
  passport.authenticate('google', { failureRedirect: '/login' }),
  (req, res) => res.redirect('/dashboard')
);

💡 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.

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:

  1. Create an Express app with express.json() and an in-memory users array (a real DB comes later).
  2. Add POST /register that hashes the password with bcrypt before storing the user.
  3. Add POST /login that looks up the user and uses bcrypt.compare. On success, respond with a short-lived JWT.
  4. Write an authenticateToken middleware and protect GET /me so it returns the current user's id.
  5. Test the whole flow with curl or a REST client: register, login, then call /me with 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 saltStore passwords in plain text or with fast hashes like MD5/SHA-256 alone
Serve everything over HTTPSSend tokens or cookies over plain HTTP
Rate-limit login endpoints and lock accounts after repeated failuresAllow unlimited login attempts (invites brute force and credential stuffing)
Keep access tokens short-lived; pair with rotating refresh tokensIssue long-lived access tokens you can't revoke
Set HttpOnly, Secure, SameSite on session/refresh cookiesPut JWTs in localStorage without weighing the XSS risk
Return the same "Invalid credentials" message for bad user or bad passwordReveal 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.