4. Authentication & Authorization
The area where interviewers most reliably find gaps. Get the vocabulary and the trade-offs right.
1. Authentication vs Authorization
- Authentication — who are you? → 401 when it fails
- Authorization — are you allowed to do this? → 403 when it fails
Both, in that order. A valid token does not mean the user may delete someone else's account.
2. Password Hashing
import bcrypt from "bcrypt";
const hash = await bcrypt.hash(password, 12); // 12 rounds
const isValid = await bcrypt.compare(password, hash);Rules
- Never store plaintext. Never encrypt (reversible) — always hash (one-way)
- Never MD5 or SHA-256 alone — they are fast, which is exactly wrong for passwords
- Use bcrypt, argon2 or scrypt — deliberately slow, salted, with a tunable cost factor
- bcrypt salts automatically; the salt is embedded in the output hash
- Cost factor 12 is a reasonable 2026 default — target ~250ms per hash
Why Slow Is Good
A fast hash lets an attacker with your database try billions of guesses per second on a GPU. bcrypt at cost 12 caps them at a few thousand. Argon2id is the current recommendation for new systems — it also resists GPU and ASIC attacks by being memory-hard.
Timing Attacks
// ❌ Returns instantly when the user doesn't exist — leaks which emails are registered
const user = await db.user.findUnique({ where: { email } });
if (!user) return res.status(401).json({ error: "Invalid credentials" });
// ✅ Always do the comparison work
const user = await db.user.findUnique({ where: { email } });
const valid = await bcrypt.compare(password, user?.passwordHash ?? DUMMY_HASH);
if (!user || !valid) return res.status(401).json({ error: "Invalid credentials" });Also note the error message is identical for "no such user" and "wrong password" — user enumeration protection. Interviewers like candidates who mention this unprompted.
3. JWT
A JSON Web Token has three base64url parts: header.payload.signature.
import jwt from "jsonwebtoken";
const token = jwt.sign(
{ sub: user.id, role: user.role },
process.env.JWT_SECRET,
{ expiresIn: "15m" }
);
const payload = jwt.verify(token, process.env.JWT_SECRET);The Critical Point
A JWT payload is base64-encoded, not encrypted. Anyone can decode and read it. Paste one into jwt.io and you see everything.
- Never put a password, a secret, or sensitive PII in the payload
- The signature guarantees it wasn't modified — it does not hide anything
Signature Algorithms
| Algorithm | Type | Use |
|---|---|---|
| HS256 | Symmetric (shared secret) | One service signs and verifies |
| RS256 | Asymmetric (private/public key) | Auth service signs; many services verify with the public key |
The alg: none Attack
An attacker changes the header to {"alg":"none"} and strips the signature. A library that trusts the header accepts it.
jwt.verify(token, secret, { algorithms: ["HS256"] }); // pin the algorithmAlways pin. This is a classic vulnerability and a favourite question.
4. Access Token + Refresh Token
The standard production pattern.
| Access token | Refresh token | |
|---|---|---|
| Lifetime | 5–15 minutes | 7–30 days |
| Stored | Memory, or httpOnly cookie | httpOnly cookie and the database |
| Sent with | Every API request | Only to /auth/refresh |
| Revocable | No (until it expires) | Yes — delete the DB row |
Why Two Tokens?
A JWT cannot be revoked — that is its whole design. If an access token is stolen, you cannot invalidate it. Keeping it short-lived limits the damage window to minutes. The refresh token is long-lived but stored server-side, so logout and "revoke all sessions" actually work.
export async function login(req, res) {
const { email, password } = req.body;
const user = await db.user.findUnique({ where: { email } });
const valid = await bcrypt.compare(password, user?.passwordHash ?? DUMMY_HASH);
if (!user || !valid) {
return res.status(401).json({ error: "Invalid credentials" });
}
const accessToken = jwt.sign({ sub: user.id, role: user.role },
process.env.JWT_SECRET, { expiresIn: "15m" });
const refreshToken = randomUUID();
await db.refreshToken.create({
data: {
tokenHash: await bcrypt.hash(refreshToken, 10), // hash it — it's a credential
userId: user.id,
expiresAt: new Date(Date.now() + 7 * 864e5),
},
});
res.cookie("refreshToken", refreshToken, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "strict",
path: "/api/auth/refresh", // sent only to this endpoint
maxAge: 7 * 864e5,
});
res.json({ accessToken, user: { id: user.id, name: user.name } });
}Refresh Token Rotation
Issue a new refresh token on every refresh and invalidate the old one. If an old token is reused, that means it was stolen — revoke the entire family and force re-login. This is the detail that marks a strong answer.
5. Where To Store the Token (The Big Question)
| Storage | XSS safe | CSRF safe | Notes |
|---|---|---|---|
localStorage | No | Yes | Any XSS reads it instantly |
sessionStorage | No | Yes | Same problem, shorter life |
| Memory (a JS variable) | Mostly | Yes | Lost on refresh — pair with a refresh cookie |
| httpOnly cookie | Yes | No — needs sameSite/CSRF token | The recommended default |
The Answer
httpOnly + secure + sameSite cookie. httpOnly makes it invisible to JavaScript, so XSS cannot steal it. sameSite: "strict" or "lax" blocks CSRF.
The common wrong answer is localStorage "because it's easier with JWTs". The follow-up is always: what happens if you have an XSS? — and the honest answer is the attacker owns every session.
Best of both: access token in memory, refresh token in an httpOnly cookie scoped to the refresh path. XSS cannot read the refresh token, and the in-memory access token dies with the tab.
6. Session-Based Auth
The alternative to JWTs, and often the better choice.
import session from "express-session";
import RedisStore from "connect-redis";
app.use(session({
store: new RedisStore({ client: redis }),
secret: process.env.SESSION_SECRET,
resave: false,
saveUninitialized: false,
cookie: {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
maxAge: 24 * 3600 * 1000,
},
}));
req.session.userId = user.id; // login
req.session.destroy(); // logout — actually worksJWT vs Sessions
| JWT | Session | |
|---|---|---|
| State | Stateless | Server-side store |
| Revocation | Hard — valid until expiry | Instant — delete the row |
| Scaling | No shared store needed | Needs Redis (or sticky sessions) |
| Size | Larger, sent every request | Small cookie ID |
| Microservices | Good — any service can verify | Needs shared session access |
| Mobile / third-party clients | Natural fit | Cookie handling is awkward |
The Honest Answer
Sessions are the better default for a normal web app. Instant revocation, smaller cookies, and a Redis lookup is sub-millisecond. JWTs are the right call for microservices, mobile clients, and third-party API access.
Most teams reach for JWTs by reflex and then rebuild revocation with a denylist — at which point they have a session with extra steps. Saying that shows real judgement.
7. Auth Middleware
export const requireAuth = asyncHandler(async (req, res, next) => {
const header = req.headers.authorization;
if (!header?.startsWith("Bearer ")) {
return res.status(401).json({ error: "Missing token" });
}
try {
const payload = jwt.verify(header.slice(7), process.env.JWT_SECRET, {
algorithms: ["HS256"],
});
const user = await db.user.findUnique({
where: { id: payload.sub },
select: { id: true, role: true, tokenVersion: true },
});
if (!user) return res.status(401).json({ error: "User not found" });
if (user.tokenVersion !== payload.ver) {
return res.status(401).json({ error: "Token revoked" });
}
req.user = user;
next();
} catch (err) {
const msg = err.name === "TokenExpiredError" ? "Token expired" : "Invalid token";
return res.status(401).json({ error: msg });
}
});The tokenVersion field is a cheap revocation mechanism: bump it on the user row and every existing token becomes invalid. It costs one DB read you were doing anyway.
8. Authorization Patterns
Role-Based (RBAC)
export const requireRole = (...roles) => (req, res, next) => {
if (!req.user) return res.status(401).json({ error: "Not authenticated" });
if (!roles.includes(req.user.role)) {
return res.status(403).json({ error: "Forbidden" });
}
next();
};
router.delete("/:id", requireAuth, requireRole("admin"), deleteUser);Ownership Checks (The One People Miss)
// ❌ Broken Object Level Authorization — OWASP #1 API risk
router.get("/orders/:id", requireAuth, async (req, res) => {
res.json(await db.order.findUnique({ where: { id: req.params.id } }));
// Any logged-in user can read ANY order by changing the URL
});
// ✅ Scope the query to the owner
router.get("/orders/:id", requireAuth, async (req, res) => {
const order = await db.order.findFirst({
where: { id: req.params.id, userId: req.user.id },
});
if (!order) return res.status(404).json({ error: "Not found" });
res.json(order);
});Interview Point
BOLA / IDOR is the most common real-world API vulnerability, and role checks do not catch it. Enforce ownership in the query, not with a separate if after fetching. And return 404, not 403, for a resource the user doesn't own — 403 confirms it exists.
9. OAuth 2.0 and OpenID Connect
OAuth 2.0 is authorization — "let this app access my Google Drive". OpenID Connect is a thin identity layer on top — "log in with Google" — which adds an ID token.
Authorization Code Flow with PKCE
- App redirects the user to the provider with a
code_challenge - User authenticates and consents
- Provider redirects back with a short-lived authorization code
- App exchanges the code +
code_verifierfor tokens from its backend - App uses the access token; the ID token identifies the user
Why PKCE
The implicit flow returned tokens directly in the URL fragment — logged by proxies, visible in browser history. PKCE proves the app exchanging the code is the same one that started the flow, so an intercepted code is useless. PKCE is now recommended for all clients, not just mobile.
In practice most teams use Passport.js, Auth.js or an identity provider (Auth0, Clerk, Cognito) rather than implementing this.
10. Security Checklist
- [ ] Passwords hashed with bcrypt (cost ≥ 12) or argon2id
- [ ] Identical error message for wrong email and wrong password
- [ ] Rate limiting on login, signup and password reset
- [ ] Account lockout or exponential backoff after repeated failures
- [ ] Tokens in httpOnly + secure + sameSite cookies, never
localStorage - [ ] JWT algorithm pinned in
verify - [ ] Short access token expiry (≤ 15 min) with refresh token rotation
- [ ] HTTPS everywhere; HSTS header set
- [ ] Ownership checks on every resource lookup, not just role checks
- [ ] Password reset tokens: single-use, hashed in the DB, short expiry
- [ ] Email verification before granting real permissions
- [ ] Secrets from the environment, never committed
- [ ] Log auth events (login, logout, failures) with the request ID
- [ ] 2FA / TOTP for admin accounts
Password Reset — The Common Bug
// ❌ A predictable or long-lived token, stored in plaintext
const token = user.id + Date.now();
// ✅
const token = randomBytes(32).toString("hex");
await db.resetToken.create({
data: {
tokenHash: createHash("sha256").update(token).digest("hex"),
userId: user.id,
expiresAt: new Date(Date.now() + 15 * 60_000),
},
});
// email the raw token; store only the hashTreat a reset token like a password: cryptographically random, hashed at rest, single-use, short-lived. And always return the same "if that email exists we've sent a link" response, whether or not the account exists.