Module P-2·27 min read

JWT structure, signing and verification, refresh token rotation, bcrypt cost factors, Express auth middleware, session vs stateless — and OAuth 2.0 social login.

Written by Jatin Jain Saraf · Architect of SupraScan

Module P-2 — Authentication and Authorization

What this module covers: Authentication answers "who are you?" Authorization answers "what are you allowed to do?" These are two distinct concerns that most tutorials conflate. This module covers the complete production authentication stack: bcrypt for password hashing, JWT for stateless tokens, refresh token rotation for long-lived sessions, Express middleware for protecting routes, and role-based access control. By the end you will have a working auth system you can drop into any Node.js API.


Authentication vs Authorization

Before writing any code, get the terminology straight — confusing these two causes real security bugs:

  • Authentication — verifying identity. "Is this really Jatin?" Handled by login, tokens, sessions.
  • Authorization — verifying permission. "Is Jatin allowed to delete this post?" Handled by role checks, ownership checks, policies.

A user can be fully authenticated (we know who they are) and still be unauthorized (they don't have permission for this specific action). Both checks are needed, and they run in that order.


Passwords: Never Store Plaintext

If your database is ever breached, plaintext passwords give attackers instant access to every account — and to every other site where users reused that password. Always hash passwords with a slow, purpose-built algorithm.

bcrypt is the standard. It uses a configurable "cost factor" (work factor) that controls how long hashing takes. The higher the cost, the more computation an attacker needs to brute-force the hash.

npm install bcrypt
npm install -D @types/bcrypt
import bcrypt from 'bcrypt';
 
const SALT_ROUNDS = 12; // cost factor — each +1 doubles the time
 
// Hashing on registration
async function hashPassword(plaintext) {
  return bcrypt.hash(plaintext, SALT_ROUNDS);
}
 
// Verifying on login
async function verifyPassword(plaintext, hash) {
  return bcrypt.compare(plaintext, hash);  // returns true or false
}
 
// Usage
const hash = await hashPassword('mySecret123');
// → '$2b$12$eGqTPh7MqaXBCFk6GCTxfe7Gy3bnLKfVUOmV...' (60 char string)
 
const isValid = await verifyPassword('mySecret123', hash); // true
const isWrong = await verifyPassword('wrongPassword', hash); // false

Cost factor guidelines:

EnvironmentRecommended costApprox. time per hash
Development10~65ms
Production12~250ms
High-security14~1 second

At cost 12, a user waits ~250ms on login — imperceptible. An attacker trying to brute-force a leaked database hash faces 250ms per attempt. At scale, that's the difference between cracking a password in hours vs years.

Timing-safe comparison: bcrypt.compare is timing-safe — it takes the same amount of time regardless of whether the password is correct or not. This prevents timing attacks that infer the correct password by measuring response time differences.


JSON Web Tokens (JWT)

After a user authenticates, you need a way to identify them on subsequent requests without asking for their password again. JWT is the most common stateless solution.

npm install jsonwebtoken
npm install -D @types/jsonwebtoken

JWT Structure

A JWT is a Base64URL-encoded string with three parts separated by dots: header.payload.signature (Base64URL, not standard Base64, because standard Base64's +, /, and = characters aren't safe to put in a URL or header value unescaped)

eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiI0MiIsInJvbGUiOiJ1c2VyIiwiaWF0IjoxNzE2MjkwMDAwLCJleHAiOjE3MTYyOTM2MDB9.abc123...
  • Header — algorithm used (HS256, RS256)
  • Payload — claims: sub (subject/userId), role, iat (issued at), exp (expiry)
  • Signature — HMAC of header+payload using your secret key

The payload is Base64-encoded, not encrypted — anyone can decode and read it. Never put sensitive data (passwords, PII) in the payload. The signature guarantees it has not been tampered with.

Signing and verifying tokens

import jwt from 'jsonwebtoken';
 
const ACCESS_SECRET = process.env.JWT_ACCESS_SECRET;   // min 32 random chars
const REFRESH_SECRET = process.env.JWT_REFRESH_SECRET; // different secret
 
// Sign an access token — short-lived
export function signAccessToken(userId, role) {
  return jwt.sign(
    { sub: userId, role },
    ACCESS_SECRET,
    { expiresIn: '15m' }  // 15 minutes
  );
}
 
// Sign a refresh token — long-lived
export function signRefreshToken(userId) {
  return jwt.sign(
    { sub: userId },
    REFRESH_SECRET,
    { expiresIn: '7d' }   // 7 days
  );
}
 
// Verify a token — throws if invalid or expired
export function verifyAccessToken(token) {
  return jwt.verify(token, ACCESS_SECRET);
  // Returns decoded payload, e.g. { sub: 42, role: 'admin', iat: ..., exp: ... }
  // Throws JsonWebTokenError if invalid
  // Throws TokenExpiredError if expired
}

JWT Algorithm-Confusion Attacks

jwt.verify(token, ACCESS_SECRET) above works, but omitting the algorithms option is itself a known vulnerability class. jsonwebtoken will accept whatever algorithm the token's own header claims. If your codebase ever also handles RS256 tokens (signed with an RSA private key, verified with the corresponding public key) anywhere, an attacker can craft a token signed with HMAC using that public key as the secret — public keys are, by definition, public — and it will pass verification if the code trusts the header's algorithm. This is not theoretical; it is a real CVE-class bug that has hit multiple JWT libraries and frameworks over the years.

The fix costs one line — pin the algorithm explicitly instead of trusting the token to declare it:

export function verifyAccessToken(token) {
  return jwt.verify(token, ACCESS_SECRET, { algorithms: ['HS256'] });
}

Apply this to every jwt.verify call in the codebase, not just this one — it tells the library "only ever trust HS256 signatures," full stop, regardless of what the token header says.

# .env
JWT_ACCESS_SECRET=your-very-long-random-string-at-least-32-characters
JWT_REFRESH_SECRET=another-completely-different-random-string

Generate secure secrets:

node -e "console.log(require('crypto').randomBytes(64).toString('hex'))"

Why Two Tokens?

Analogy: think of the access token as a hotel keycard that demagnetizes itself every 15 minutes, while the refresh token is the entry in the front desk's register that decides whether housekeeping will cut you a new card at all.

A single long-lived token is a security liability — if stolen, the attacker has access for days or weeks. The two-token pattern mitigates this:

  • Access token — short-lived (15 min). Sent with every API request. Verified entirely from the signature — no database lookup needed. If stolen, expires quickly.
  • Refresh token — long-lived (7 days). Stored securely. Used only to get a new access token when the old one expires. Can be invalidated by deleting it from the database.
Client                    API Server
  │                            │
  │──── POST /auth/login ──────►│
  │◄─── { accessToken } + Set-Cookie: refreshToken (HttpOnly) ─│
  │                            │
  │──── GET /posts (Authorization: Bearer <accessToken>) ──►│
  │◄─── 200 posts ─────────────│
  │                            │
  │   (15 minutes later, accessToken expires)
  │                            │
  │──── POST /auth/refresh (refreshToken cookie sent automatically) ──►│
  │◄─── { newAccessToken } + Set-Cookie: refreshToken (rotated) ────│

The Complete Auth Flow

User registration

// src/services/auth.service.js
import bcrypt from 'bcrypt';
import jwt from 'jsonwebtoken';
import { signAccessToken, signRefreshToken } from '../utils/jwt.js';
import * as usersRepo from '../repositories/users.repository.js';
import * as tokensRepo from '../repositories/tokens.repository.js';
import { AppError } from '../errors/AppError.js';
 
const SALT_ROUNDS = 12;
 
export async function register({ name, email, password }) {
  // Check for existing user
  const existing = await usersRepo.findByEmail(email);
  if (existing) throw new AppError('Email already registered', 409);
 
  // Hash the password
  const passwordHash = await bcrypt.hash(password, SALT_ROUNDS);
 
  // Create the user
  const user = await usersRepo.create({ name, email, passwordHash });
 
  // Issue tokens
  const accessToken = signAccessToken(user.id, user.role);
  const refreshToken = signRefreshToken(user.id);
 
  // Store refresh token (so we can invalidate it later)
  await tokensRepo.create({ userId: user.id, token: refreshToken });
 
  // refreshToken is returned to the controller only — it sets the HttpOnly
  // cookie and must NOT forward it into the JSON response body (see Security Checklist)
  return { user: { id: user.id, name: user.name, email: user.email }, accessToken, refreshToken };
}

User login

export async function login({ email, password }) {
  // Find the user
  const user = await usersRepo.findByEmail(email);
  if (!user) throw new AppError('Invalid email or password', 401);
  // Note: same error for "user not found" and "wrong password"
  // Never tell attackers which one it is
 
  // Verify password — guard against passwordHash being null (an OAuth-only
  // account has no password yet), since bcrypt.compare throws on a non-string
  // hash rather than just returning false
  const isValid = user.passwordHash && await bcrypt.compare(password, user.passwordHash);
  if (!isValid) throw new AppError('Invalid email or password', 401);
 
  // Issue new tokens
  const accessToken = signAccessToken(user.id, user.role);
  const refreshToken = signRefreshToken(user.id);
 
  await tokensRepo.create({ userId: user.id, token: refreshToken });
 
  // Same rule as register(): refreshToken goes to the controller for cookie-setting,
  // never directly into the JSON body sent to the client
  return { user: { id: user.id, name: user.name, email: user.email }, accessToken, refreshToken };
}

Token refresh

export async function refresh(incomingRefreshToken) {
  // Verify the refresh token is valid and not expired
  let payload;
  try {
    payload = jwt.verify(incomingRefreshToken, process.env.JWT_REFRESH_SECRET);
  } catch {
    throw new AppError('Invalid or expired refresh token', 401);
  }
 
  // Check it exists in the database (not been revoked)
  const stored = await tokensRepo.findByToken(incomingRefreshToken);
  if (!stored) throw new AppError('Refresh token revoked', 401);
 
  // Rotation: delete old token, issue new pair
  await tokensRepo.deleteByToken(incomingRefreshToken);
 
  const user = await usersRepo.findById(payload.sub);
  const newAccessToken = signAccessToken(user.id, user.role);
  const newRefreshToken = signRefreshToken(user.id);
 
  await tokensRepo.create({ userId: user.id, token: newRefreshToken });
 
  return { accessToken: newAccessToken, refreshToken: newRefreshToken };
}

Refresh token rotation: every time a refresh token is used, it is deleted and a new one is issued. If a stolen refresh token is used, the legitimate user's next refresh will fail (their token was also invalidated). This is how you detect token theft.

Sign in to keep reading

The rest of this module is free — sign in with Google to unlock it and track your progress.

Sign in & Register

Discussion

0

Join the discussion

Loading comments...

© 2026 Jatin Jain Saraf (JJS). All rights reserved.