Authentication vs Authorization
Authentication checks who you are (email + password). Authorization checks what you are allowed to do (your role). Authentication always comes first. Not logged in → 401. Logged in but not allowed → 403.
Ali is a user
Ali is an admin
Press ▶ Play or “Next step”. Arrow keys work too.
Debug view
Step through the diagram to see requests and responses.
Step through the diagram to see requests and responses.
Two separate checks happen on the request:
- Who is this? The server checks the password or token → "this is Ali".
- Can Ali do this? The server looks at Ali's role →
usercan't delete users,admincan.
The password problem
Before OAuth, if a travel app needed your Gmail contacts, it asked for your Gmail password. That gave it full access to everything, and the only way to take it back was to change your password. OAuth fixed this with tokens that have limited permissions.
Without OAuth
With OAuth
Press ▶ Play or “Next step”. Arrow keys work too.
Debug view
Step through the diagram to see requests and responses.
Step through the diagram to see requests and responses.
Why handing over your password was a disaster:
- The app can read, send and delete all your email.
- To take access back, you change your password, and every other app breaks.
- Same password on other sites? One leak exposes all of them.
How auth evolved: Passwords → MFA → OAuth → JWT → Zero Trust → Passkeys.
Password hashing
I never store the password itself. I store an Argon2 hash of it. At login, I verify what the user typed against that hash. Argon2 is slow on purpose, so if the database leaks, attackers can't guess passwords fast, the way they can with MD5 or SHA-256.
Plain text
Argon2 hash
Press ▶ Play or “Next step”. Arrow keys work too.
Debug view
Step through the diagram to see requests and responses.
Step through the diagram to see requests and responses.
Why not MD5 or SHA-256? They are built to be fast, so a GPU can try billions of guesses per second. Argon2 is built to be slow and to need a lot of memory for every guess.
Salt: Argon2 mixes a random value into each password before hashing, so two users with the same password get different hashes.
const argon2 = require('argon2')
// REGISTER: hash and store (salt + settings are saved inside the hash string)
const hash = await argon2.hash(password)
await pool.query('INSERT INTO users (email, password) VALUES ($1, $2)', [email, hash])
// LOGIN: verify
const valid = await argon2.verify(user.password, password)
if (!valid) throw new AuthError('authentication failed')MFA: multi-factor authentication
MFA means proving who you are with two different types of proof: something you know (password), something you have (phone), or something you are (fingerprint). If someone steals the password, they still can't log in without the second one.
Press ▶ Play or “Next step”. Arrow keys work too.
Debug view
Step through the diagram to see requests and responses.
| Factor | Type | Example |
|---|---|---|
| Something you know | Knowledge | Password, PIN |
| Something you have | Possession | Phone, authenticator app |
| Something you are | Inherence | Fingerprint, Face ID |
Passkeys (WebAuthn)
A passkey is a key pair on your device. The server keeps only the public key. At login the server sends a random challenge, the device signs it after Face ID, and the server checks the signature with the public key. No password is ever sent, so there's nothing to phish or leak.
Press ▶ Play or “Next step”. Arrow keys work too.
Debug view
Step through the diagram to see requests and responses.
- The private key never leaves your phone or laptop.
- The server stores only the public key, which is useless to an attacker.
- The passkey only works on the real site (
notes.app), so a fake site gets nothing.
HTTP is stateless
HTTP doesn't remember anything between requests; every request arrives like a stranger. So after login, the client must send proof on every request: either a session ID (the server remembers the user) or a JWT (the token itself says who the user is).
Press ▶ Play or “Next step”. Arrow keys work too.
Debug view
Step through the diagram to see requests and responses.
Two ways to carry that proof:
- Session (stateful): the server saves you in Redis; the browser keeps only an ID.
- JWT (stateless): the token itself carries who you are.
Session-based authentication
At login the server creates a random session ID, saves sessionId → userId in Redis, and sends the ID to the browser in a cookie. On every request the browser sends the cookie back and the server looks it up in Redis. Logout = delete the key, so access is gone instantly.
Session in server memory
Shared Redis
Press ▶ Play or “Next step”. Arrow keys work too.
Debug view
Step through the diagram to see requests and responses.
Step through the diagram to see requests and responses.
- Login → server makes a random ID
abc123. - Redis stores
abc123 → { userId: 42 }. - Browser gets cookie
sessionId=abc123. - Every request sends the cookie → Redis lookup → "user 42".
The diagram above shows why Redis must be shared when you run more than one server.
// LOGIN (after verifying the password)
const sessionId = crypto.randomUUID()
await redis.setex(`session:${sessionId}`, 1800, JSON.stringify({ userId: user.id, role: user.role }))
res.cookie('sessionId', sessionId, { httpOnly: true, secure: true, sameSite: 'lax', maxAge: 1800000 })
// PROTECTED ROUTE
const data = await redis.get(`session:${req.cookies.sessionId}`)
if (!data) return res.status(401).json({ error: 'authentication failed' })
// LOGOUT: instant revocation
await redis.del(`session:${req.cookies.sessionId}`)JWT: JSON Web Tokens
A JWT is a signed token with three parts: header, payload, signature. The payload holds the user ID, role and expiry. The server checks the signature with its secret, with no database lookup, which is why it's called stateless. The payload is only encoded, not encrypted, so anyone can read it: never put secrets in it.
JWT decoder
⚠ Anyone holding this token can read the payload below. No key was needed to decode it. It is encoded, not encrypted.
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI0MiIsInJvbGUiOiJ1c2VyIiwiZXhwIjoxNzk5OTk5OTk5fQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
{
"alg": "HS256",
"typ": "JWT"
}{
"sub": "42",
"role": "user",
"exp": 1799999999
}SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5cNot decoded: it's raw bytes from an HMAC over header.payload with a shared secret. This tool does not verify it. Decoding ≠ verifying.
- alg: HS256 — symmetric: whoever can verify can also sign.
- exp: 2027-01-15 07:59:59 UTC
Try to tamper with a JWT
The server signed this token for Ali (role: user). You're the attacker: you can read and edit the payload, but you don't know the server's secret.
HEADER.PAYLOAD.SIGNATURE
- Header: which algorithm signed it, e.g.
HS256. - Payload:
sub(user ID),role,exp(expiry time). - Signature:
HMAC-SHA256(header + "." + payload, SECRET). Change one letter of the payload and it no longer matches.
| Pros | Cons |
|---|---|
| No session storage | Hard to revoke before it expires |
| Works across many servers | A stolen token works until it expires |
| Great for microservices | Anyone can read the payload |
| Portable between services | Tokens can get large |
Many services: with HS256, every service needs the same secret to verify, so every service could also create tokens. With RS256, only the auth service has the private key to sign; the others only have the public key to verify.
// LOGIN: create a JWT
const token = jwt.sign({ sub: user.id, role: user.role }, process.env.JWT_SECRET, { expiresIn: '15m' })
// MIDDLEWARE: verify on every protected request
function authenticate(req, res, next) {
try {
const token = req.headers.authorization.split(' ')[1] // "Bearer <token>"
req.user = jwt.verify(token, process.env.JWT_SECRET)
} catch {
return res.status(401).json({ error: 'authentication failed' })
}
next()
}Sessions vs JWT
With sessions, the server stores the state in Redis and the client only carries an ID, so it's easy to revoke but needs a lookup on every request. With JWT, the token carries the state, so there's no lookup and it scales easily, but you can't cancel it before it expires.
Session (stateful)
JWT (stateless)
Press ▶ Play or “Next step”. Arrow keys work too.
Debug view
Step through the diagram to see requests and responses.
Step through the diagram to see requests and responses.
| Property | Session (stateful) | JWT (stateless) |
|---|---|---|
| Server stores state | Yes (Redis) | No |
| Client carries | Session ID only | Full token with claims |
| Revoke immediately | Yes: delete from Redis | Hard: needs a blocklist |
| Distributed systems | Needs shared Redis | Any server verifies locally |
| Microservices | Complicated | Natural fit |
| Token theft | Delete the session | Valid until expiry |
| Best for | Web apps, single region | APIs, microservices, distributed |
| Infrastructure | Redis required | Just a key |
Hybrid: short JWT + refresh token
In production I use both: a short access JWT (15 min) for fast requests with no lookup, and a long refresh token (30 days) in Redis for control. When the JWT expires, the refresh token gets a new JWT and is itself replaced (rotation). If an already-used refresh token shows up again, it was stolen, so I revoke the whole token family.
Press ▶ Play or “Next step”. Arrow keys work too.
Debug view
Step through the diagram to see requests and responses.
- Normal request: only the JWT, checked in memory.
- JWT expired: send the refresh token → Redis check → new JWT + new refresh token.
- An old refresh token shows up again: someone copied it → revoke every token from that login (the family).
- Logout: revoke the refresh token; the JWT dies within 15 minutes.
async function refresh(token) {
const record = JSON.parse(await redis.get(`rt:${token}`)) // { userId, role, familyId }
if (!record || (await redis.exists(`revoked:${record.familyId}`))) throw new AuthError('invalid')
// mark as used; NX fails if it was already used -> reuse detected
const firstUse = await redis.set(`used:${token}`, 1, 'EX', THIRTY_DAYS, 'NX')
if (!firstUse) {
await redis.set(`revoked:${record.familyId}`, 1, 'EX', THIRTY_DAYS) // revoke whole family
throw new AuthError('invalid')
}
const newToken = crypto.randomUUID()
await redis.set(`rt:${newToken}`, JSON.stringify(record), 'EX', THIRTY_DAYS) // same familyId
const accessToken = jwt.sign({ sub: record.userId, role: record.role }, SECRET, { expiresIn: '15m' })
return { accessToken, refreshToken: newToken }
}Cookies
A cookie is not an auth method. It's a small piece of browser storage that is sent automatically with every request to that site. It can carry a session ID or a JWT. I protect it with HttpOnly (JavaScript can't read it), Secure (HTTPS only) and SameSite (not sent on cross-site requests).
Cookie playground
Set-Cookie: sessionId=abc123; SameSite=Lax; Secure; HttpOnlyAn XSS script injected into notes.app runs:
> document.cookie ""
✓ HttpOnly: JavaScript can't read the cookie, so the script can't steal it.
A cookie can carry a session ID or a JWT. The mechanism is the session or the JWT; the cookie just carries it.
| Attribute | What it does |
|---|---|
HttpOnly | JavaScript can't read it. Protects against XSS stealing the token. |
Secure | Only sent over HTTPS. |
SameSite=Lax | Not sent on cross-site POSTs or background requests; sent on normal link clicks. Reduces CSRF. |
SameSite=Strict | Never sent on cross-site requests. Strictest CSRF protection. |
MaxAge | How long the cookie lives. |
Cookie vs Authorization header
- Cookie (browser apps): sent automatically. HttpOnly protects against XSS, and SameSite against CSRF.
- Authorization header (mobile, machine to machine):
Authorization: Bearer eyJ…, set manually. There's no CSRF risk because it's never sent automatically.
res.cookie('sessionId', sessionId, {
httpOnly: true, // JS can't read it
secure: true, // HTTPS only
sameSite: 'lax', // CSRF protection
maxAge: 1800000, // 30 minutes
})CSRF tokens
CSRF is when another website makes your browser send a request to my site, and the browser automatically attaches your cookie. The fix is a random CSRF token that my own frontend sends in a header. The other site can't read that token, so its fake request gets 403.
No CSRF token
With CSRF token
Press ▶ Play or “Next step”. Arrow keys work too.
Debug view
Step through the diagram to see requests and responses.
Step through the diagram to see requests and responses.
evil.com can make your browser send a request, but it can't read notes.app's pages, so it never learns the token.
SameSite=Lax already blocks most of these; the token is a second lock. Never change data with a GET request.
// at login: a random token tied to the session
req.session.csrfToken = crypto.randomBytes(32).toString('hex')
// frontend: send it back on every POST / PUT / PATCH / DELETE
fetch('/notes/7/delete', { method: 'POST', headers: { 'X-CSRF-Token': csrfToken } })
// middleware
function csrf(req, res, next) {
if (['GET', 'HEAD', 'OPTIONS'].includes(req.method)) return next()
if (req.get('X-CSRF-Token') !== req.session.csrfToken) {
return res.status(403).json({ error: 'invalid csrf token' })
}
next()
}OAuth 1.0 and OAuth 2.0
OAuth lets an app use part of my data, like my Google contacts, without my password. Google asks me to approve, then gives the app a token with only that permission, and I can revoke it any time. OAuth 2.0 is only about what the app can access, not who I am; that part is OIDC.
OAuth 1.0 solved delegation: the travel app redirects you to Google, you approve "read contacts," and the app gets a token for contacts only. You never gave your password, and you can revoke the token anytime. But it needed complex cryptographic signatures on every request.
OAuth 2.0 simplified it with bearer tokens (no per-request signatures) and different flows:
| Flow | When to use |
|---|---|
| Authorization Code (+ PKCE) | Web, mobile and browser apps: the most common and most secure |
| Client Credentials | Machine to machine, no user involved |
| Device Code | TVs, CLIs, devices with no browser |
| Implicit | Old and deprecated; replaced by Authorization Code + PKCE |
| Component | What it is |
|---|---|
| Resource Owner | You, the user |
| Client | The app that wants access (travel app) |
| Resource Server | The server holding your data (Gmail) |
| Authorization Server | The server that issues tokens (Google auth) |
Bearer token: whoever bears (carries) the token gets access. The server doesn't check who is holding it, only that it's valid. That's why a stolen bearer token is dangerous.
PKCE
PKCE protects the login code. Before redirecting, the app makes a random secret (the code_verifier) and sends only its hash to Google. When it exchanges the code for tokens, it must send the original secret. So a stolen code alone is useless.
Without PKCE
With PKCE
Press ▶ Play or “Next step”. Arrow keys work too.
Debug view
Step through the diagram to see requests and responses.
Step through the diagram to see requests and responses.
Mobile and browser apps can't hide a client secret (anyone can open the app's code), so PKCE gives them a fresh secret for every single login.
const verifier = crypto.randomBytes(32).toString('base64url') // keep secret
const challenge = crypto.createHash('sha256').update(verifier).digest('base64url')
// 1. redirect: /authorize?...&code_challenge=${challenge}&code_challenge_method=S256
// 2. exchange: POST /token ... &code=${code}&code_verifier=${verifier}OpenID Connect (OIDC)
OIDC is OAuth 2.0 plus identity. Besides the access token, Google gives an ID token, a JWT that says who the user is; that's what "Sign in with Google" uses. Google first redirects back with only a code, and my server exchanges that code for the ID token and access token.
Press ▶ Play or “Next step”. Arrow keys work too.
Debug view
Step through the diagram to see requests and responses.
- Access token → what the app can do (call Google APIs).
- ID token → who the user is (name, email). It's a JWT that your app checks:
{
"sub": "google-user-123",
"name": "Ali Ahmed",
"email": "ali@gmail.com",
"iss": "https://accounts.google.com",
"aud": "your-client-id",
"iat": 1700000000,
"exp": 1700003600
}SSO and SAML
SSO means logging in once at a central login server, the identity provider (like Okta), and then every company app lets you in without asking again. SAML is the older XML way to do it; OIDC is the newer JSON/JWT way.
Press ▶ Play or “Next step”. Arrow keys work too.
Debug view
Step through the diagram to see requests and responses.
| SAML | OIDC | |
|---|---|---|
| Format | XML assertion | JSON / JWT (ID token) |
| Age | 2005, enterprise | 2014, modern |
| Typical use | Corporate SSO (Okta, Entra ID, ADFS) | "Sign in with Google", mobile, SPAs |
| Signed by | IdP's private key (XML signature) | IdP's private key (JWT signature) |
API key authentication
An API key is a long secret string that a program sends with every request, in a header. There's no login and no session; the server looks the key up to know which client it is. It's for machine-to-machine calls. I store only a hash of the key, like a password.
Press ▶ Play or “Next step”. Arrow keys work too.
Debug view
Step through the diagram to see requests and responses.
| Use case | Best auth | Why |
|---|---|---|
| Web app login | Session or JWT | A user logs in |
| Mobile app | JWT | Stateless, portable |
| Machine to machine | API key | No user involved |
| Third-party access | OAuth 2.0 | Limited, revocable permissions |
| Sign in with Google | OIDC | Identity from Google |
async function apiKeyAuth(req, res, next) {
const key = req.headers['x-api-key']
if (!key) return res.status(401).json({ error: 'authentication failed' })
// store and look up a HASH of the key, never the raw key
const hash = crypto.createHash('sha256').update(key).digest('hex')
const { rows } = await pool.query('SELECT user_id FROM api_keys WHERE key_hash = $1 AND active = true', [hash])
if (!rows[0]) return res.status(401).json({ error: 'authentication failed' })
req.user = { id: rows[0].user_id }
next()
}Service-to-service auth (mTLS)
In normal HTTPS, only the server shows a certificate. In mutual TLS, both sides show one, so two services can prove who they are to each other without API keys. In Kubernetes, a service mesh like Istio sets this up automatically.
Normal TLS
Mutual TLS
Press ▶ Play or “Next step”. Arrow keys work too.
Debug view
Step through the diagram to see requests and responses.
Step through the diagram to see requests and responses.
const https = require('https')
https.createServer({
key: fs.readFileSync('payments.key'),
cert: fs.readFileSync('payments.crt'),
ca: fs.readFileSync('internal-ca.crt'), // trust only our internal CA
requestCert: true, // ask the caller for a certificate
rejectUnauthorized: true, // no valid certificate → handshake fails
}, app).listen(443)
app.post('/charge', (req, res) => {
const caller = req.socket.getPeerCertificate().subject.CN // "orders.internal"
if (caller !== 'orders.internal') return res.status(403).end()
// ...charge
})RBAC: role-based access control
RBAC means each user has a role (user, moderator, admin) and each role has permissions. A middleware checks the role on every request and returns 403 if it's not allowed. Before that, authentication must return 401 for a bad token, not crash with 500.
RBAC request simulator
app.delete('/notes/:id', authenticate, authorize('admin', 'moderator'), deleteNote)Pick a token, role and route, then send the request.
- user → read own notes
- moderator → read and write any note
- admin → everything, plus the dead zone
// authentication: who are you? (jwt.verify throws on a bad token, so catch it -> 401)
function authenticate(req, res, next) {
try {
const token = req.headers.authorization.split(' ')[1]
req.user = jwt.verify(token, process.env.JWT_SECRET)
} catch {
return res.status(401).json({ error: 'authentication failed' })
}
next()
}
// authorization: what can you do?
function authorize(...roles) {
return (req, res, next) => {
if (!roles.includes(req.user.role)) return res.status(403).json({ error: 'forbidden' })
next()
}
}
app.get('/admin/dead-zone', authenticate, authorize('admin'), getDeadZoneNotes)
app.delete('/notes/:id', authenticate, authorize('admin', 'moderator'), deleteNote)
app.get('/notes', authenticate, authorize('user', 'moderator', 'admin'), getNotes)Ownership checks (BOLA / IDOR)
A role check isn't enough. If Ali changes /notes/7 to /notes/8, his role check still passes, so I must also check that the note belongs to him: WHERE id = $1 AND owner_id = $2. Missing this is called BOLA or IDOR, the #1 API security bug.
Role check only
Role + ownership
Press ▶ Play or “Next step”. Arrow keys work too.
Debug view
Step through the diagram to see requests and responses.
Step through the diagram to see requests and responses.
app.get('/notes/:noteId', authenticate, authorize('user', 'moderator', 'admin'), async (req, res) => {
const canReadAny = ['moderator', 'admin'].includes(req.user.role)
const { rows } = canReadAny
? await pool.query('SELECT * FROM notes WHERE id = $1', [req.params.noteId])
: await pool.query('SELECT * FROM notes WHERE id = $1 AND owner_id = $2', [req.params.noteId, req.user.sub])
if (!rows[0]) return res.status(404).json({ error: 'not found' })
res.json(rows[0])
})Generic errors and timing attacks
On login failure I always return the same message, "authentication failed", never "user not found" or "wrong password". I also make every failure take the same time by always running argon2.verify, with a real dummy hash when the user doesn't exist. Otherwise attackers can find out which emails are registered.
Email doesn't exist
Email exists, wrong password
Press ▶ Play or “Next step”. Arrow keys work too.
Debug view
Step through the diagram to see requests and responses.
Step through the diagram to see requests and responses.
Specific messages tell attackers what to try next:
user not found→ this email doesn't existincorrect password→ the email exists, now guess the passwordaccount locked→ the email exists
Always return 401 { "error": "authentication failed" }.
// FIX 1: always hash. The dummy must be a REAL argon2 hash, computed once at startup.
// (A fake string can make argon2.verify throw or return early, and the timing gap comes back.)
const dummyHashPromise = argon2.hash('dummy-password')
const hash = user ? user.password : await dummyHashPromise
const valid = await argon2.verify(hash, password)
if (!user || !valid) return res.status(401).json({ error: 'authentication failed' })
// FIX 2: constant-time comparison for secret strings (hash first so lengths always match)
const a = crypto.createHash('sha256').update(expected).digest()
const b = crypto.createHash('sha256').update(received).digest()
const match = crypto.timingSafeEqual(a, b)Password reset
The user enters an email and I always reply with the same message, whether the account exists or not. If it exists, I email a random one-time link that expires in 15 minutes, and I store only a hash of that token. After the password changes, I delete the token and log the user out everywhere.
Press ▶ Play or “Next step”. Arrow keys work too.
Debug view
Step through the diagram to see requests and responses.
app.post('/forgot', async (req, res) => {
const user = await findUserByEmail(req.body.email)
if (user) {
const token = crypto.randomBytes(32).toString('base64url')
await redis.set(`reset:${sha256(token)}`, user.id, 'EX', 900) // 15 min, stored hashed
queue.add('send-reset-email', { to: user.email, token }) // in the background: no timing difference
}
res.json({ message: 'If an account exists, we sent a reset link.' }) // always the same
})
app.post('/reset', async (req, res) => {
const key = `reset:${sha256(req.body.token)}`
const userId = await redis.get(key)
if (!userId) return res.status(400).json({ error: 'invalid or expired link' })
await redis.del(key) // single use
await pool.query('UPDATE users SET password = $1 WHERE id = $2', [await argon2.hash(req.body.password), userId])
await revokeAllSessions(userId) // log out everywhere
res.json({ message: 'Password updated. Please log in.' })
})Quiz
Think of the answer first, then reveal it.
- Q1 · concept
What is the difference between authentication and authorization?
- Q2 · concept
Why never store plain-text passwords, and why Argon2 instead of SHA-256?
- Q3 · concept
What makes a JWT stateless, and is its payload encrypted?
- Q4 · concept
How do you revoke a JWT immediately? What is the hybrid approach?
- Q5 · concept
What is the difference between a session and a cookie? What does HttpOnly do?
- Q6 · concept
What is the difference between OAuth 2.0 and OIDC? What does Google send back in the redirect?
- Q7 · concept
What is refresh token rotation, and how is reuse detected?
- Q8 · concept
What is RBAC? What happens if
authenticatecallsjwt.verifywithout try/catch? - Q9 · concept
What is a timing attack on login, and how do you prevent it?
- Q10 · scenario
A user logs out but their JWT is still valid for 10 minutes. Is this a problem?
- Q11 · scenario
Your app runs on five servers in three regions and uses microservices. Sessions or JWT? HS256 or RS256?
- Q12 · scenario
Your login endpoint is being brute-forced, and a user reports someone else is using their account. What do you do?
- Q13 · concept
What is CSRF, and how does a CSRF token stop it?
- Q14 · scenario
A malicious app on the phone intercepts the authorization code in your mobile login. How does PKCE save you?
- Q15 · scenario
Ali changes
/notes/7to/notes/8and sees Sara's note, even though your RBAC check passed. What's the bug and the fix?