Authentication and Authorization, Explained: The Interview-Ready Deep Dive
A beginner-to-advanced guide to authentication and authorization: password hashing, sessions vs JWTs, access and refresh tokens, OAuth 2.1, and passkeys.
This guide explains how modern web authentication and authorization really work, from "what is a password hash" all the way up to OAuth 2.1, refresh-token rotation, and passkeys. It is written for someone preparing for technical interviews who wants to understand the machinery, not memorize buzzwords, and every section builds toward being able to answer real interview questions out loud. By the end you should be able to whiteboard a login system, reason about token storage trade-offs, and explain why OAuth exists in the first place.
The one mental model: the bouncer, the wristband, and the valet
Almost every confusion in this topic clears up if you keep three characters in mind. Picture a nightclub.
- The bouncer at the door checks your ID. That is authentication: proving who you are.
- The wristband you get after the bouncer is happy says which areas you can enter (VIP, bar, dance floor). That is authorization: deciding what you are allowed to do. The wristband is your access token.
- The coat check holds a re-entry pass so you can step outside and come back without showing ID again. That is your refresh token.
- The valet can fetch your car without being you and without holding your house keys. That is OAuth: letting one service act on your behalf with a limited, revocable ticket instead of your actual credentials.
Analogy. Authentication is the bouncer checking your ID. Authorization is the colour of your wristband. The wristband works everywhere inside the club without the staff phoning the front door, because it is trusted on sight. Hold this picture; the whole guide hangs off it.
We will refer back to the bouncer, the wristband, the coat check, and the valet throughout.
A gentle first pass: logging in, in plain English
Before any code, here is the whole story of logging into a normal website, told plainly. Every technical term in the rest of the guide is just a zoom-in on one of these steps.
You type your email and password and hit "Log in." The site does not compare your password to a stored copy, because it never kept one. Instead it kept a scrambled fingerprint of your password (a hash), runs the same scrambling on what you just typed, and checks the two fingerprints match. That is authentication: the bouncer confirming you are you.
Now the site needs to remember you are logged in, because the web forgets you between every click. So it hands your browser a small pass. That pass is either a session ID (a meaningless ticket number the server looks up in its own records) or a token (a tamper-proof card that carries your details on its face). On every later click your browser shows the pass, and the site trusts it instead of asking for your password again. That is your wristband.
When you click "Delete account" or "View billing," the site looks at your pass, sees your role, and decides whether to allow it. That is authorization: checking the wristband colour.
Your wristband is deliberately set to expire soon (often 15 minutes) so a stolen one is useless quickly. To avoid logging you out constantly, the site also gave you a longer-lived re-entry pass (a refresh token) that quietly fetches a fresh wristband in the background. You never notice this happening.
Finally, when you click "Connect Google Calendar," the site does not ask for your Google password. It bounces you to Google, you approve, and Google hands the site a limited ticket that can read your calendar and nothing else. That is OAuth: the valet.
Analogy. That entire paragraph is one night at the club: get your ID checked (authentication), receive a wristband (token/session), get waved into rooms your band allows (authorization), swap your band for a fresh one at coat check (refresh), and hand a valet a ticket to fetch one specific thing (OAuth). The rest of this guide is just the close-up on each moment.
Now we zoom in, starting with the two words people mix up most.
Authentication vs authorization: the two questions
These two words get used interchangeably in casual speech, and getting them straight is the single most common thing an interviewer checks first.
Authentication (often shortened to AuthN, where the "N" stands for the n in authentication) answers "Who are you?" It is the act of verifying an identity: matching a password, validating a token, checking a fingerprint.
Authorization (shortened to AuthZ, the "Z" standing for the z sound in authoriZation) answers "What are you allowed to do?" It is the act of deciding whether an already-identified user may perform an action: read this file, delete that record, enter the admin panel.
Analogy. AuthN is the bouncer confirming the photo on your ID is you. AuthZ is the wristband saying you may enter the VIP lounge. You can be authenticated (the club knows exactly who you are) and still not be authorized (no VIP band, so the rope stays up).
Gotcha. "Login" is not a synonym for authentication. A login flow usually does both: it authenticates you (checks the password), then authorizes you (loads your roles and issues a token that encodes them). Saying "auth" without specifying which one is the verbal tell of someone who has not thought it through.
Request comes in
|
v
[ AuthN ] Who are you? -> verify identity (password, token, passkey)
| identity established
v
[ AuthZ ] What may you do? -> check roles/permissions/scopes for THIS action
| permitted
v
Handler runsInterview answer: "Authentication is verifying who the user is; authorization is deciding what that verified user is allowed to do. They run in that order: you authenticate once, then authorize on every protected action. A 401 status means 'I do not know who you are,' a 403 means 'I know who you are and you still cannot do this.'"
That last sentence about status codes is worth memorizing, because it is a frequent trap. 401 Unauthorized is poorly named: it means unauthenticated. 403 Forbidden means authenticated but not authorized.
How passwords are stored (and why almost everyone gets this wrong)
Before tokens and OAuth, we need the foundation: the password. The interviewer wants to hear that you never store passwords in a form anyone can read.
You do not store the password. You store a hash of it. A hash function is a one-way mathematical function: easy to run forwards (password in, fixed-length scramble out), practically impossible to run backwards (scramble in, password out). When the user logs in, you hash what they typed and compare it to the stored hash. The plaintext is never kept.
Analogy. A hash is like grinding a steak into a precise, reproducible pattern of mince. The same steak always grinds to the same mince, so you can check a steak by grinding it and comparing. But no one can rebuild the original steak from the mince.
Two more pieces are mandatory:
- A salt is a unique random value added to each password before hashing. It means two users with the same password get different hashes, which defeats precomputed lookup tables (called rainbow tables). Modern hashing libraries generate and store the salt for you automatically.
- A pepper is an additional secret value shared across all passwords, kept outside the database (in a secrets manager or environment variable). If an attacker steals only the database, they still lack the pepper.
Beginner trap. Fast, general-purpose hashes like SHA-256 (Secure Hash Algorithm, 256-bit) and MD5 (Message Digest 5) are the wrong tool for passwords. They are designed to be fast, which is exactly what you do not want: a modern GPU can try billions of SHA-256 guesses per second. Password hashing needs a deliberately slow, memory-hungry algorithm.
The current OWASP (Open Web Application Security Project) Password Storage Cheat Sheet recommends, in order of preference:
| Algorithm | Type | OWASP-recommended parameters (verified, 2026) | Use it when |
|---|---|---|---|
| Argon2id | Memory-hard, RFC 9106 | Minimum 19 MiB memory, 2 iterations, 1 degree of parallelism; a common production profile is 64 MiB, 3 iterations, parallelism 1 | New applications, default first choice |
| scrypt | Memory-hard | Cost N = 2^17, block size r = 8, parallelism p = 1 (roughly 128 MiB) | Argon2id binding unavailable on your runtime |
| bcrypt | CPU-hard, since 1999 | Work factor (cost) of 10 minimum; 12 to 13 in 2026; hard 72-byte input limit | Legacy systems already on bcrypt |
| PBKDF2 | CPU-hard | At least 600,000 iterations of PBKDF2-HMAC-SHA256 | FIPS (Federal Information Processing Standards) compliance is required |
OWASP now lists Argon2id as the first recommendation. It won the Password Hashing Competition in 2015 and was standardized as RFC 9106 in 2021. It is memory-hard, meaning each guess needs a lot of RAM, which is what crushes GPU and ASIC (Application-Specific Integrated Circuit) attackers more than raw time cost does.
// Node.js, using @node-rs/argon2 (a fast native binding; avoid pure-JS implementations).
import { hash, verify } from "@node-rs/argon2";
// OWASP-aligned production profile. memoryCost is in KiB, so 65536 KiB = 64 MiB.
const ARGON_OPTS = { memoryCost: 65536, timeCost: 3, parallelism: 1 };
async function registerUser(plainPassword) {
// The salt is generated and embedded in the output string automatically.
const passwordHash = await hash(plainPassword, ARGON_OPTS);
// Store passwordHash in the DB. It looks like:
// $argon2id$v=19$m=65536,t=3,p=1$<salt-b64>$<hash-b64>
return passwordHash;
}
async function checkLogin(plainPassword, storedHash) {
// verify() re-reads the parameters and salt from storedHash, so you never
// pass them in again. Returns true/false in (deliberately) constant-ish time.
return verify(storedHash, plainPassword);
}If you are stuck on bcrypt (a very common real-world situation), remember its two famous quirks: a work factor of at least 12 in 2026, and a hard limit of 72 bytes of input. Anything past 72 bytes is silently ignored, so a 100-character passphrase is no stronger than its first 72 bytes. The standard fix is to pre-hash with SHA-256 before bcrypt, but only do that with care.
Interview answer: "Never store plaintext. Hash with a slow, memory-hard function: Argon2id is the OWASP default today, bcrypt at cost 12 or higher is acceptable for existing systems. The library salts each password automatically; you can add a pepper kept outside the database for defence in depth. Never use SHA-256 or MD5 for passwords; they are too fast and brute-forceable on GPUs."
Sessions vs tokens: stateful or stateless
Once a user proves who they are, the server needs to remember it across the many requests that follow, because HTTP is stateless (each request arrives with no memory of the last). There are two classic strategies, and the interview almost always asks you to compare them.
Session-based authentication (stateful)
The server creates a session: a record of "this user is logged in," stored server-side (in memory, Redis, or a database). It hands the browser a session ID, a random opaque string, inside a cookie. On each request the browser sends the cookie back, and the server looks up the session to know who you are.
Analogy. The coat check gives you a numbered ticket. The number means nothing on its own; the coat check's ledger maps number 247 to your coat. Lose the ledger and the ticket is worthless. The server holds the ledger.
// Express with express-session. The cookie holds only an opaque ID;
// the actual session data lives server-side in the store.
import session from "express-session";
app.use(session({
secret: process.env.SESSION_SECRET, // signs the cookie so it cannot be forged
resave: false,
saveUninitialized: false,
cookie: {
httpOnly: true, // JavaScript cannot read it -> blunts XSS token theft
secure: true, // only sent over HTTPS
sameSite: "lax", // mitigates CSRF (cross-site request forgery)
maxAge: 1000 * 60 * 60 * 24, // 24 hours
},
}));
// After verifying the password:
req.session.userId = user.id; // server remembers this; client only holds the IDThe defining property: the server holds the state. That makes logout trivial (delete the session) but means every server instance needs to reach the shared session store.
Token-based authentication (stateless)
Instead of a ledger, the server hands you a token that contains the facts and is cryptographically signed so it cannot be tampered with. On each request you present the token; the server verifies the signature and trusts the contents without looking anything up. The dominant format is the JWT (JSON Web Token).
Analogy. This is the club wristband. It is colour-coded and stamped (signed) so staff trust it on sight, anywhere in the building, without phoning the front door. No central ledger needed: the band carries its own authority.
Stateful (session) Stateless (token / JWT)
----------------------------- -----------------------------
cookie: sid=247 Authorization: Bearer eyJ...
server: look up 247 in store server: verify signature, read claims
-> {userId: 9, role: admin} -> {userId: 9, role: admin}
state lives on the SERVER state lives in the TOKEN (on the CLIENT)| Dimension | Session (stateful) | Token / JWT (stateless) |
|---|---|---|
| Where state lives | Server store (Redis, DB) | Inside the signed token, on the client |
| Lookup per request | Yes (read the store) | No (just verify signature) |
| Scales horizontally | Needs shared/sticky store | Trivial; any server can verify |
| Instant logout / revoke | Easy (delete session) | Hard (token is valid until expiry) |
| Works across domains/APIs | Awkward (cookies are domain-bound) | Natural (send the header anywhere) |
| Main risk | Session fixation, CSRF | Token theft, no easy revocation |
Rule of thumb. Single web app, one backend, you want easy logout: sessions are simpler and safer by default. Multiple services, mobile clients, or third-party APIs that must verify identity without a shared database: tokens earn their keep.
Interview answer: "Sessions keep state on the server and give the client an opaque ID; tokens push signed state to the client so any server can verify it without a lookup. Sessions make revocation easy but scaling harder; tokens make scaling easy but revocation harder. I pick sessions for a single-backend web app and tokens for distributed services or APIs consumed by many clients."
JWT deep dive: what is inside the wristband
JWT (JSON Web Token, RFC 7519) is the format most people mean when they say "tokens." Understanding its three parts is a guaranteed interview topic.
A JWT is three Base64url-encoded sections joined by dots: header.payload.signature.
eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9 . eyJzdWIiOiI5Iiwicm9sZSI6ImFkbWluIn0 . SflKxwRJ...
└────────── header ──────────┘ └────────── payload ──────────┘ └── signature ──┘- The header says which signing algorithm was used, for example
{"alg":"RS256","typ":"JWT"}. - The payload holds the claims: statements about the user. Standard registered claims include
iss(issuer),sub(subject, usually the user ID),aud(audience, who the token is for),exp(expiry timestamp),iat(issued-at), andjti(a unique JWT ID). - The signature is computed over the header and payload using a secret or private key. It is the part that makes the token trustworthy.
Analogy. The header is the kind of stamp the club uses. The payload is the human-readable info printed on the wristband (your name, "VIP", expiry time). The signature is the tamper-evident hologram: change the printed info and the hologram no longer matches.
Beginner trap. A JWT payload is encoded, not encrypted. Base64url is reversible by anyone; paste any JWT into a decoder and read it. So never put secrets (passwords, card numbers, anything sensitive) in a JWT. The signature stops people editing the token, but it does nothing to hide the contents.
Signing algorithms: HS256 vs RS256 vs ES256
- HS256 = HMAC (Hash-based Message Authentication Code) with SHA-256. Symmetric: one shared secret both signs and verifies. Simple, but every service that verifies also holds the signing key, so any of them could forge tokens.
- RS256 = RSA (Rivest-Shamir-Adleman) signature with SHA-256. Asymmetric: the authorization server signs with a private key; every other service verifies with the public key. Resource servers can verify without being able to forge.
- ES256 = ECDSA (Elliptic Curve Digital Signature Algorithm) on the P-256 curve. Also asymmetric, smaller keys and signatures than RSA for the same strength.
Rule of thumb. One service issues and verifies its own tokens: HS256 is fine. Multiple services verify tokens minted by a central auth server: use RS256 or ES256 so only the issuer holds the signing key.
import jwt from "jsonwebtoken";
import fs from "node:fs";
// SIGN with the private key. Only the auth server holds this.
const privateKey = fs.readFileSync("/secrets/jwt-private.pem");
const accessToken = jwt.sign(
{ sub: "9", role: "admin" }, // payload claims
privateKey,
{
algorithm: "RS256",
expiresIn: "15m", // short-lived access token
issuer: "https://auth.example.com",
audience: "https://api.example.com",
keyid: "key-2026-01", // 'kid' header, so verifiers pick the right public key
}
);
// VERIFY with the public key. Any resource server can do this safely.
const publicKey = fs.readFileSync("/secrets/jwt-public.pem");
const claims = jwt.verify(accessToken, publicKey, {
algorithms: ["RS256"], // CRITICAL: pin the algorithm explicitly
issuer: "https://auth.example.com",
audience: "https://api.example.com",
});Gotcha (the two classic JWT attacks). First, thealg: noneattack: an attacker sets the header algorithm tonone, strips the signature, and a careless library accepts the token unsigned. Second, the algorithm confusion attack: the server expects RS256, the attacker sends an HS256 token signed using the public RSA key as the HMAC secret (the public key is public, so they have it). Both are defeated by the same rule: always pass an explicit allow-list of algorithms toverify, never trust thealgin the token's own header. That is why the code above pinsalgorithms: ["RS256"].
Interview answer: "A JWT is header, payload, and signature, Base64url-encoded and dot-separated. The payload is signed, not encrypted, so I never store secrets in it. I use RS256 or ES256 when many services verify centrally minted tokens, and I always pin the expected algorithm at verification time to block alg: none and HS/RS confusion attacks."Access tokens and refresh tokens: why two tokens
This is the heart of the question you were asked, so we will go slowly. The problem: stateless JWTs are hard to revoke, so a stolen one is valid until it expires. That pushes you toward very short lifetimes. But forcing the user to log in again every fifteen minutes is miserable. The two-token pattern resolves the tension.
- The access token is short-lived (typically 5 to 15 minutes) and sent with every API request to prove you may act. This is the wristband: checked everywhere, on sight, no phone call to the door.
- The refresh token is long-lived (days to weeks), kept safe, and used only to obtain a fresh access token when the old one expires. This is the re-entry pass at coat check: you do not flash it at the bar, you only present it to get a new wristband.
Analogy. Your wristband (access token) is checked at every bar and door, so it is designed to expire fast in case it falls off and someone else grabs it. Your re-entry pass (refresh token) lives safely in coat check and is shown only at one specific window to get a new wristband. Two tokens, two jobs, two risk profiles.
1. Login -> server returns { accessToken (15m), refreshToken (7d) }
2. Call API -> send Authorization: Bearer <accessToken>
3. Access expires -> API returns 401
4. Refresh -> POST /token { refreshToken } -> new { accessToken, refreshToken }
5. Retry API -> send the NEW accessToken
6. Refresh expires -> user must log in againRefresh-token rotation and reuse detection
A long-lived refresh token is a juicy target, so modern practice (and OAuth 2.1, covered below) requires it to be protected. The standard defence is refresh-token rotation: every time a refresh token is used, it is invalidated and a brand-new one is issued. Refresh tokens become single-use.
This unlocks reuse detection via token families. All refresh tokens descended from one login form a family. If a token that was already rotated out is presented again, that means two parties hold copies: the legitimate user and a thief. The server cannot tell which is which, so it revokes the entire family, forcing a fresh login. The attack is caught the moment the stolen token is replayed.
Analogy. Each re-entry pass works exactly once and is swapped for a new one. If someone tries to reuse an already-swapped pass, the coat check knows a pass was copied, tears up every pass in that family, and makes you check in again from scratch.
// Simplified rotation with family-based reuse detection.
async function refresh(presentedToken) {
const record = await db.refreshTokens.findByHash(sha256(presentedToken));
if (!record) throw new Error("unknown refresh token");
if (record.used) {
// A rotated-out token is being replayed -> theft. Nuke the whole family.
await db.refreshTokens.revokeFamily(record.familyId);
throw new Error("refresh token reuse detected; family revoked");
}
await db.refreshTokens.markUsed(record.id); // single-use: burn it
const newRefresh = issueRefreshToken(record.familyId); // same family, new token
const newAccess = issueAccessToken(record.userId); // fresh 15-minute access
return { accessToken: newAccess, refreshToken: newRefresh };
}Note. Store refresh tokens hashed in your database, exactly like passwords (a fast hash such as SHA-256 is acceptable here because the token is already high-entropy random). If the database leaks, the raw tokens do not.
Where do you store tokens on the client?
This is the trickiest follow-up, because every option trades off against a different attack.
localStorage: easy, but readable by any JavaScript on the page, so a single XSS (Cross-Site Scripting) flaw leaks the token. Current guidance (OWASP) is to avoid it for tokens.HttpOnlycookie: JavaScript cannot read it, which removes the XSS theft path, but cookies are auto-sent by the browser, which opens CSRF (Cross-Site Request Forgery). You counter CSRF with theSameSitecookie attribute and/or anti-CSRF tokens.- In-memory (a JS variable): safest against persistence-based theft (gone on refresh), but the user is logged out on every page reload unless a refresh token in an
HttpOnlycookie silently restores the session.
Rule of thumb. A common modern pattern: keep the access token in memory, keep the refresh token in anHttpOnly,Secure,SameSitecookie. XSS cannot read the in-memory access token easily, and cannot read the cookie at all; CSRF is contained because the refresh endpoint is the only cookie-driven action and is protected withSameSite.
Interview answer: "I issue a short-lived access token, around 15 minutes, sent as a Bearer header on each request, and a long-lived refresh token used only to mint new access tokens. I rotate refresh tokens on every use and group them into families so that replay of a rotated token triggers revocation of the whole family. For storage I keep the access token in memory and the refresh token in an HttpOnly, Secure, SameSite cookie, which balances the XSS and CSRF risks."
JWT vs sessions: the trade-offs, in depth
"JWT or sessions?" is the most common design question in this whole topic, and the honest senior answer is "it depends, and here is the framework." You have now seen both mechanisms plus refresh tokens, so we can weigh them properly. Keep one idea front and centre: the only real difference is where the source of truth lives. With a session, the server holds the truth and the client holds a pointer. With a JWT, the client holds the truth and the server holds a verification key. Every trade-off below flows from that one line.
Analogy. A session is a coat-check ticket: the number is worthless without the venue's ledger, so the venue stays in control. A JWT is a stamped festival wristband: it carries its own authority, so anyone can check it on sight, but the venue cannot un-issue a band already on someone's wrist without extra machinery.
Revocation: the trade-off that decides most arguments
This is the big one. With a session, logging someone out (or banning them, or killing a stolen session) is a single delete: remove the row and the next request fails. With a JWT, the token stays valid until its exp no matter what, because verification is just a signature check with no lookup. To truly revoke a JWT you must add a denylist (a server-side list of revoked token IDs checked on every request) or lean on very short lifetimes plus refresh rotation.
Gotcha. Here is the irony that catches people: a JWT system with proper revocation is no longer stateless. The denylist is server state, checked on every request, which is the exact thing a session store does. Teams routinely choose JWT "to avoid a session store," then bolt on a Redis denylist, and end up with a session store wearing a different hat, plus the added complexity of signing keys and refresh rotation.
Scale and latency
A session costs one lookup per request against a shared store (Redis or a database). On the same network that is typically well under a millisecond, but it is still a round-trip and a component that must stay available and scale with you.
A JWT costs a local signature verification and no I/O at all: HMAC (HS256) verification is microseconds; RSA (RS256) is slower but still local and fast. This is the legitimate win: any server in any region can verify a token with just the key, no shared database. That is why JWTs shine across microservices and edge deployments.
Note. The win evaporates the moment you add a denylist check for revocation, because that reintroduces the per-request lookup. So the accurate statement is "stateless JWT verification is faster until you need revocation or fresh data." For a single web app, a Redis session lookup is almost never the bottleneck, so this advantage matters far more for large distributed systems than for typical products.
Data freshness: the sneaky one
A session reads the user's current record on each request, so changes take effect immediately: demote an admin and their very next click is unprivileged.
A JWT is a snapshot frozen at issue time. If you bake role: admin into a token and then demote the user, they stay admin in the eyes of every service until the token expires.
Beginner trap. Putting volatile data (roles, permissions, plan tier) directly into a long-lived JWT causes stale authorization. Mitigations: keep access tokens short (minutes), store only stable identifiers in the token and look up volatile data server-side, or add a version claim you bump to invalidate old tokens. If you reach for that last option, notice you have quietly rebuilt server state again.Logout, and "log out of all devices"
Sessions make both trivial: delete one row, or delete every row for a user. JWTs make both hard: you need a denylist, per-user token versioning, or refresh-token family revocation (from the access-and-refresh section) to get the same effect.
Size and bandwidth
A session cookie carries a tiny opaque ID. A JWT carries its whole payload, signed, so it runs from a few hundred bytes to a few kilobytes and is sent on every request. On chatty APIs that header weight adds up, and oversized tokens can bump against header size limits in proxies and gateways.
Cross-domain and multiple clients
Session cookies are domain-bound and awkward across separate services or for native mobile apps that have no cookie jar. A JWT is just a string you place in an Authorization header, so it travels naturally to many services, mobile clients, and third-party APIs. This is the other legitimate JWT win.
Security surface
Both are only as safe as where the client stores them, and the best place for both is an HttpOnly, Secure, SameSite cookie.
Gotcha. The widespread belief that "sessions use cookies and so face CSRF, while JWTs uselocalStorageand so face XSS" confuses storage with format. You can, and usually should, put a JWT in anHttpOnlycookie, which removes the XSS-theft path and makes the CSRF story identical to a session. The XSS-versus-CSRF trade-off is about where you store the credential, not about whether it is a JWT or a session ID.
Beyond storage, each adds its own pitfalls: JWTs introduce signing-key management and the alg: none and algorithm-confusion attacks (always pin the algorithm at verification); sessions introduce session-fixation risk (regenerate the session ID on login).
Complexity and failure modes
A session has essentially one moving part: the store. A production JWT setup has several: signing keys with rotation (via the kid header and a JWKS endpoint), refresh-token rotation, a revocation denylist, and clock-skew handling on exp. More moving parts means more ways to misconfigure, which is why a notable share of senior engineers argue that sessions are underrated and that JWTs get reached for too often, by default rather than by need.
The summary: pick by what you need
| If you need... | Prefer | Why |
|---|---|---|
| Instant logout, ban, or revoke | Sessions | Delete the record; no denylist machinery |
| Always-fresh roles and permissions | Sessions | Current data is read each request |
| One web app, one backend, same domain | Sessions | Simpler, fewer failure modes |
| Many services verifying one login | JWT | Local verification, no shared store |
| Mobile, native, or third-party API clients | JWT | A header travels anywhere |
| Edge or multi-region with no shared database | JWT | Verify with just the key |
| The fewest moving parts | Sessions | One component to run |
Rule of thumb. Default to sessions for a single first-party web app; reach for JWTs when identity must be verified by services that do not share a database, or by mobile and third-party clients. If you want a JWT's revocation to match a session's, you will rebuild server state anyway, so choose JWT for its distribution benefits, not to escape the database.
The hybrid that most real systems use
You rarely pick one purist extreme. The common production pattern, and what providers like Auth0 and Clerk do under the hood, is a blend: a short-lived JWT access token (stateless and fast for the many API calls) paired with an opaque, stored refresh token (stateful, so it can be rotated and revoked). The frequent path stays lookup-free; the rare refresh path is where you keep control. For APIs that must revoke instantly, another blend is opaque access tokens plus token introspection, where the resource server asks the authorization server "is this token still valid," trading a lookup for airtight revocation.
The hybrid in one picture
every API call ──> verify short JWT locally (fast, stateless, no lookup)
│ expired?
▼
present refresh token ──> server store (slow path, stateful)
│ valid + unused?
▼
rotate it, issue a fresh JWT (revocable control point)Interview answer: "The one real difference is where the source of truth lives: sessions keep it on the server, JWTs keep it in the token. Sessions give instant revocation and always-fresh data at the cost of a lookup and a shared store; JWTs give lookup-free, cross-service verification at the cost of hard revocation and stale claims. The catch is that securing JWTs with a denylist reintroduces the very server state you were trying to avoid. So I default to sessions for a single web app and JWTs when multiple services or mobile clients must verify identity without a shared database, and in practice I use the hybrid: a short JWT access token plus a stored, rotating refresh token."
OAuth 2.0 and 2.1: the valet, explained properly
Now the valet. OAuth (Open Authorization) solves one specific problem: letting an application access some of your data on another service without ever seeing your password. When you click "Sign in with Google" or let an app read your calendar, that is OAuth.
Analogy. You hand the valet a numbered ticket, not your house keys. The ticket lets them fetch your car (one scoped action) and nothing else, you can void it at any time, and the valet never learns the keys to your home. OAuth gives apps a scoped, revocable ticket instead of your actual credentials.
Beginner trap. OAuth is an authorization framework, not an authentication one. By itself it answers "what may this app do," not "who is the user." The piece that adds identity ("who are you") on top of OAuth is OpenID Connect, covered in the next section. Conflating the two is the most common OAuth interview mistake.
The four roles
| Role | Plain meaning | Nightclub mapping |
|---|---|---|
| Resource Owner | The user who owns the data | You, the car owner |
| Client | The app wanting access | The restaurant that wants your car parked |
| Authorization Server | Issues tokens after the user consents | The valet stand that prints tickets |
| Resource Server | The API holding the data | The parking garage that honours the ticket |
The app never touches your password. It bounces you to the authorization server, you log in there and consent to specific scopes (named permissions like calendar.read or email), and the authorization server hands the app an access token limited to those scopes.
The flows (grant types) and when to use each
A grant type (or flow) is the specific choreography by which a client obtains a token. Picking the right one is the senior-level part of this topic.
Authorization Code flow with PKCE is the default for almost everything today: web apps, mobile apps, and single-page apps. PKCE (Proof Key for Code Exchange, pronounced "pixie," RFC 7636) stops an attacker who intercepts the intermediate authorization code from exchanging it for tokens.
Authorization Code + PKCE (the one to know cold)
User Client (your app) Authorization Server Resource Server
| | | |
| click "Sign in" | |
| | 1. make code_verifier (random) | |
| | code_challenge = SHA256(verifier)| |
| | 2. redirect with code_challenge --> | |
| <---------- login + consent screen ------------ | |
| approve scopes ------------------------------> | |
| | <-- 3. redirect back with ?code=xyz | |
| | 4. POST code + code_verifier -----> | (server checks the |
| | | verifier hashes to the |
| | <-- 5. { access_token, | earlier challenge) |
| | refresh_token, id_token } | |
| | 6. call API with access_token -----------------------------> |The clever bit: the client invents a random secret (code_verifier), sends only its hash (code_challenge) up front, then proves it knew the original secret when redeeming the code. An attacker who steals just the code from the redirect cannot redeem it, because they never had the verifier.
// PKCE setup (browser/SPA). Real apps use a library; this shows the mechanics.
function base64url(bytes) {
return btoa(String.fromCharCode(...bytes))
.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}
const verifier = base64url(crypto.getRandomValues(new Uint8Array(32))); // keep secret
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier));
const challenge = base64url(new Uint8Array(digest)); // send this
// 1) Send the user to the auth server's /authorize with the challenge.
// Real endpoints come from the provider's discovery document, see OIDC below.
const params = new URLSearchParams({
response_type: "code",
client_id: "YOUR_CLIENT_ID",
redirect_uri: "https://app.example.com/callback",
scope: "openid email profile",
code_challenge: challenge,
code_challenge_method: "S256",
state: base64url(crypto.getRandomValues(new Uint8Array(16))), // anti-CSRF for the redirect
});
location.assign(`${AUTH_ENDPOINT}?${params}`);// 2) On the callback route, exchange the code + verifier for tokens (server-side).
const res = await fetch(TOKEN_ENDPOINT, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "authorization_code",
code, // from the ?code= query param
redirect_uri: "https://app.example.com/callback",
client_id: "YOUR_CLIENT_ID",
code_verifier: verifier, // proves we started this flow
}),
});
const { access_token, refresh_token, id_token } = await res.json();The other grant types you should be able to name:
| Grant type | What it is for | Example |
|---|---|---|
| Authorization Code + PKCE | Any app acting for a logged-in user | Web, mobile, and SPA logins |
| Client Credentials | Machine-to-machine, no user involved | A backend cron job calling another API |
| Device Authorization (Device Code) | Inputs-limited devices | Signing a smart TV in by typing a code on your phone |
| Refresh Token | Trade a refresh token for a new access token | Silent re-auth without re-login |
Gotcha. Two old grants are now considered unsafe and are removed from OAuth 2.1 (see below). The Implicit flow returned the access token directly in the URL fragment, where it leaked through browser history and referrer headers; it is replaced by Authorization Code + PKCE. The Resource Owner Password Credentials (ROPC) grant had the app collect the user's actual username and password, which defeats the entire point of OAuth. If an interviewer mentions either approvingly, flag it.
What changed in OAuth 2.1 (verified, mid-2026)
OAuth 2.0 is RFC 6749, published in 2012. OAuth 2.1 is a consolidation that folds a decade of security best practice into one document. As of mid-2026 it is a late-stage IETF (Internet Engineering Task Force) Internet-Draft (draft-ietf-oauth-v2-1-15, dated March 2026) and has not yet been published as a final RFC, but every major identity provider already implements its profile, so "write new code to the 2.1 profile" is current industry advice. The concrete changes worth quoting:
- PKCE is mandatory for all clients using the authorization code flow, not just public ones. In 2.0 it was only recommended for public clients.
- The Implicit grant is removed.
- The Resource Owner Password Credentials grant is removed.
- Exact redirect-URI matching is required (no wildcard or prefix matching), which closes open-redirect attacks.
- Refresh tokens for public clients must be sender-constrained or use rotation, so a stolen refresh token cannot be replayed indefinitely.
- Bearer tokens are prohibited in URL query strings; they belong in the
Authorizationheader or a POST body.
Interview answer: "OAuth lets an app get scoped, revocable access to my data on another service without ever seeing my password, using four roles: resource owner, client, authorization server, resource server. The default flow today is Authorization Code with PKCE for web, mobile, and SPAs; client credentials for machine-to-machine. OAuth 2.1 is the current best-practice profile: it mandates PKCE for every client, removes the implicit and password grants, and requires refresh-token rotation. It is still a draft RFC but every major provider already follows it."
OpenID Connect: adding "who are you" on top of OAuth
OAuth alone tells the app what it may do, not who the user is. OpenID Connect (OIDC) is a thin identity layer built on top of OAuth 2.0 that adds authentication. It is what powers "Sign in with Google."
The key addition is the ID token: a JWT, returned alongside the access token, that contains verified claims about the user's identity (their subject ID, email, name). You request it by including the openid scope.
Analogy. The access token is the valet ticket (what the app may fetch). The ID token is a signed note from the valet stand confirming who handed over the car. One is about permission, the other about identity.
Gotcha. Do not send the ID token to your APIs as a credential, and do not use the access token to read user identity. The ID token is for the client to learn who logged in; the access token is for calling resource servers. Mixing them up is a frequent and revealing mistake.
OIDC also defines discovery: providers publish a well-known JSON document at /.well-known/openid-configuration listing their real authorization endpoint, token endpoint, and JWKS (JSON Web Key Set) URL. That is the correct, non-hardcoded way to find the endpoints used in the PKCE example above.
// Discover a provider's real endpoints instead of hardcoding them.
const config = await fetch(
"https://accounts.google.com/.well-known/openid-configuration"
).then(r => r.json());
const AUTH_ENDPOINT = config.authorization_endpoint;
const TOKEN_ENDPOINT = config.token_endpoint;
const JWKS_URI = config.jwks_uri; // public keys to verify RS256 tokensInterview answer: "OAuth is authorization; OpenID Connect adds authentication on top of it. OIDC introduces the ID token, a JWT of identity claims for the client, while the access token stays the credential for calling APIs. Endpoints come from the provider's discovery document at /.well-known/openid-configuration, and I verify ID tokens against the published JWKS."
Multi-factor authentication and passkeys
MFA / 2FA
MFA (Multi-Factor Authentication; 2FA is the two-factor case) requires evidence from more than one category: something you know (password), something you have (phone, security key), something you are (fingerprint, face). The point is that compromising one factor is not enough.
The common app-based second factor is TOTP (Time-based One-Time Password, RFC 6238): the server and your authenticator app share a secret once, then both derive the same 6-digit code from that secret plus the current time, refreshing every 30 seconds.
Analogy. TOTP is like you and the bouncer owning identical synchronized dice that reroll every 30 seconds. You read your dice, say the number, and the bouncer checks it matches theirs. No number works for long, and no number works twice.
Note. SMS-based codes are a weak second factor (vulnerable to SIM-swap and interception), and several regulators are phasing them out for sensitive sectors. App-based TOTP or hardware keys are stronger.
Passkeys (WebAuthn / FIDO2): the passwordless future
A passkey is a login credential built on FIDO2, the standard pairing of WebAuthn (the browser API, maintained by the W3C, the World Wide Web Consortium) and CTAP (Client to Authenticator Protocol, from the FIDO Alliance; FIDO stands for Fast Identity Online). Passkeys replace the shared-secret password with public-key cryptography.
Here is the mechanism, which is the part interviewers love because it kills an entire class of attacks. When you register, your device generates a key pair. The private key never leaves your device (it sits behind your fingerprint, face, or PIN). The server stores only the public key. To log in, the server sends a random challenge; your device signs it with the private key; the server verifies the signature with the stored public key.
Analogy. Instead of you and the club both knowing a secret password that could be overheard, you keep a unique signet ring that never leaves your finger. The club keeps a wax imprint of the ring (the public key). To prove yourself, you stamp a fresh, one-time message they hand you. Nobody can copy the ring from the wax imprint, and the imprint is useless to a thief.
The security payoff: if the server's database is breached, attackers get a list of public keys, which are public by definition and worthless for impersonation. There is no shared secret to phish, leak, or reuse. Passkeys are bound to the site's origin, so they cannot be used on a look-alike phishing site.
// WebAuthn registration (browser side). The heavy crypto lives in the device + server.
const credential = await navigator.credentials.create({
publicKey: {
challenge: serverChallengeBytes, // random, from your server
rp: { name: "Example", id: "example.com" }, // relying party = your site
user: { id: userIdBytes, name: "ana@example.com", displayName: "Ana" },
pubKeyCredParams: [{ type: "public-key", alg: -7 }], // -7 = ES256
authenticatorSelection: { userVerification: "required" }, // biometric/PIN
},
});
// Send credential.response (which carries the PUBLIC key) to the server to store.Synced passkeys travel across your devices through a provider (iCloud Keychain, Google Password Manager), solving the "lost device" fear; device-bound passkeys (such as a YubiKey) never leave one piece of hardware. As of 2026, Apple, Google, and Microsoft ship native passkey support, and NIST (the US National Institute of Standards and Technology) recognizes synced passkeys as meeting multi-factor, phishing-resistant requirements. The open problems are account recovery when all devices are lost, and sign-in on borrowed or older devices.
Interview answer: "A passkey uses public-key cryptography instead of a shared secret. The private key stays on the device behind a biometric; the server stores only the public key and verifies a signed challenge at login. Because there is no shared secret and the credential is bound to the site origin, passkeys are phishing-resistant and a server breach leaks only useless public keys. They are built on FIDO2, which is WebAuthn plus CTAP."
Authorization models: how you decide "what may you do"
Once you know who the user is, you need a model for permissions. Name these and you sound senior.
| Model | Full name | Decision is based on | Example |
|---|---|---|---|
| RBAC | Role-Based Access Control | The user's role | "admins may delete users" |
| ABAC | Attribute-Based Access Control | Attributes of user, resource, context | "managers may view records in their own region during work hours" |
| ReBAC | Relationship-Based Access Control | Relationships in a graph | "you may edit a doc you own or that was shared with you" (Google Docs style) |
| Scopes | OAuth scopes | Named permissions granted to a token | "this token has calendar.read only" |
Analogy. RBAC is wristband colours (one band, one tier). ABAC is a rule the bouncer evaluates on the spot from facts about you and the situation. ReBAC is "are you on the host's guest list for this specific table." Scopes are a valet ticket stamped with exactly which services it unlocks.
Rule of thumb. Start with RBAC; it covers most apps and is easy to reason about. Reach for ABAC when rules depend on context (time, location, ownership), and ReBAC when permissions are inherently about relationships between users and resources.
The mistakes and edge cases interviewers probe
Gotcha. Treating 401 and 403 as interchangeable. 401 means unauthenticated (who are you?), 403 means authenticated but forbidden (you cannot do this). Wiring a 403 where a 401 belongs leaks whether a resource exists.
Gotcha. Believing a JWT can be "logged out." Because it is stateless, it stays valid untilexp. Real revocation needs a server-side denylist (often a Redis set of revokedjtivalues with a TTL matching token expiry) or short access-token lifetimes plus refresh rotation. Claiming "just delete the token client-side" is not revocation; the server would still accept a copy.
Gotcha. Putting sensitive data in a JWT payload. It is Base64url, readable by anyone. Signing protects integrity, not confidentiality.
Gotcha. Trusting the token's ownalgheader at verification. Always pass an explicit algorithm allow-list to blockalg: noneand HS/RS confusion.
Gotcha. Long-lived access tokens "to avoid implementing refresh." A 24-hour access token is a 24-hour breach window if stolen. Short access token plus rotating refresh token is the answer.
Gotcha. Storing refresh tokens in plaintext in the database. Hash them; a database leak should not hand out working tokens.
Gotcha. Skippingaudandissvalidation. A token minted for service A should not be accepted by service B, and you should only accept tokens from your own issuer. Validate both.
Gotcha. Using the OAuth access token as proof of identity, or sending the OIDC ID token to your API. Access token = permission for resource servers; ID token = identity for the client. Keep them in their lanes.
Beginner trap. Thinking "Sign in with Google" means Google handles your authorization too. Google authenticates the user and hands you identity and scoped access; your app still owns its own authorization (which of your features this user may use).
Where and how to use each approach (the practical decision guide)
Knowing how each piece works is half the job; the other half is knowing when to reach for it. This section is the practical map: for each approach, a one-line reminder, the real products that use it, when to use it, when to avoid it, and the concrete steps to wire it up.
Start with this decision tree, then read the details below it.
WHICH APPROACH DO I REACH FOR?
Do users log in with another service's account (Google, GitHub, Apple)?
|-- yes --> OAuth 2.x + OpenID Connect (social login)
|
Does my app need to read/write a user's data on another service?
|-- yes --> OAuth 2.x (delegated, scoped access)
|
Am I building the login myself?
|-- One web app + one backend, server-rendered or same-origin?
| --> SESSIONS (stateful cookie)
|-- Mobile/native app, many services, or a public API?
| --> TOKENS: short access token + refresh token
|
Storing passwords at all?
|-- yes --> hash with Argon2id, always. (or skip passwords with PASSKEYS)
|
Does the account hold real value (money, admin, personal data)?
|-- yes --> add MFA (prefer authenticator app or passkey over SMS)The simplest login that is still safe (one small file)
Before the per-approach details, here is a complete, beginner-readable login using the two simplest safe choices: Argon2id for the password and a server session for "remember me." If you can read this, you understand the spine of authentication.
import express from "express";
import session from "express-session";
import { hash, verify } from "@node-rs/argon2";
const app = express();
app.use(express.json());
// Give each visitor a signed cookie that holds only a session id.
app.use(session({
secret: process.env.SESSION_SECRET,
resave: false,
saveUninitialized: false,
cookie: { httpOnly: true, secure: true, sameSite: "lax" },
}));
const users = new Map(); // pretend database: email -> { passwordHash }
// SIGN UP: store a hash, never the password itself.
app.post("/signup", async (req, res) => {
const { email, password } = req.body;
users.set(email, { passwordHash: await hash(password) }); // salt handled for us
res.json({ ok: true });
});
// LOG IN (authentication): compare hashes, then start a session.
app.post("/login", async (req, res) => {
const { email, password } = req.body;
const user = users.get(email);
if (!user || !(await verify(user.passwordHash, password))) {
return res.status(401).json({ error: "wrong email or password" }); // 401 = who are you?
}
req.session.email = email; // the server now remembers this browser is logged in
res.json({ ok: true });
});
// PROTECTED ROUTE (authorization gate): only logged-in users get through.
app.get("/me", (req, res) => {
if (!req.session.email) return res.status(401).json({ error: "log in first" });
res.json({ email: req.session.email });
});
// LOG OUT: forget the session.
app.post("/logout", (req, res) => req.session.destroy(() => res.json({ ok: true })));
app.listen(3000);Note. That is a real, safe shape for a single web app. To turn it into a token-based API (for a mobile app or microservices), you would swap the session for a signed JWT returned at login and sent back as Authorization: Bearer <token>, exactly as the JWT section showed. Same spine, different wristband.Passwords and hashing
In one line: store a slow, salted hash of the password, never the password.
Where to use it. Any app offering email-and-password login: SaaS dashboards, forums, internal tools. If you are not delegating login to Google or a provider, you are storing password hashes.
How to use it. (1) Choose Argon2id and let the library generate the salt. (2) On signup, hash and store. (3) On login, verify the submitted password against the stored hash. (4) Never log or email passwords; if you are stuck on bcrypt, cap input at 72 bytes.
Avoid it when you can skip passwords entirely with passkeys or social login. The safest password is the one you never store, because a hash you do not hold cannot leak.
Sessions (stateful)
In one line: the server remembers you; the browser holds a meaningless ID in a cookie.
Where to use it. Single-backend, server-rendered or same-origin web apps: classic Rails, Django, Laravel, or Next.js apps, admin dashboards, and most bank web portals. If your frontend and backend share a domain, sessions are the simplest safe default.
How to use it. (1) After the password check, create a session record in Redis or your database. (2) Set anHttpOnly,Secure,SameSitecookie holding only the session ID. (3) On each request, look up the session to load the user. (4) Log out by deleting the session row and clearing the cookie.
Avoid it when you have many independent services or mobile clients that cannot share one cookie, or you want stateless scale without a shared session store. Then reach for tokens.
Tokens and JWTs (stateless)
In one line: hand the client a signed card carrying the facts, and verify the signature without any lookup.
Where to use it. Mobile and native app backends, public APIs, and microservice fleets where several services must trust the same login without sharing a database. "Sign in with Google" issues tokens under the hood, and API platforms hand out bearer tokens for this reason.
How to use it. (1) After authentication, sign a short-lived JWT (use RS256 or ES256 when multiple services verify it). (2) Send it asAuthorization: Bearer <token>. (3) Each service verifies the signature plusiss,aud, andexp, pinning the allowed algorithm. (4) Pair it with a refresh token and short lifetimes so you can effectively revoke.
Avoid it when you need instant logout and do not want to run a revocation denylist, or it is a simple single web app where a session is less work.
Access plus refresh tokens
In one line: a short wristband for every request and a long re-entry pass that silently renews it.
Where to use it. Almost every modern token-based login: Google, GitHub, Auth0, and Clerk all issue this pair. Use it whenever you chose tokens and want both safety (short access token) and a smooth experience (no re-login every few minutes).
How to use it. (1) Issue an access token (about 15 minutes) and a refresh token (days) at login. (2) Send the access token on each API call. (3) When the API returns 401, call the refresh endpoint to get a fresh pair. (4) Rotate refresh tokens so each is single-use, and group them into families to detect theft. (5) Keep the access token in memory and the refresh token in an HttpOnly cookie.Skip it when a plain server session already gives you easy logout and you do not have a separate API to call; a single session may be all you need.
OAuth 2.x and OpenID Connect
In one line: let an app act for you with a scoped, revocable ticket instead of your password.
Where to use it. Three situations. Social login ("Sign in with Google/GitHub/Apple"). Delegated access, where your app reads a user's data elsewhere (Calendly reading your Google Calendar, a budgeting app reading bank data, a CI tool posting to GitHub). And being the provider yourself, letting other apps integrate with your API.
How to use it (Authorization Code + PKCE). (1) Register your app with the provider to get a client ID and secret, and set an exact redirect URI. (2) Generate a PKCE verifier and challenge, then redirect the user to the provider's/authorizewith the challenge and the scopes you need. (3) The provider redirects back with a code; exchange the code plus verifier at/tokenfor tokens. (4) Use the access token to call the API and, with OpenID Connect, read the ID token to learn who the user is. In practice, use a library or a provider rather than hand-rolling this.
Overkill when you only need a first-party email-and-password login for one app. OAuth adds redirects and moving parts that a single self-contained login does not require.
Multi-factor authentication
In one line: require a second proof so a stolen password alone is not enough.
Where to use it. Any account with real value: banking, admin panels, anything holding personal data. Banks often text a code (weak), while GitHub and Google support authenticator apps and security keys (strong). Recommended broadly.
How to use it (TOTP, the authenticator-app method). (1) Generate a shared secret and show it as a QR code for the user's authenticator app. (2) Store the secret server-side, encrypted. (3) After the password step at login, ask for the 6-digit code and verify it against the secret and current time. (4) Issue backup codes so a lost phone is not a lockout.
Prefer alternatives when you were about to use SMS: text-message codes are vulnerable to SIM-swap, so favour a TOTP app, a hardware key, or passkeys.
Passkeys (FIDO2 and WebAuthn)
In one line: replace the password with a device-held private key; the server stores only the public key.
Where to use it. Modern passwordless login. Google, Apple, Microsoft, and GitHub already let you sign in with Face ID, a fingerprint, or a device PIN. Use it when you want phishing-resistant login and your users are on current devices and browsers.
How to use it. (1) On registration, callnavigator.credentials.create()and store the returned public key. (2) On login, send a random challenge and callnavigator.credentials.get(); the device signs the challenge. (3) Verify the signature against the stored public key using a server library. (4) Keep a recovery path: a second passkey or an email-based fallback.
Plan ahead for older devices that lack support and for the lost-all-devices case; always offer a fallback method and a recovery flow.
Choosing an authorization model
In one line: how you decide what an authenticated user may do.
Where to use each. Use RBAC (roles) for most apps; it is simple and covers "admins can do X." Use ABAC (attributes) when permission depends on context like time, location, or ownership. Use ReBAC (relationships) for sharing and ownership graphs, the Google-Docs "shared with you" pattern.
How to use it. (1) Start with arolefield on the user and check it in middleware. (2) When rules grow conditional, move to an attribute check (a small policy function reading user, resource, and context). (3) For complex, evolving rules across a big system, graduate to a dedicated policy engine rather than scatteringifstatements.
Rule of thumb. Do not jump to ABAC or a policy engine on day one. RBAC with a clean middleware check carries most products a long way; add complexity only when a real rule demands it.
Quick-reference cheat sheet
| Concept | One-line summary |
|---|---|
| Authentication (AuthN) | Who are you? Verify identity. |
| Authorization (AuthZ) | What may you do? Check permissions. |
| 401 vs 403 | 401 = unauthenticated; 403 = authenticated but forbidden. |
| Password storage | Argon2id (OWASP default); bcrypt cost 12+ for legacy; never SHA-256/MD5. |
| Session | Server holds state; client holds opaque ID in a cookie. |
| JWT | Signed, self-contained token: header.payload.signature, Base64url. |
| Payload is... | Encoded, not encrypted. No secrets inside. |
| HS256 vs RS256 | Symmetric shared secret vs asymmetric private-sign/public-verify. |
| Access token | Short-lived (5 to 15 min), sent on every request. |
| Refresh token | Long-lived, single-use with rotation, only mints new access tokens. |
| Token storage | Access in memory; refresh in HttpOnly, Secure, SameSite cookie. |
| OAuth | Scoped, revocable delegated access without sharing your password. |
| OAuth default flow | Authorization Code + PKCE. |
| OAuth 2.1 | Mandates PKCE for all clients; removes implicit and ROPC; requires rotation. |
| OIDC | Authentication layer on OAuth; adds the ID token. |
| Passkey / FIDO2 | Public-key login; private key never leaves the device; phishing-resistant. |
| RBAC / ABAC / ReBAC | Roles vs attributes vs relationships for permission decisions. |
A worked end-to-end example
Here is a single coherent story tying it together: a user logs in with a password, calls an API, refreshes, and later links a third-party calendar via OAuth.
1. REGISTER
user submits password -> Argon2id(password) stored, salt embedded -> done
2. LOGIN (authentication)
submitted password -> Argon2id verify against stored hash -> OK
server loads roles (authorization data) -> ["user"]
server issues:
access_token = JWT { sub: 9, role: "user", exp: +15m } (RS256)
refresh_token = random, hashed + stored, family F1, used=false
access kept in memory; refresh set as HttpOnly Secure SameSite cookie
3. CALL API (authorization on each request)
GET /api/orders Authorization: Bearer <access_token>
server verifies RS256 signature + iss + aud + exp, reads role -> allow
4. ACCESS TOKEN EXPIRES (after 15 min) -> API returns 401
client POSTs /token (refresh cookie auto-sent)
server: token valid + unused -> mark used, issue new access + new refresh (family F1)
if an OLD refresh token is replayed -> revoke family F1 -> force re-login
5. LINK GOOGLE CALENDAR (OAuth, the valet)
app sends user to Google /authorize with scope=calendar.readonly + PKCE challenge
user logs in AT GOOGLE and consents
Google redirects back with ?code -> app exchanges code + verifier at /token
app receives access_token (scoped to calendar.readonly) + id_token (who they are)
app calls Google Calendar API with that scoped token; never saw the Google passwordEvery actor from the mental model appears: the bouncer (step 2 password check), the wristband (the access token in step 3), the coat-check re-entry pass (the refresh in step 4), and the valet (the OAuth delegation in step 5).
What this interview round really tests
This round is rarely about reciting RFC numbers. Interviewers are checking whether you can: cleanly separate authentication from authorization (and pick the right HTTP status for each); reason about trade-offs rather than declare one approach "best" (sessions vs tokens, where to store tokens, HS256 vs RS256); explain why the two-token pattern and refresh rotation exist, not just that they do; describe the Authorization Code + PKCE flow end to end and say why each step is there; and avoid the well-known traps (logging out a stateless JWT, secrets in payloads, trusting the alg header, long-lived access tokens). The senior signal is connecting a concrete threat to each design choice: "short access tokens because JWTs are hard to revoke," "PKCE because the redirect code can be intercepted," "passkeys because there is no shared secret to phish."
Keep returning to the bouncer, the wristband, the coat check, and the valet, and the whole landscape stays coherent: authentication is the bouncer proving who you are, authorization is the wristband saying what you may do, the access and refresh tokens are the wristband and the re-entry pass with deliberately different lifespans and risk profiles, and OAuth is the valet ticket that lets one service act for you with a scoped, revocable permission instead of the keys to everything. Master those four images and the parts that feel like alphabet soup (JWT, PKCE, OIDC, FIDO2, RBAC) become names for jobs you already understand.
Related
Node.js Memory, Garbage Collection & Production Failures
How Node.js memory really works: the V8 heap, garbage collection, memory leaks, OOM crashes, and diagnosing it all in production on EC2 and ECS.
API Integration in JavaScript: Reading Responses, Handling Errors, and Stripe
Read API responses the right way: res.json() vs res.text(), error handling, timeouts, retries, React data fetching, and real Stripe payments and webhooks.
DNS Records for Email: SPF, DKIM, DMARC, and How Mail Authentication Works
How DNS really works for email: nameservers, SPF, DKIM, and DMARC explained, the attacks they stop, and the exact records for Google Workspace, SES, Mailgun, and Cloudflare.