Authentication

Let users sign up and sign in securely: hash passwords with scrypt, compare them safely, issue signed tokens or sessions, store them in secure cookies, protect routes with middleware, and understand when to use sessions, JWTs or an identity provider.

Lesson 19 of 43Advanced18 min read

Most applications need to know who is making a request. Authentication answers "who are you?", usually by checking a password, while authorisation answers "what are you allowed to do?", such as whether this user can delete products. Getting authentication wrong has severe consequences: leaked passwords, hijacked accounts and data breaches. Fortunately, the correct techniques are well established, and Node's built-in crypto module provides the tools.

The first rule is that you never store passwords. You store a hash: the output of a slow, one-way function that can't be reversed. When someone signs in, you hash what they typed and compare it with the stored hash. Even if your database leaks, attackers don't get the passwords.

After a successful sign-in, the server gives the client proof of identity for later requests: either a session ID stored in a cookie, which points to session data on the server, or a signed token such as a JWT, which carries the user's details and a signature the server can verify. In this lesson you'll hash and verify passwords, sign and verify tokens, set secure cookies, protect routes with middleware and add role checks.

Hashing passwords

JavaScript
import { scrypt, randomBytes, timingSafeEqual } from 'node:crypto';
import { promisify } from 'node:util';
const scryptAsync = promisify(scrypt);

async function hashPassword(password) {
  const salt = randomBytes(16).toString('hex');
  const hash = await scryptAsync(password, salt, 64);
  return `${salt}:${hash.toString('hex')}`;
}
  • A salt is random data stored with each hash, so two users with the same password get different hashes, and precomputed tables of common passwords are useless.
  • Password hashes must be slow on purpose: scrypt, bcrypt and argon2 are designed for this. Fast hashes such as SHA-256 alone can be guessed billions of times a second.
  • Compare hashes with timingSafeEqual, which takes the same time whether the first or last byte differs, so attackers can't learn anything from response times.

Sessions and cookies

With sessions, the server stores who is signed in, and the browser holds only a random session ID in a cookie:

JavaScript
res.cookie('sid', sessionId, {
  httpOnly: true,     // JavaScript in the page can't read it
  secure: true,       // only sent over HTTPS
  sameSite: 'lax',    // not sent with most cross-site requests
  maxAge: 7 * 24 * 60 * 60 * 1000,
});

Sessions are simple and easy to revoke: delete the session and the user is signed out everywhere. They need shared storage, such as Redis or the database, when you run several servers. The express-session package handles the details.

Signed tokens and JWTs

A JSON Web Token has three parts: a header, a payload of claims such as { "sub": "42", "role": "admin", "exp": 1767225600 }, and a signature. The server signs with a secret key, so any change to the payload makes the signature invalid. Tokens are verified without a database lookup, which suits APIs used by mobile apps and other services.

  • The payload is encoded, not encrypted: anyone can read it, so never put secrets in it.
  • Always set an expiry; keep access tokens short-lived (minutes) and use a refresh token to get new ones.
  • Use a well-tested library such as jose or jsonwebtoken, and keep the signing secret in an environment variable.

Node.js Advanced Syntax

Auth building blocks

import { scrypt, randomBytes, timingSafeEqual, createHmac } from 'node:crypto';
const salt = randomBytes(16).toString('hex');
const hash = await scryptAsync(password, salt, 64);
timingSafeEqual(hashA, hashB);
res.cookie('sid', id, { httpOnly: true, secure: true, sameSite: 'lax' });
res.clearCookie('sid');
const sig = createHmac('sha256', process.env.TOKEN_SECRET).update(data).digest('base64url');
// with the jose library:
const token = await new SignJWT({ role }).setProtectedHeader({ alg: 'HS256' }).setSubject(id).setExpirationTime('15m').sign(key);

Node.js Advanced Summary

  • Authentication checks who a user is; authorisation checks what they may do.
  • Store salted, slow hashes (scrypt, bcrypt, argon2), never passwords; compare with timingSafeEqual.
  • Sessions keep state on the server and a random ID in an HttpOnly, Secure, SameSite cookie.
  • JWTs carry signed, readable claims; set expiries and never put secrets in them.
  • Protect routes with middleware that verifies the session or token and checks roles.

Node.js Advanced Examples

Each example shows the code, the output it produces and what each part does.

Example 1

Hashing and verifying passwords

Node.js
import { scrypt, randomBytes, timingSafeEqual } from 'node:crypto';
import { promisify } from 'node:util';

const scryptAsync = promisify(scrypt);

async function hashPassword(password) {
  const salt = randomBytes(16).toString('hex');
  const derived = await scryptAsync(password, salt, 64);
  return `${salt}:${derived.toString('hex')}`;
}

async function verifyPassword(password, stored) {
  const [salt, hashHex] = stored.split(':');
  const derived = await scryptAsync(password, salt, 64);
  return timingSafeEqual(derived, Buffer.from(hashHex, 'hex'));
}

const stored1 = await hashPassword('correct horse battery staple');
const stored2 = await hashPassword('correct horse battery staple');

console.log(`Stored value length: ${stored1.length}`);
console.log(`Same password, same hash? ${stored1 === stored2}`);
console.log(`Right password: ${await verifyPassword('correct horse battery staple', stored1)}`);
console.log(`Wrong password: ${await verifyPassword('Correct horse battery staple', stored1)}`);
Output
Stored value length: 161
Same password, same hash? false
Right password: true
Wrong password: false
Line-by-line explanation
  1. Line 7A new random salt for every password.
  2. Line 8scrypt is deliberately slow and memory-hungry, which makes guessing expensive.
  3. Line 9The salt is stored alongside the hash; it isn't secret.
  4. Line 15timingSafeEqual compares without leaking timing information.
  5. Line 22Different salts give different stored values for the same password.
  6. Line 24Even a one-letter difference fails.
Example 2

Signing and verifying a token

Node.js
import { createHmac, timingSafeEqual } from 'node:crypto';

const SECRET = 'demo-secret-from-env';
const b64 = (obj) => Buffer.from(JSON.stringify(obj)).toString('base64url');

function sign(payload) {
  const body = `${b64({ alg: 'HS256', typ: 'JWT' })}.${b64(payload)}`;
  const sig = createHmac('sha256', SECRET).update(body).digest('base64url');
  return `${body}.${sig}`;
}

function verify(token, now) {
  const [header, payload, sig] = token.split('.');
  const expected = createHmac('sha256', SECRET).update(`${header}.${payload}`).digest('base64url');
  if (sig.length !== expected.length || !timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) {
    throw new Error('Bad signature');
  }
  const claims = JSON.parse(Buffer.from(payload, 'base64url').toString());
  if (claims.exp < now) throw new Error('Token expired');
  return claims;
}

const token = sign({ sub: '42', role: 'customer', exp: 1_800_000_000 });
console.log(token.split('.').length);
console.log(verify(token, 1_700_000_000));

const [h, p, s] = token.split('.');
const forged = `${h}.${b64({ sub: '42', role: 'admin', exp: 1_800_000_000 })}.${s}`;
for (const [label, t, now] of [['forged', forged, 1_700_000_000], ['expired', token, 1_900_000_000]]) {
  try {
    verify(t, now);
  } catch (err) {
    console.log(`${label}: ${err.message}`);
  }
}
Output
3
{ sub: '42', role: 'customer', exp: 1800000000 }
forged: Bad signature
expired: Token expired
Line-by-line explanation
  1. Lines 6 to 10A JWT is a header and payload, base64url-encoded, plus an HMAC signature made with a secret.
  2. Line 15The signature is recomputed and compared; any change to the payload breaks it.
  3. Line 19The expiry claim is checked on every request.
  4. Line 23Real projects use a library such as jose, which also checks the algorithm and other claims.
  5. Lines 27 to 28Changing the role to admin without the secret produces an invalid signature.
Example 3

Session cookies protecting routes

Node.js
import express from 'express';
import { randomBytes } from 'node:crypto';

const users = { ada: { password: 'pw-ada', role: 'admin' }, ben: { password: 'pw-ben', role: 'customer' } };
const sessions = new Map();

const app = express();
app.use(express.json());

app.post('/login', (req, res) => {
  const user = users[req.body.username];
  if (!user || user.password !== req.body.password) {
    return res.status(401).json({ error: 'Wrong username or password' });
  }
  const sid = randomBytes(24).toString('base64url');
  sessions.set(sid, { username: req.body.username, role: user.role });
  res.cookie('sid', sid, { httpOnly: true, secure: true, sameSite: 'lax', maxAge: 3600_000 });
  res.json({ ok: true });
});

function requireUser(req, res, next) {
  const sid = req.get('Cookie')?.match(/sid=([\w-]+)/)?.[1];
  req.user = sessions.get(sid);
  if (!req.user) return res.status(401).json({ error: 'Please sign in' });
  next();
}

app.get('/account', requireUser, (req, res) => res.json({ hello: req.user.username }));
app.get('/admin', requireUser, (req, res) =>
  req.user.role === 'admin' ? res.json({ secret: 'sales report' }) : res.status(403).json({ error: 'Forbidden' }));

const server = app.listen(0, async () => {
  const base = `http://localhost:${server.address().port}`;
  const login = (username, password) => fetch(`${base}/login`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username, password }) });
  console.log((await login('ben', 'nope')).status);
  const r = await login('ben', 'pw-ben');
  const cookie = r.headers.get('set-cookie');
  console.log(cookie.includes('HttpOnly'), cookie.includes('Secure'), cookie.includes('SameSite=Lax'));
  const sid = cookie.split(';')[0];
  console.log((await fetch(`${base}/account`)).status);
  console.log(await (await fetch(`${base}/account`, { headers: { Cookie: sid } })).text());
  console.log((await fetch(`${base}/admin`, { headers: { Cookie: sid } })).status);
  server.close();
});
Output
401
true true true
401
{"hello":"ben"}
403
Line-by-line explanation
  1. Line 4Plain passwords are used only to keep the demo short; real code stores scrypt hashes as shown earlier.
  2. Line 13The same message for a wrong username or password avoids revealing which accounts exist.
  3. Line 15The session ID is long and random, so it can't be guessed.
  4. Line 17HttpOnly, Secure and SameSite protect the cookie from scripts, plain HTTP and cross-site requests.
  5. Lines 21 to 26Middleware finds the session for the cookie and attaches the user to req.
  6. Lines 29 to 30Authorisation is a separate check: signed in, but not an admin, gives 403.

Common Node.js Advanced Mistakes

Storing passwords or fast hashes

Don't
await db.users.insert({ email, password });                                   // plain text
await db.users.insert({ email, hash: createHash('sha256').update(password).digest('hex') });   // too fast, no salt
Do
await db.users.insert({ email, hash: await hashPassword(password) });          // salted scrypt

Secrets in a token payload

Don't
const token = jwt.sign({ id, email, passwordHash, creditCard }, secret);      // anyone can read the payload
Do
const token = jwt.sign({ sub: id, role }, secret, { expiresIn: '15m' });      // minimal claims, short expiry

Different errors for unknown users and wrong passwords

Don't
if (!user) return res.status(404).json({ error: 'No account with that email' });   // reveals who has an account
Do
if (!user || !(await verifyPassword(password, user.hash))) {
  return res.status(401).json({ error: 'Wrong email or password' });
}

Node.js Advanced in Real Projects

Authentication in production

Production apps hash passwords with argon2 or bcrypt, rate-limit sign-in attempts, offer two-factor authentication, and send password resets as single-use, expiring links. Browser apps usually use session cookies, while mobile apps and service-to-service calls use short-lived access tokens with refresh tokens. Many teams hand the whole problem to an identity provider and only verify its tokens in their API.

Node.js Advanced FAQ

What is the difference between authentication and authorisation?

Authentication answers "who are you?", usually by checking a password, a passkey or a sign-in from another provider. Authorisation answers "what are you allowed to do?", such as whether this user may delete that order. A request needs both: first identify the user, then check their permissions on every protected route.

Should I store a JWT in localStorage or in a cookie?

For browser apps, an httpOnly, secure, sameSite cookie is usually safer, because JavaScript on the page can't read it, so an XSS bug can't simply copy the token. Anything in localStorage can be read by any script running on your page. Cookies bring their own risk of cross-site request forgery, which the sameSite setting largely handles.

Which is better for passwords: bcrypt, scrypt or argon2?

All three are designed for passwords and are far better than plain SHA-256. Argon2id is the current recommendation of OWASP's password storage guidance, scrypt is built into Node's crypto module, and bcrypt is the oldest and most widely supported. Use whichever your stack supports well, with settings strong enough that hashing takes a noticeable fraction of a second.

How do I log a user out when using JWTs?

A signed token stays valid until it expires, because the server doesn't look it up. Keep access tokens short-lived, delete the token or cookie on the client, and revoke the refresh token on the server so no new access tokens can be issued. If you need instant logout everywhere, sessions stored on the server are simpler.

Do I still need to hash passwords if my site uses HTTPS?

Yes. HTTPS protects the password while it travels over the network; hashing protects it while it's stored. If your database is ever leaked, properly hashed passwords are very hard to recover, whereas plain or encrypted ones expose every user, including on other sites where they reused the same password.

Node.js Advanced Exercises

Answer each one and check it instantly. If you get stuck, open the hint before the answer.

Fill in the blankseasy

Complete the cookie option that stops page scripts from reading the cookie.

res.cookie('sid', id, { : true, secure: true, sameSite: 'lax' });
Multiple choiceeasy

What should a database store for each user's password?

Multiple choicemedium

What's the difference between authentication and authorisation?

Multiple choicemedium

Can anyone read the payload of a JWT?

Multiple choicehard

Why does each password hash use its own random salt?

Node.js Advanced Quiz

5 questions to check what you learned.

  1. Which status code means "not signed in"?

    • 403
    • 404
    • 500
    • 401
  2. What does the Secure cookie flag do?

    • Sends the cookie only over HTTPS
    • Encrypts the cookie
    • Hides it from JavaScript
    • Makes it permanent
  3. Why should password hashing be slow?

    • To save memory
    • To make guessing passwords from a leaked database very expensive
    • Because crypto is slow
    • It shouldn't be
  4. What's an advantage of server-side sessions over JWTs?

    • No storage needed
    • They never expire
    • They can be revoked instantly by deleting the session
    • They work without cookies
  5. Why compare hashes with timingSafeEqual instead of ===?

    • === doesn't work on Buffers
    • It's faster
    • It decrypts the hash
    • It takes the same time however many bytes match, so response times reveal nothing