Lesson 16 of 20

Authentication & Authorization

Password Hashing with bcrypt

The rule is absolute: never store a password. Store a hash of it. A hash is a one-way function — easy to compute forwards and effectively impossible to reverse — so a stolen database gives an attacker a column of hashes rather than a column of passwords. Hashing is not encryption: encryption is designed to be undone by whoever holds the key, and there is no key here because you never need the original back.

And you genuinely never need it. Logging somebody in is not "decrypt the stored password and compare" — it is "hash what they just typed, in the same way, and see whether the result matches what we stored". That is exactly what bcrypt.compare does, which is why the whole system works without your server ever knowing anybody's actual password, including your own.

bcrypt also adds a salt: random data mixed into each hash, so that two people who chose the same password end up with different hashes. Without a salt, an attacker can hash a list of common passwords once and match it against every user in your database simultaneously. The salt is stored inside the resulting hash string, which is why you never manage it yourself and why bcrypt.compare needs nothing but the password and the stored hash.

The cost factor — sometimes called salt rounds — controls how much work each hash takes, and it is deliberately expensive. That is the entire point: a fast hash is fast for the attacker too. Something in the region of 10 to 12 is a common choice, and it is a real trade-off, because higher is safer per password and slower for every login. Never use a general-purpose fast hash such as MD5 or SHA-256 for passwords; being fast is precisely the wrong property here.

Because it is slow on purpose, always use the asynchronous API and never the synchronous one. Hashing on the main thread blocks every other request for as long as it runs, and lesson 1's single-thread argument arrives here with real consequences — a burst of logins can stall the entire server. Two more absolutes while you are here: never log a password, not even in a debug line you intend to delete, and never send one back in a response.

Example
// npm install bcrypt jsonwebtoken

const bcrypt = require('bcrypt');

// Registration: Hash the password before storing
async function registerUser(name, email, password) {
  // Deliberately slow — always the ASYNC form. bcrypt.hashSync would block
  // every other request in the process for as long as it takes.
  const saltRounds = 12;
  const hashedPassword = await bcrypt.hash(password, saltRounds);

  // Save to database (password is now hashed)
  const user = {
    name,
    email,
    password: hashedPassword  // $2b$10$X7Q3...
  };

  // Save user to DB...
  return user;
}

// Login: Compare submitted password with stored hash
async function loginUser(email, password) {
  // Find user in database
  const user = await findUserByEmail(email);
  if (!user) {
    throw new Error('Invalid email or password');
  }

  // Compare password with hash
  const isMatch = await bcrypt.compare(password, user.password);
  if (!isMatch) {
    throw new Error('Invalid email or password');
  }

  return user;
}
  • await bcrypt.hash(password, 12) — the async form; the sync form blocks the server
  • await bcrypt.compare(plain, hash) — returns a boolean; never compare strings yourself
  • Hashing is one-way; encryption is reversible — passwords need the first
  • The salt is generated per password and stored inside the hash string
  • A higher cost factor is safer per password and slower for every login
  • Never MD5 or SHA-256 for passwords — being fast is exactly the wrong property
  • Never log a password, and never include one in a response
Notes
  • Use the same message — "Invalid email or password" — and the same status code for a wrong email as for a wrong password. If the two differ, anybody can feed your login endpoint a list of addresses and learn which ones have accounts here, which is valuable by itself and is the first step of a targeted attack. Rate-limit the endpoint too, because otherwise repeating the check costs the attacker nothing.

Sessions or Tokens?

HTTP is stateless, so once somebody has logged in successfully the server needs a way to recognise the same person on the next request. There are two established answers, and it is worth understanding both — interviews ask about the difference, and the choice determines how logout works in your application.

A session keeps the state on the server. On login you create a session record — in memory, in Redis, or in a database — and give the browser a cookie containing nothing but its id. Every later request carries that cookie automatically, the server looks the session up, and it knows who is asking. Logging out means deleting the record, and it takes effect immediately for everybody holding that cookie.

A token, usually a JWT, keeps the state in the token itself. The server signs a small object containing the user id and hands it over; every later request sends it back, and the server verifies the signature rather than looking anything up. Nothing is stored server-side, which is why any instance can serve any request, and why token authentication suits APIs, multiple servers and mobile clients so well.

The trade-off is revocation, and it is the answer an interviewer is listening for. Because nothing is stored, nothing can be un-stored: a signed token stays valid until it expires, so you cannot truly log somebody out or invalidate a stolen token without adding state back — a short expiry with refresh tokens, or a deny-list, both of which reintroduce exactly the storage you were avoiding. Sessions get revocation for free and pay for it in storage.

Whichever you choose, the browser has to keep something, and where it keeps it matters. An httpOnly cookie cannot be read by JavaScript, so a cross-site scripting bug cannot steal it — but cookies are sent automatically, so you need sameSite and an awareness of CSRF. A token in localStorage is immune to CSRF and readable by any script that gets injected into your page. Neither option is free. For a browser application, httpOnly cookies with sameSite are the safer default; for a mobile app or a public API, the Authorization header is the natural fit.

Example
// --- Session style: the server remembers ---
// login
// const session = await Session.create({ userId: user._id });
// res.cookie('sid', session.id, {
//   httpOnly: true,     // JavaScript cannot read it — XSS cannot steal it
//   secure: true,       // HTTPS only
//   sameSite: 'lax',    // limits cross-site sending (CSRF)
//   maxAge: 1000 * 60 * 60 * 24
// });
// logout  -> await Session.deleteOne({ _id: sessionId });   // immediate

// --- Token style: the token carries the claim ---
const token = jwt.sign(
  { id: user._id, role: user.role },
  process.env.JWT_SECRET,
  { expiresIn: '1h' }
);
res.json({ token });

// logout -> the frontend forgets the token.
// The token itself stays valid until it expires. If somebody copied it,
// it still works. That is the cost of statelessness, and it is why
// short expiries (or a server-side deny-list) exist.
  • Session — state on the server, an id in a cookie; logout is immediate
  • Token — state in the token, verified by signature; nothing to look up
  • Tokens scale across instances trivially; sessions need shared storage
  • Sessions can be revoked; tokens cannot, without adding storage back
  • httpOnly cookie — safe from XSS theft, needs CSRF protection
  • Token in localStorage — safe from CSRF, readable by injected scripts
Notes
  • "How does logout work?" is a much better interview question than "what is a JWT?", and it separates the two answers instantly. With sessions you delete a record. With tokens you either wait for the expiry or you keep a deny-list — and admitting that plainly, rather than claiming tokens can be revoked, is what demonstrates you have actually thought about it.

JWT Authentication and Route Protection

A JWT is three base64 pieces joined by dots: a header, a payload and a signature. The server builds the payload — normally a user id and a role — and signs the whole thing with a secret that only it knows. Anybody can present a token, but only somebody holding the secret could have produced a valid signature for it, and that asymmetry is the entire security model.

Here is the fact that surprises people, and it matters: a JWT is signed, not encrypted. Base64 is an encoding, not a cipher. Paste any token into an online decoder and you will read its contents in a second. So never put anything private in the payload — no password hash, no phone number, no address. Put an id, a role and an expiry, and look up everything else from the database.

There are two library functions and choosing the wrong one is a complete authentication bypass. jwt.verify(token, secret) checks the signature and the expiry, and throws if either fails. jwt.decode(token) simply reads the payload and checks nothing whatsoever — an attacker can hand-write a token claiming role: 'admin' and decode will believe it without hesitation. Use verify, always, and be suspicious of any tutorial that uses decode in a middleware.

Always set an expiry. A token issued without expiresIn is a permanent password that you have no way to withdraw. Keep the window short for anything sensitive and add a refresh token if the short window becomes inconvenient. The secret itself must come from the environment and be long and genuinely random — a secret like 'secret' can be brute-forced offline against a single captured token, with no requests to your server at all.

Protecting a route is then just a middleware: read the Authorization header, expect the word Bearer followed by the token, verify it, attach the result to req.user, and call next(). Anything that fails is a 401. Every route below it can then assume req.user exists, which is the same chained-precondition pattern from lesson 9 doing useful work.

Example
const jwt = require('jsonwebtoken');
const bcrypt = require('bcrypt');
const express = require('express');
const app = express();
app.use(express.json());

// Fail at startup rather than shipping a guessable fallback secret.
// NEVER write: process.env.JWT_SECRET || 'your-secret-key'
const JWT_SECRET = process.env.JWT_SECRET;
if (!JWT_SECRET) throw new Error('JWT_SECRET is not set — refusing to start');

// Generate a token
function generateToken(user) {
  return jwt.sign(
    { id: user.id, email: user.email, role: user.role },
    JWT_SECRET,
    { expiresIn: '24h' }
  );
}

// Login route
app.post('/api/auth/login', async (req, res) => {
  try {
    const { email, password } = req.body;
    const user = await findUserByEmail(email); // DB lookup

    if (!user || !(await bcrypt.compare(password, user.password))) {
      return res.status(401).json({ error: 'Invalid credentials' });
    }

    const token = generateToken(user);
    res.json({ token, user: { id: user.id, name: user.name } });
  } catch (err) {
    res.status(500).json({ error: 'Server error' });
  }
});

// Auth middleware — protects routes
function authenticate(req, res, next) {
  const authHeader = req.headers.authorization;
  if (!authHeader || !authHeader.startsWith('Bearer ')) {
    return res.status(401).json({ error: 'No token provided' });
  }

  const token = authHeader.split(' ')[1];
  try {
    // jwt.verify CHECKS the signature and the expiry.
    // jwt.decode would read the payload and believe anything — never use it here.
    req.user = jwt.verify(token, JWT_SECRET);
    return next();
  } catch (err) {
    return res.status(401).json({ error: 'Invalid or expired token' });
  }
}

// Protected route
app.get('/api/profile', authenticate, (req, res) => {
  res.json({ user: req.user });
});

// Admin-only middleware
function requireAdmin(req, res, next) {
  if (req.user.role !== 'admin') {
    return res.status(403).json({ error: 'Admin access required' });
  }
  next();
}

app.get('/api/admin/users', authenticate, requireAdmin, (req, res) => {
  res.json({ message: 'Admin dashboard' });
});
  • header.payload.signature — three base64 parts joined by dots
  • Signed, not encrypted — anyone can read the payload, so keep secrets out of it
  • jwt.verify checks the signature; jwt.decode checks nothing at all
  • expiresIn is mandatory — a token without it is a password you cannot withdraw
  • The secret comes from process.env, is long and random, and has no fallback
  • Authorization: Bearer <token> — the conventional header
  • One middleware verifies and sets req.user; every route below can rely on it
Notes
  • Never write process.env.JWT_SECRET || 'some-default'. It looks like a considerate fallback, and what it actually does is let a misconfigured deployment start up perfectly while signing tokens with a secret that is published in your public repository — so anybody who reads your code can mint a valid administrator token. Read the variable, and if it is absent, refuse to start.

Authorisation: Roles and Ownership

Authentication asks who you are. Authorisation asks what you are allowed to do. They are separate steps and separate middlewares, and conflating them is how an endpoint ends up thoroughly protected against strangers and wide open to every logged-in user in the system.

A role check is a small middleware that runs after authentication and reads req.user.role. Chain them — router.get('/admin/users', authenticate, requireRole('admin'), handler) — so the intent is visible on the route line itself. Deny by default: write the checks so that anything new is restricted until you deliberately open it, rather than discovering next month which routes were forgotten.

The far more common flaw is ownership rather than role. A logged-in user requesting /api/tasks/91 should not receive a task belonging to somebody else, and Task.findById(req.params.id) hands it over without hesitation, because it was never asked who wanted it. Put the owner into the query, as the previous lesson showed, so another person's record is simply not found. This is broken access control, and it is the flaw most often present in a student project that otherwise looks complete.

If the role lives inside the JWT payload, remember that the payload was fixed at login. Demote an administrator today and their existing token still claims admin until it expires. For anything that genuinely matters, look the current role up rather than trusting a claim that could be hours old — which is one more reason short expiries are worth the inconvenience they cause.

One last detail: answer 404, not 403, for a record that exists but is not yours. A 403 confirms the record exists, which is information you did not intend to give away. Keep 403 for the cases where the client already knows the thing exists and simply may not act on it.

Example
// Authentication: who are you?   (sets req.user, or 401)
function authenticate(req, res, next) { /* verifies the JWT — see above */ }

// Authorisation by role: are you allowed?   (403, not 401)
function requireRole(...allowed) {
  return (req, res, next) => {
    if (!allowed.includes(req.user.role)) {
      return res.status(403).json({ error: 'Not permitted' });
    }
    return next();
  };
}

router.get('/admin/users', authenticate, requireRole('admin'), listAllUsers);

// Authorisation by ownership: the far more common gap
router.get('/tasks/:id', authenticate, async (req, res) => {
  // WRONG — hands anyone's task to anyone who happens to be logged in
  // const task = await Task.findById(req.params.id);

  // RIGHT — ownership is part of the query itself
  const task = await Task.findOne({ _id: req.params.id, owner: req.user.id });
  if (!task) return res.status(404).json({ error: 'Task not found' });
  res.json(task);
});

// A role inside a token was decided at LOGIN time.
// Demoting a user does not change the token they are already holding.
const fresh = await User.findById(req.user.id).select('role');
if (fresh.role !== 'admin') return res.status(403).json({ error: 'Not permitted' });
  • Authentication sets req.user; authorisation decides what that user may do
  • 401 means we do not know you; 403 means we know you and the answer is still no
  • Chain the checks — authenticate, requireRole('admin'), handler
  • Ownership belongs in the query, never in an if after the fact
  • A role inside a token is exactly as old as the token; look it up when it matters
  • 404, not 403, for a record that exists but does not belong to the caller
Notes
  • Test your own authorisation the way an attacker would, which takes two minutes. Log in as one user and create something. Log in as a second user and request the first one's id directly. If the data comes back, you have the most common serious bug in student projects — and it is fixed by one extra condition in a query.

Auth Mistakes That Cost Marks and Users

The hardcoded fallback secret comes first because it is the most common of all. process.env.JWT_SECRET || 'your-secret-key' in a public repository means anybody who reads your code can sign a token your server will happily accept. The fix is one line — refuse to start when the variable is missing — and it is the difference between a project that looks secure and one that is.

No expiry and no logout story is the next. If your answer to "how does a user log out?" is "the frontend deletes the token", that is not logout; it is the frontend agreeing to forget. The token still works for anybody who copied it. Short expiries, refresh tokens, or a server-side deny-list are the real answers — choose one deliberately and be able to explain why.

Leaking the user object is quieter and just as bad. res.json(user) after a successful login sends whatever that document contains, password hash included. Map to a public shape, as lesson 13 argued, and add select: false to the password field so that the mistake becomes impossible rather than merely unlikely.

No rate limit on the login route means an attacker can try passwords as fast as your server can answer, and every common password will be found eventually. It is a few lines of configuration, and it is what makes a password policy mean something instead of being a suggestion.

And a handful of smaller ones that each undo everything else in this lesson: hashing passwords with a fast algorithm because it seemed simpler; putting a token in a URL, where it lands in server logs, browser history and referrer headers; trusting a role field sent in the request body; and a registration endpoint that spreads req.body into the new user, which lets anybody register as an administrator.

Example
// A short audit you can run against your own project today

// 1. Is the secret required, with no fallback?
if (!process.env.JWT_SECRET) throw new Error('JWT_SECRET is not set');

// 2. Do tokens expire?
jwt.sign({ id: user._id, role: user.role }, process.env.JWT_SECRET, { expiresIn: '1h' });

// 3. Can a password hash ever leave the server?
//    schema: password: { type: String, required: true, select: false }
res.json({ id: user._id, name: user.name, email: user.email });   // never res.json(user)

// 4. Is the login route rate limited?
app.use('/api/auth/login', rateLimit({ windowMs: 15 * 60 * 1000, max: 10 }));

// 5. Can a client set its own role?
// const user = await User.create({ ...req.body });     // accepts role: 'admin'
const user = await User.create({                         // named fields only
  name: req.body.name,
  email: req.body.email,
  password: req.body.password
});

// 6. Is a token ever in a URL?
// GET /api/tasks?token=eyJhbGciOi...   <- logs, history, referrer headers. Never.
  • No fallback secret — refuse to start when JWT_SECRET is missing
  • Always set expiresIn, and have a real answer for how logout works
  • Never res.json(user) — map to a public shape and use select: false
  • Rate-limit the login route, or your password policy means nothing
  • Never accept role from the request body; never spread req.body into a user
  • Never put a token in a URL — it ends up in logs, history and referrer headers
  • Use a hashing function built for passwords, never MD5 or SHA-256
Notes
  • Run through this list against your own project before you put it on a CV. Every item is a line or two of code, and every one of them is the sort of thing an interviewer looks for precisely because it distinguishes somebody who followed a tutorial from somebody who thought about what the tutorial left out.
Ask AI