Lecture 9: Application Security: Authentication and Authorization¶
In CSC336 you learned to build login systems that work. This lecture asks a harder question: how do they hold up when someone is actively trying to break them? You will revisit authentication and authorization at production depth — comparing stateful sessions against stateless tokens, hardening how you store secrets and hashes, and formalizing who is allowed to do what with proper access-control models.
In This Lecture¶
- Distinguish authentication from authorization precisely, and compare stateful sessions with stateless tokens
- Implement server-side session storage backed by Redis
- Understand JWT structure, signing, expiry, and refresh tokens, and where to safely store them client-side
- Apply Role-Based Access Control (RBAC) and survey other access-control models (DAC, MAC, ABAC)
- Hash and salt passwords correctly with bcrypt, enforce password policies, and understand multi-factor authentication (MFA)
Authentication vs. Authorization, Revisited¶
Authentication answers "who are you?" — verifying an identity, typically via a password, a token, or a biometric factor. Authorization answers "what are you allowed to do?" — deciding whether an already-authenticated identity may perform a specific action on a specific resource. These are separate concerns, and conflating them is a common source of vulnerabilities: a system can authenticate a user correctly and still let them access another user's data because authorization was never checked.
Note
A useful mental model: authentication happens once (or periodically, via re-verification), producing an identity. Authorization happens on every request, checking that identity against the specific thing being requested.
Every authentication scheme needs a way to remember "this request came from an already-verified identity" without re-checking a password on every single HTTP call (HTTP itself is stateless). There are two fundamentally different strategies for this, and the rest of the lecture builds on the distinction between them:
- Stateful sessions — the server stores session data and hands the client an opaque reference (a session ID) to look it up.
- Stateless tokens — the server hands the client a signed, self-contained credential (a JWT) that carries its own claims and needs no server-side lookup to verify.
flowchart TD
A[Client sends credentials] --> B{Authentication scheme}
B -->|Stateful| C[Server creates session<br/>stores it in Redis/DB]
C --> D[Client gets opaque session ID<br/>in a cookie]
D --> E[Every request: server looks up<br/>session ID in the store]
B -->|Stateless| F[Server signs a JWT<br/>with claims embedded]
F --> G[Client stores JWT<br/>httpOnly cookie or localStorage]
G --> H[Every request: server verifies<br/>signature, no DB lookup needed]
Session-Based Authentication and Server-Side Session Stores¶
In a session-based scheme, the server is the sole source of truth. After a successful
login, the server creates a session — a record of who the user is and what state
belongs to them — and stores it server-side, keyed by a randomly generated session
ID. That ID is sent to the browser, almost always in an httpOnly cookie, and the
browser automatically re-sends it on every subsequent request. The server looks the ID
up in its session store to reconstitute "who is this."
The naive default — Express's in-memory MemoryStore — keeps sessions in the Node
process's own memory. This works for local development but fails in production for two
reasons: memory is lost on every restart or deploy, and it cannot be shared across
multiple server instances behind a load balancer. The standard production fix is a
Redis-backed session store: Redis is an in-memory key-value data store, fast enough
to look up a session on every request, and — unlike the Node process's own heap — it is
external, shared, and persists across restarts and multiple app instances.
const express = require("express");
const session = require("express-session");
const RedisStore = require("connect-redis").default;
const { createClient } = require("redis");
const app = express();
const redisClient = createClient({ url: process.env.REDIS_URL });
redisClient.connect().catch(console.error);
app.use(
session({
store: new RedisStore({ client: redisClient, prefix: "sess:" }),
secret: process.env.SESSION_SECRET, // signs the session-ID cookie
resave: false, // don't re-save unchanged sessions
saveUninitialized: false, // don't create a session until something is stored
cookie: {
httpOnly: true, // JavaScript cannot read this cookie
secure: true, // only sent over HTTPS
sameSite: "lax", // mitigates CSRF (see Lecture 11)
maxAge: 1000 * 60 * 60, // 1 hour
},
})
);
app.post("/login", async (req, res) => {
const user = await verifyCredentials(req.body.email, req.body.password);
if (!user) return res.status(401).json({ error: "Invalid credentials" });
req.session.userId = user.id; // Redis now holds this session
res.json({ message: "Logged in" });
});
Never ship MemoryStore to production
Express literally logs a warning that MemoryStore is "not designed for a
production environment" — it leaks memory, loses every session on restart, and
cannot be shared across horizontally scaled instances. Always swap in a real store
(Redis, MongoDB, PostgreSQL) before deploying.
Because the session lives entirely on the server, revocation is trivial: deleting the Redis key immediately invalidates that session everywhere, which is exactly what you want for a "log out of all devices" or "force-expire compromised sessions" feature. This is the single biggest practical advantage sessions have over tokens.
Token-Based Authentication and JWT¶
A JSON Web Token (JWT) is a compact, self-contained credential the server issues once and the client presents on every subsequent request — no server-side session lookup required. This statelessness is what makes JWTs attractive for APIs consumed by multiple client types (web, mobile, third-party) and for horizontally scaled systems where you'd rather not hit a shared store on every request.
Structure: header.payload.signature¶
A JWT is three Base64URL-encoded segments joined by dots:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI2NmZhMSIsInJvbGUiOiJhZG1pbiIsImV4cCI6MTcxOTk5OTk5OX0.4f8a1e9c2b7d...
└──────────── header ────────────┘ └───────────────── payload ─────────────────┘ └── signature ──┘
- Header — metadata: the signing algorithm (e.g.
HS256,RS256) and token type. - Payload — the claims: data about the subject, such as
sub(user ID),role,iat(issued-at), andexp(expiry). The payload is only encoded, not encrypted — anyone can decode and read it, so never put secrets or sensitive personal data in it. - Signature — computed over the header and payload using a secret (HMAC,
HS256) or a private key (asymmetric,RS256). It proves the token wasn't tampered with; it does not hide the payload's contents.
A JWT is signed, not encrypted
Anyone can paste a JWT into jwt.io and read every claim inside it. The signature only guarantees integrity (nobody altered it) — never store passwords, credit card numbers, or other secrets in the payload.
Signing, Expiry, and Refresh Tokens¶
const jwt = require("jsonwebtoken");
// Issue a short-lived access token
const accessToken = jwt.sign(
{ sub: user.id, role: user.role },
process.env.JWT_SECRET,
{ expiresIn: "15m" }
);
// Verify it on protected routes
function requireAuth(req, res, next) {
const token = req.cookies.accessToken; // see storage discussion below
try {
req.user = jwt.verify(token, process.env.JWT_SECRET);
next();
} catch (err) {
return res.status(401).json({ error: "Invalid or expired token" });
}
}
A short expiresIn limits the damage a stolen token can do, but it also means users
would be forced to re-login every 15 minutes — unacceptable for usability. The standard
fix is a refresh token: a long-lived, separately stored credential (often days or
weeks) whose only job is to obtain new access tokens. When the short-lived access
token expires, the client silently calls a /refresh endpoint with the refresh token
to get a new access token, without asking the user to log in again.
sequenceDiagram
participant C as Client
participant S as Server
C->>S: POST /login (credentials)
S-->>C: access token (15 min) + refresh token (7 days)
Note over C: Access token expires after 15 min
C->>S: GET /api/data (expired access token)
S-->>C: 401 Unauthorized
C->>S: POST /refresh (refresh token)
S->>S: Verify refresh token,<br/>check it isn't revoked
S-->>C: new access token
C->>S: GET /api/data (new access token)
S-->>C: 200 OK
Tip
Because refresh tokens are long-lived and powerful, store a record of issued
refresh tokens server-side (even in a stateless-token system) so you can revoke
them — for example, in a revoked_tokens table or a Redis set — and rotate them
(issue a new one, invalidate the old one) on every use.
Secure Token Storage: httpOnly Cookies vs. localStorage¶
Where the client keeps the JWT matters as much as how the server issues it.
httpOnly cookie |
localStorage |
|
|---|---|---|
| Readable by JavaScript | No | Yes |
| Vulnerable to XSS theft | No (script can't read it) | Yes (any injected script can read and exfiltrate it) |
| Sent automatically by browser | Yes, on every matching request | No — you must attach it manually to each request |
| Vulnerable to CSRF | Yes, unless mitigated (SameSite, anti-CSRF token) |
No (not sent automatically) |
| Works across subdomains/APIs easily | Only within cookie domain rules | Yes, attach to any request manually |
There is no universally "correct" answer — it's a genuine trade-off between two attack classes covered in depth in Lecture 11 (XSS and CSRF):
- Storing the JWT in an httpOnly cookie protects it from being stolen via
Cross-Site Scripting (a malicious script cannot read an httpOnly cookie), but exposes
the app to Cross-Site Request Forgery unless you add
SameSitecookie attributes and/or anti-CSRF tokens. - Storing the JWT in
localStorageavoids CSRF risk (it isn't sent automatically), but if an attacker manages to inject any JavaScript into your page via XSS, they can read it directly and exfiltrate it — arguably the more dangerous failure mode, since it lets the attacker impersonate the user entirely, from anywhere.
The industry-recommended default
For most applications, prefer an httpOnly, secure, SameSite=strict-or-lax
cookie for token storage, combined with rigorous XSS defenses (Lecture 11) and CSRF
protection where needed. Avoid localStorage for anything as sensitive as an
access token unless you have a specific reason (e.g. a fully separate API domain
with no cookie support) and compensating XSS controls.
Access-Control Models¶
Once you know who the user is, you need a systematic way to decide what they can do. Several formal access-control models exist:
- DAC (Discretionary Access Control) — resource owners decide who else can access their resource (e.g. file-sharing permissions you set yourself). Flexible, but hard to audit at scale.
- MAC (Mandatory Access Control) — access is governed by centrally defined security labels/classifications that individual users cannot override (common in government/military systems, e.g. "Top Secret" clearance levels).
- RBAC (Role-Based Access Control) — permissions are attached to roles
(
admin,editor,viewer), and users are assigned one or more roles. This is the model most web applications use, because it scales well: you manage a handful of roles instead of per-user permission lists. - ABAC (Attribute-Based Access Control) — access decisions are computed from
attributes of the user, the resource, and the environment (e.g. "allow if
user.department == resource.departmentANDtime is business hours"). More flexible and fine-grained than RBAC, but more complex to reason about and audit.
Implementing RBAC in Express¶
function requireRole(...allowedRoles) {
return (req, res, next) => {
if (!req.user || !allowedRoles.includes(req.user.role)) {
return res.status(403).json({ error: "Forbidden: insufficient role" });
}
next();
};
}
app.delete(
"/api/users/:id",
requireAuth,
requireRole("admin"),
async (req, res) => {
await User.findByIdAndDelete(req.params.id);
res.status(204).end();
}
);
Note
Notice the two middleware functions are separate and composed: requireAuth
establishes who the user is (authentication); requireRole decides whether
that identity may proceed (authorization). Keeping them as distinct, chainable
middleware is good practice — never merge identity verification and permission
checking into one function.
Password Hashing and Multi-Factor Authentication¶
Passwords must never be stored in plaintext or with reversible encryption — if your database is ever breached, plaintext passwords are an immediate, catastrophic compromise of every user account (and, because people reuse passwords, of accounts on other sites too). Instead, store a hash: the one-way output of a cryptographic hash function, from which the original password cannot practically be recovered.
bcrypt is the standard choice for password hashing on the web. Unlike general-purpose hash functions (SHA-256, MD5), bcrypt is deliberately slow and includes a work factor (cost/rounds) you can tune upward as hardware gets faster, keeping brute-force attacks expensive even years later. It also automatically generates and embeds a salt — random data mixed into the input before hashing — so that two users with the identical password get completely different hashes, defeating precomputed rainbow-table attacks.
const bcrypt = require("bcrypt");
async function registerUser(email, plainPassword) {
const saltRounds = 12; // higher = slower to compute = more resistant to brute force
const passwordHash = await bcrypt.hash(plainPassword, saltRounds);
await User.create({ email, passwordHash });
}
async function verifyCredentials(email, plainPassword) {
const user = await User.findOne({ email });
if (!user) return null;
const matches = await bcrypt.compare(plainPassword, user.passwordHash);
return matches ? user : null;
}
Never roll your own hashing
Never use plain SHA-256/MD5 for passwords (they're designed to be fast, which is exactly wrong for password hashing — it makes brute-forcing cheap), and never write your own salting scheme. Use a vetted library: bcrypt, scrypt, or Argon2.
Password policies reduce the odds of weak, guessable, or reused passwords: a minimum length (prefer length over forced complexity — modern guidance, e.g. NIST SP 800-63B, favors long passphrases over "must contain a symbol"), checks against known-breached password lists, and rate-limiting login attempts to slow brute-force guessing (covered further in Lecture 12).
Multi-factor authentication (MFA) requires proving identity with two or more independent factors, typically drawn from:
- Something you know — a password or PIN
- Something you have — a phone (SMS/push notification), a hardware key (FIDO2/ WebAuthn), or a Time-based One-Time Password (TOTP) app like Google Authenticator
- Something you are — a biometric factor (fingerprint, face recognition)
Even if an attacker steals a password (via phishing, a data breach, or credential stuffing), MFA blocks them from completing login without the second factor. TOTP is a common, low-friction implementation: the server and the user's authenticator app share a secret, and both independently compute a 6-digit code that changes every 30 seconds.
Tip
You don't need to implement MFA from scratch. Libraries like speakeasy or
otplib handle TOTP secret generation and verification; for WebAuthn/FIDO2,
@simplewebauthn/server handles the much more complex hardware-key protocol.
Try It Yourself¶
- Build a small Express app with Redis-backed sessions (
express-session+connect-redis). Add a/loginroute that setsreq.session.userId, a/meroute that reads it back, and a/logoutroute that callsreq.session.destroy(). Verify inredis-cli(KEYS sess:*) that a key disappears after logout. - Implement JWT access + refresh tokens: a
/loginroute issuing both, a/refreshroute that exchanges a valid refresh token for a new access token, and arequireAuthmiddleware. Then addrequireRole("admin")to one route and confirm a non-admin user gets a403.
Key Takeaways¶
- Authentication verifies identity; authorization decides what that identity may do — keep the two checks separate in your code.
- Session-based auth is stateful (server stores everything, revocation is instant); JWTs are stateless (self-contained, no lookup needed, but harder to revoke early).
- A JWT's payload is readable by anyone — the signature guarantees integrity, not secrecy. Never put secrets in it.
- Short-lived access tokens plus long-lived refresh tokens balance security with usability; store refresh-token state server-side so you can revoke it.
httpOnlycookies resist XSS token theft but need CSRF protection;localStorageavoids CSRF but is fully exposed to any successful XSS.- RBAC (roles → permissions) is the standard web access-control model; DAC, MAC, and ABAC solve the same problem with different trade-offs.
- Always hash passwords with bcrypt (or scrypt/Argon2) using a proper salt and work factor — never store plaintext or use fast general-purpose hashes.
- MFA adds a second independent proof of identity, blocking attackers who have only stolen a password.