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.
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.
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 }
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.
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.
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)
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.
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:
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.
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) ────│
// 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 });
// 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 }; }
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);
// 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 }; }
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);
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.
Production story: on a UPI-scale payments rail, refresh-token rotation once caught real theft. A partner bank's integration accidentally logged full refresh tokens in a shared observability tool, and within the hour a script replayed one. Both the attacker's and the legitimate client's next refresh raced for the same now-deleted token; the collision paged on-call before the customer noticed. The postmortem is why the team added a token-family kill switch — revoking every token descended from a compromised one — instead of deleting just the single reused token.
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 }
Two more flows round out a production auth system. Both follow the same shape: generate a single-use, expiring token, email it, and require it back before doing anything sensitive.
export async function requestPasswordReset(email) { const user = await usersRepo.findByEmail(email); // Respond the same way whether or not the user exists — don't leak // which emails are registered (same principle as the login error above) if (!user) return;
const rawToken = randomBytes(32).toString('hex'); const tokenHash = createHash('sha256').update(rawToken).digest('hex'); // store the hash, never the raw token
// Single-use: delete the token immediately, and invalidate every existing // refresh token for this user so a stolen password can't ride an old session await passwordResetTokensRepo.deleteByHash(tokenHash); await tokensRepo.deleteAllForUser(stored.userId); }
Three things make this safe: the token is random and long enough to be unguessable, it is stored hashed (a database leak doesn't hand out working reset tokens), and it is deleted after exactly one use.
// The user can usually still log in immediately — most APIs gate specific // actions (posting, payouts) on emailVerified rather than blocking login entirely return user; }
Whether an unverified user can log in at all, or only gets a reduced set of permissions, is a product decision — but the token mechanics (random, hashed, expiring, single-use) are identical to password reset.
P-6 covers IP-based rate limiting on /auth/login — throttling requests from a single address. That catches a script hammering the endpoint from one IP, but does nothing against credential stuffing spread across a botnet, where each attacker IP only tries a handful of passwords. The complementary control is account-level lockout: track failed attempts per account, regardless of source IP, and lock the account after too many.
export async function login({ email, password }) { const user = await usersRepo.findByEmail(email); if (!user) throw new AppError('Invalid email or password', 401);
if (user.lockedUntil && user.lockedUntil > new Date()) { throw new AppError('Account temporarily locked due to repeated failed logins', 423); }
This needs failedLoginAttempts and lockedUntil columns on users. Keep the error message generic (423 Locked with no detail on the remaining wait time) so an attacker can't use it to enumerate which accounts are close to locking. Combine this with the IP-based rate limiter from P-6 — one stops a single source from brute-forcing many accounts, the other stops many sources from brute-forcing one account.
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.
// 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(); }; }
// 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);
RBAC is not enough for "users can only edit their own posts". That requires an ownership check in the service:
// 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'); }
// src/routes/auth.routes.js import { Router } from 'express'; import * as authController from '../controllers/auth.controller.js'; import { authenticate } from '../middleware/auth.js';
// src/controllers/auth.controller.js import * as authService from '../services/auth.service.js';
// Sets the refresh token as an HttpOnly cookie instead of returning it in the // JSON body — client-side JS can never read it, which closes off the XSS // theft path called out in the Security Checklist below. function setRefreshTokenCookie(res, token) { res.cookie('refreshToken', token, { httpOnly: true, // JS cannot read this cookie secure: process.env.NODE_ENV === 'production', // HTTPS only in production sameSite: 'strict', // not sent on cross-site requests maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days — matches refresh token expiry path: '/auth', // only sent to /auth/* routes }); }
Everything so far assumes the user has a password with you. Social login — "Sign in with Google" — delegates identity verification to a provider that already knows the user, and hands your API back proof of who they are. It plugs into the exact JWT system already built: once we know who the user is, we issue our own accessToken/refreshToken pair exactly as register() and login() do above. Google (or GitHub) never sees our tokens, and we never see the user's Google password.
Browser Your API Google │ │ │ │── GET /auth/google ────►│ │ │ │── redirect ────────────►│ (with client_id, redirect_uri, scope, state) │◄─────────── 302 to accounts.google.com ────────────│ │ │ │ │── user logs in / consents on Google's own page ───►│ │ │ │ │◄── 302 to redirect_uri?code=...&state=... ─────────│ │── GET /auth/google/callback?code=...&state=... ──►│ │ │── POST code ───────────►│ (server-to-server, over HTTPS, │ │ │ includes client_secret) │ │◄── access_token ─────────│ │ │── GET profile ──────────►│ │ │◄── { email, name, sub } ─│ │ │ (find-or-create user, issue OUR OWN JWTs) │◄── redirect with our accessToken + refresh cookie ─│
Two details matter more than the rest of the diagram:
Redirect URI — the URL Google sends the browser back to (/auth/google/callback) must be registered exactly (scheme, host, path) in the Google Cloud Console for your OAuth client. Google refuses to redirect anywhere else. This is what stops an attacker from registering their own app and pointing your users' authorization codes at a server they control.
state parameter — a random value your server generates before redirecting to Google, and checks on the way back. Without it, an attacker can start their own OAuth flow, capture the resulting code, and trick a victim's browser into hitting your callback URL with the attacker's code — logging the victim into the attacker's account (a CSRF-shaped login attack, sometimes called "login CSRF"). Verifying state matches what you issued closes that hole.
This module has consistently shown the raw mechanics before reaching for a library (bcrypt calls directly, jsonwebtoken directly), so here is OAuth the same way — no framework magic, five explicit steps:
// src/controllers/oauth.controller.js import { randomBytes } from 'crypto'; import { GOOGLE_AUTH_URL, GOOGLE_TOKEN_URL, GOOGLE_USERINFO_URL } from '../config/oauth.js'; import { signAccessToken, signRefreshToken } from '../utils/jwt.js'; import * as usersRepo from '../repositories/users.repository.js'; import * as oauthAccountsRepo from '../repositories/oauthAccounts.repository.js'; import * as tokensRepo from '../repositories/tokens.repository.js'; import { AppError } from '../errors/AppError.js';
// Must exactly match the URI registered in Google Cloud Console const REDIRECT_URI = `${process.env.API_BASE_URL}/auth/google/callback`;
// Step 1: send the browser to Google export function googleLogin(req, res) { const state = randomBytes(16).toString('hex');
// Store state server-side via a short-lived, signed cookie so we can verify // it on the way back — this is the CSRF protection described above res.cookie('oauth_state', state, { httpOnly: true, maxAge: 5 * 60 * 1000, sameSite: 'lax' });
// Step 2–5: Google redirects back here with ?code=...&state=... export async function googleCallback(req, res, next) { try { const { code, state } = req.query;
// Reject if state doesn't match what we set — stops login CSRF if (!state || state !== req.cookies?.oauth_state) { return res.status(400).json({ error: 'Invalid OAuth state' }); } res.clearCookie('oauth_state');
// Step 5: find-or-create in the SAME `users` table the password flow uses // above, then link the Google identity to it let link = await oauthAccountsRepo.findByProvider('google', profile.sub); let user;
if (link) { user = await usersRepo.findById(link.userId); } else { // No linked Google identity yet — does an account with this email // already exist? If so, link Google to it instead of creating a duplicate. user = await usersRepo.findByEmail(profile.email); if (!user) { // Brand-new user, no password — they can only sign in via Google // until they set one (see Password Reset above for that mechanism) user = await usersRepo.create({ name: profile.name, email: profile.email, passwordHash: null }); } await oauthAccountsRepo.create({ userId: user.id, provider: 'google', providerAccountId: profile.sub }); }
// Issue OUR OWN JWTs — Google's tokens never leave this function const accessToken = signAccessToken(user.id, user.role); const refreshToken = signRefreshToken(user.id); await tokensRepo.create({ userId: user.id, token: refreshToken });
res.cookie('refreshToken', refreshToken, { httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'strict', maxAge: 7 * 24 * 60 * 60 * 1000, path: '/auth', }); // same flags as setRefreshTokenCookie() in auth.controller.js — extract to a shared helper if duplicated
// Passing the access token in a redirect query string is simple but leaks // into browser history and referrer headers. A short-lived one-time code // that the frontend immediately exchanges via POST, or a URL fragment // (#accessToken=...) instead of a query string, are safer in production. res.redirect(`${process.env.FRONTEND_URL}/oauth/callback?accessToken=${accessToken}`); } catch (err) { next(err); } }
// src/repositories/oauthAccounts.repository.js import prisma from '../db/prisma.js';
This assumes a small oauthAccounts table with a (provider, providerAccountId) unique constraint and a userId foreign key into the same users table used everywhere else in this module — one user can have zero, one, or several linked providers. It also requires passwordHash to be nullable on users: a Google-only account has no password until they set one. That means the password-based login() from earlier must guard against passwordHash being null before calling bcrypt.compare (which throws on a non-string hash), returning the same generic "Invalid email or password" error rather than revealing that the account only supports Google sign-in.
// src/routes/auth.routes.js (additions) import { googleLogin, googleCallback } from '../controllers/oauth.controller.js';
// src/config/passport.js import passport from 'passport'; import { Strategy as GoogleStrategy } from 'passport-google-oauth20'; import * as usersRepo from '../repositories/users.repository.js'; import * as oauthAccountsRepo from '../repositories/oauthAccounts.repository.js';
passport.use(new GoogleStrategy( { clientID: process.env.GOOGLE_CLIENT_ID, clientSecret: process.env.GOOGLE_CLIENT_SECRET, callbackURL: '/auth/google/callback', }, async (_accessToken, _refreshToken, profile, done) => { try { // Same find-or-create-and-link logic as googleCallback above let link = await oauthAccountsRepo.findByProvider('google', profile.id); let user = link ? await usersRepo.findById(link.userId) : await usersRepo.findByEmail(profile.emails?.[0]?.value);
if (!user) { user = await usersRepo.create({ name: profile.displayName, email: profile.emails?.[0]?.value, passwordHash: null, }); } if (!link) { await oauthAccountsRepo.create({ userId: user.id, provider: 'google', providerAccountId: profile.id }); }
router.get('/google/callback', passport.authenticate('google', { session: false, failureRedirect: '/login?error=oauth_failed' }), (req, res) => { // req.user is set by the strategy above — issue our own JWTs exactly as in googleCallback const accessToken = signAccessToken(req.user.id, req.user.role); const refreshToken = signRefreshToken(req.user.id); tokensRepo.create({ userId: req.user.id, token: refreshToken }); setRefreshTokenCookie(res, refreshToken); res.redirect(`${process.env.FRONTEND_URL}/oauth/callback?accessToken=${accessToken}`); }, );
One catch with session: false: passport-oauth2's default state handling stores the value in req.session, which does not exist in a fully stateless API like this one. Either mount a minimal express-session just for the OAuth handshake (it does not need to touch the rest of the app, which stays JWT-only), or supply a custom store option that persists state the same way the manual version above does with a signed cookie. This is the one place a "stateless" API briefly needs state.
GitHub OAuth follows the identical shape — different authorize/token/profile URLs and a passport-github2 strategy — so the find-or-create-and-link logic above does not change per provider; only the endpoints and profile field names do.
You will encounter both in production. Choosing correctly matters:
JWT (stateless)
Sessions (stateful)
Server state
None — self-contained token
Session store (Redis/DB)
Revocation
Hard — token valid until expiry
Instant — delete session
Horizontal scaling
Trivial — any server can verify
Needs shared session store
Token theft response
Wait for expiry
Delete session immediately
Complexity
Refresh token rotation required
Simpler — 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.