Back/Module P-2 Authentication and Authorization
Module P-2·25 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.

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.

bash
npm install bcrypt npm install -D @types/bcrypt
javascript
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.

bash
npm install jsonwebtoken npm install -D @types/jsonwebtoken

JWT Structure

A JWT is a Base64-encoded string with three parts separated by dots: header.payload.signature

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

javascript
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 }
bash
# .env JWT_ACCESS_SECRET=your-very-long-random-string-at-least-32-characters JWT_REFRESH_SECRET=another-completely-different-random-string

Generate secure secrets:

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

Why Two Tokens?

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, refreshToken } ─│
  │                            │
  │──── GET /posts (Authorization: Bearer <accessToken>) ──►│
  │◄─── 200 posts ─────────────│
  │                            │
  │   (15 minutes later, accessToken expires)
  │                            │
  │──── POST /auth/refresh (refreshToken in body/cookie) ──►│
  │◄─── { newAccessToken } ────│

The Complete Auth Flow

User registration

javascript
// src/services/auth.service.js import bcrypt from 'bcrypt'; 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 }); return { user: { id: user.id, name: user.name, email: user.email }, accessToken, refreshToken }; }

User login

javascript
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 const isValid = 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 }); return { user: { id: user.id, name: user.name, email: user.email }, accessToken, refreshToken }; }

Token refresh

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

Logout

javascript
export async function logout(refreshToken) { await tokensRepo.deleteByToken(refreshToken); // Access tokens cannot be invalidated (they're stateless) // They expire on their own after 15 minutes }

The Authentication Middleware

This middleware extracts the JWT from the Authorization header, verifies it, and attaches the decoded user to req.user.

javascript
// src/middleware/auth.js import { verifyAccessToken } from '../utils/jwt.js'; import { UnauthorizedError } from '../errors/AppError.js'; export function authenticate(req, res, next) { const authHeader = req.headers['authorization']; // Expect: "Authorization: Bearer <token>" if (!authHeader?.startsWith('Bearer ')) { return next(new UnauthorizedError('Authorization header required')); } const token = authHeader.slice(7); // remove "Bearer " try { const payload = verifyAccessToken(token); req.user = { id: payload.sub, role: payload.role }; next(); } catch (err) { if (err.name === 'TokenExpiredError') { return next(new UnauthorizedError('Token expired')); } return next(new UnauthorizedError('Invalid token')); } }

Usage in routes:

javascript
// Protected route — must be logged in router.get('/profile', authenticate, usersController.getProfile); // Public route — no authenticate middleware router.post('/register', authController.register); router.post('/login', authController.login);

Authorization: Role-Based Access Control

Authentication confirms identity. Authorization enforces what they can do. The most common approach is role-based access control (RBAC) — users have a role, roles have permissions.

javascript
// src/middleware/authorize.js // Middleware factory: authorize(allowedRoles) export function authorize(...allowedRoles) { return (req, res, next) => { // authenticate must run before authorize if (!req.user) { return next(new UnauthorizedError()); } if (!allowedRoles.includes(req.user.role)) { return next(new ForbiddenError('Insufficient permissions')); } next(); }; }
javascript
// src/routes/admin.routes.js import { authenticate } from '../middleware/auth.js'; import { authorize } from '../middleware/authorize.js'; // Only admins router.get('/stats', authenticate, authorize('admin'), adminController.getStats); // Admins and moderators router.delete('/posts/:id', authenticate, authorize('admin', 'moderator'), postsController.forceDelete); // Any authenticated user router.get('/feed', authenticate, postsController.getFeed);

Ownership checks

RBAC is not enough for "users can only edit their own posts". That requires an ownership check in the service:

javascript
// src/services/posts.service.js export async function updatePost(postId, updates, requestingUserId) { const post = await postsRepo.findById(postId); if (!post) throw new NotFoundError('Post'); // Ownership check — user can only edit their own posts if (post.authorId !== requestingUserId) { throw new ForbiddenError('You can only edit your own posts'); } return postsRepo.update(postId, updates); }
javascript
// src/controllers/posts.controller.js export async function updatePost(req, res, next) { try { const post = await postsService.updatePost( parseInt(req.params.id), req.body, req.user.id, // from authenticate middleware ); res.json(post); } catch (err) { next(err); } }

Ownership logic lives in the service, not the route or middleware. It has access to the full business context.


Auth Routes

javascript
// src/routes/auth.routes.js import { Router } from 'express'; import * as authController from '../controllers/auth.controller.js'; import { authenticate } from '../middleware/auth.js'; const router = Router(); router.post('/register', authController.register); router.post('/login', authController.login); router.post('/refresh', authController.refresh); router.post('/logout', authenticate, authController.logout); export default router;
javascript
// src/controllers/auth.controller.js import * as authService from '../services/auth.service.js'; export async function register(req, res, next) { try { const result = await authService.register(req.body); res.status(201).json(result); } catch (err) { next(err); } } export async function login(req, res, next) { try { const result = await authService.login(req.body); res.json(result); } catch (err) { next(err); } } export async function refresh(req, res, next) { try { const { refreshToken } = req.body; if (!refreshToken) return res.status(400).json({ error: 'refreshToken required' }); const tokens = await authService.refresh(refreshToken); res.json(tokens); } catch (err) { next(err); } } export async function logout(req, res, next) { try { const { refreshToken } = req.body; if (refreshToken) await authService.logout(refreshToken); res.status(204).send(); } catch (err) { next(err); } }

Session-Based Auth vs Stateless JWT

You will encounter both in production. Choosing correctly matters:

JWT (stateless)Sessions (stateful)
Server stateNone — self-contained tokenSession store (Redis/DB)
RevocationHard — token valid until expiryInstant — delete session
Horizontal scalingTrivial — any server can verifyNeeds shared session store
Token theft responseWait for expiryDelete session immediately
ComplexityRefresh token rotation requiredSimpler — one session ID

Use JWT when: you have multiple services, horizontal scaling, or mobile clients. The statelessness simplifies architecture.

Use sessions when: you need instant revocation (e.g. "log out all devices"), you have a monolith, or your team is more familiar with sessions. Express + express-session + Redis is the standard stack.

For most modern APIs (especially mobile or multi-service), JWT with refresh token rotation is the right choice.


Security Checklist

Before shipping auth:

  • Passwords hashed with bcrypt, cost factor ≥ 12
  • JWT secrets are long (≥ 32 bytes), random, and different for access vs refresh
  • Access tokens expire in ≤ 15 minutes
  • Refresh tokens are stored in the database and rotated on use
  • Login returns the same error for "wrong email" and "wrong password" (prevents user enumeration)
  • Rate limiting on /auth/login and /auth/register (covered in P-6)
  • HTTPS enforced in production — tokens in plaintext over HTTP are useless
  • Refresh tokens sent in HttpOnly cookies rather than response body (prevents XSS theft)
  • Authorization header checked with startsWith('Bearer '), not split/regex

Summary

  • Passwords: always bcrypt with cost ≥ 12. Never MD5, SHA1, or plaintext. bcrypt.compare is timing-safe.
  • JWT: header.payload.signature. Payload is readable — never put secrets in it. Signature prevents tampering.
  • Two-token pattern: short-lived access tokens (15 min, stateless), long-lived refresh tokens (7 days, stored in DB). Rotation on use detects theft.
  • authenticate middleware extracts the Bearer token, verifies it, attaches req.user. Run it on every protected route.
  • Authorization: authorize('admin') middleware for role checks. Ownership checks belong in the service layer.
  • Same error message for "user not found" and "wrong password" — never reveal which one failed.
  • JWT vs sessions: JWT for multi-service/mobile, sessions for instant revocation in monoliths.

Next: TypeScript in Node.js — adding type safety to everything you have built so far.

Discussion