Skip to content

4. Authentication & Authorization

The area where interviewers most reliably find gaps. Get the vocabulary and the trade-offs right.


1. Authentication vs Authorization

  • Authenticationwho are you? → 401 when it fails
  • Authorizationare 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

js
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

js
// ❌ 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.

js
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

AlgorithmTypeUse
HS256Symmetric (shared secret)One service signs and verifies
RS256Asymmetric (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.

js
jwt.verify(token, secret, { algorithms: ["HS256"] });   // pin the algorithm

Always pin. This is a classic vulnerability and a favourite question.


4. Access Token + Refresh Token

The standard production pattern.

Access tokenRefresh token
Lifetime5–15 minutes7–30 days
StoredMemory, or httpOnly cookiehttpOnly cookie and the database
Sent withEvery API requestOnly to /auth/refresh
RevocableNo (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.

js
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)

StorageXSS safeCSRF safeNotes
localStorageNoYesAny XSS reads it instantly
sessionStorageNoYesSame problem, shorter life
Memory (a JS variable)MostlyYesLost on refresh — pair with a refresh cookie
httpOnly cookieYesNo — needs sameSite/CSRF tokenThe 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.

js
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 works

JWT vs Sessions

JWTSession
StateStatelessServer-side store
RevocationHard — valid until expiryInstant — delete the row
ScalingNo shared store neededNeeds Redis (or sticky sessions)
SizeLarger, sent every requestSmall cookie ID
MicroservicesGood — any service can verifyNeeds shared session access
Mobile / third-party clientsNatural fitCookie 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

js
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)

js
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)

js
// ❌ 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

  1. App redirects the user to the provider with a code_challenge
  2. User authenticates and consents
  3. Provider redirects back with a short-lived authorization code
  4. App exchanges the code + code_verifier for tokens from its backend
  5. 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

js
// ❌ 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 hash

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

© 2025 DDocs · Dipak's Documentation Guide