Two distinct concerns that are often conflated. Authentication proves who you are; authorisation determines what you can do. Every secure system needs both.
401 Unauthorized = unauthenticated403 Forbidden = unauthorisedAuthN at the front door: login forms, API key validation, SSO callbacks, token verification middleware. AuthZ at every resource: route guards, database row-level security, feature flags, admin panels. A user can be authenticated but still unauthorised to access a resource.
// Express: AuthN middleware runs first, then AuthZ
app.get('/admin/users', authenticate, authorise('admin'), (req, res) => {
// authenticate() verifies the JWT/session — sets req.user
// authorise('admin') checks req.user.role === 'admin'
res.json(await User.findAll());
});
The classic approach: the server creates a session after login, stores it server-side, and sends a session ID cookie to the browser. Every subsequent request includes the cookie automatically.
const session = require('express-session');
const RedisStore = require('connect-redis').default;
app.use(session({
store: new RedisStore({ client: redisClient }),
secret: process.env.SESSION_SECRET,
resave: false,
saveUninitialized: false,
cookie: { secure: true, httpOnly: true, maxAge: 1800000 } // 30 min
}));
app.post('/login', async (req, res) => {
const user = await User.findByEmail(req.body.email);
if (user && await bcrypt.compare(req.body.password, user.hash)) {
req.session.userId = user.id; // session stored in Redis
return res.redirect('/dashboard');
}
res.status(401).json({ error: 'Invalid credentials' });
});
Simple mental model, easy revocation (delete session), cookie sent automatically, works with any framework.
Server-side state, requires shared store in clustered environments, CSRF-vulnerable if cookies are used.
connect-redis, connect-mongo, connect-pg-simple, memorystore (dev only).
Cookies are the transport layer for session IDs, CSRF tokens, and sometimes JWTs. Understanding every attribute is critical for security.
| Attribute | Purpose | Recommended Value |
|---|---|---|
HttpOnly | Prevents JavaScript access via document.cookie | Always set for auth cookies |
Secure | Cookie only sent over HTTPS | Always set in production |
SameSite | Controls cross-origin cookie sending | Lax (default) or Strict |
Domain | Which domains receive the cookie | Omit to restrict to exact origin |
Path | URL path scope | / for session cookies |
Max-Age / Expires | Lifetime — omit for session cookies (cleared on browser close) | 30 min for sessions, 7-30 days for refresh |
__Host- prefix | Requires Secure, no Domain, path / | Use for highest security cookies |
Set-Cookie: __Host-sid=abc123; Path=/; Secure; HttpOnly; SameSite=Lax; Max-Age=1800
Cookie never sent on cross-site requests. Breaks OAuth redirects and inbound links that expect a logged-in session.
Sent on top-level navigations (GET) but not on cross-site POST/fetch. Good balance of usability and CSRF protection.
Sent on all cross-site requests. Requires Secure. Needed for cross-origin APIs and third-party embeds.
A JWT is a compact, URL-safe token with three Base64URL-encoded parts separated by dots: header.payload.signature. The server can verify the token without storing state.
{
"alg": "HS256",
"typ": "JWT"
}
Declares the signing algorithm and token type.
{
"sub": "user_4821",
"email": "alice@example.com",
"role": "admin",
"iat": 1712505600,
"exp": 1712509200
}
Registered claims: sub, iss, aud, exp, iat, nbf, jti.
HMACSHA256(
base64url(header) + "." +
base64url(payload),
secret
)
Integrity check. Tampered tokens fail verification.
| Algorithm | Type | Key | Use Case |
|---|---|---|---|
HS256 | Symmetric HMAC | Shared secret | Single-server APIs |
RS256 | Asymmetric RSA | Private key signs, public key verifies | Distributed systems, OIDC |
ES256 | Asymmetric ECDSA | Smaller keys, faster signing | Mobile, IoT, performance-critical |
none | No signature | — | NEVER use in production |
A production JWT flow uses short-lived access tokens paired with longer-lived refresh tokens. The access token authorises API calls; the refresh token obtains new access tokens without re-authentication.
const jwt = require('jsonwebtoken');
const SECRET = process.env.JWT_SECRET;
// Issue tokens after successful login
function issueTokens(user) {
const accessToken = jwt.sign(
{ sub: user.id, role: user.role },
SECRET,
{ expiresIn: '15m' }
);
const refreshToken = jwt.sign(
{ sub: user.id, type: 'refresh' },
SECRET,
{ expiresIn: '7d' }
);
return { accessToken, refreshToken };
}
app.post('/login', async (req, res) => {
const user = await authenticate(req.body);
if (!user) return res.status(401).json({ error: 'Bad credentials' });
const tokens = issueTokens(user);
// Store refresh token hash in DB for revocation
await RefreshToken.create({ userId: user.id,
hash: hashToken(tokens.refreshToken) });
res.json(tokens);
});
// Middleware: verify access token
function authenticate(req, res, next) {
const header = req.headers.authorization;
if (!header?.startsWith('Bearer '))
return res.status(401).json({ error: 'No token' });
try {
req.user = jwt.verify(header.slice(7), SECRET);
next();
} catch (err) {
if (err.name === 'TokenExpiredError')
return res.status(401).json({ error: 'Token expired' });
res.status(401).json({ error: 'Invalid token' });
}
}
// Refresh endpoint
app.post('/refresh', async (req, res) => {
const { refreshToken } = req.body;
const payload = jwt.verify(refreshToken, SECRET);
const stored = await RefreshToken.findOne({
userId: payload.sub,
hash: hashToken(refreshToken)
});
if (!stored) return res.status(403).json({ error: 'Revoked' });
const user = await User.findById(payload.sub);
res.json(issueTokens(user));
});
Short-lived (5-15 min), stateless, sent as Authorization: Bearer <token>.
Long-lived (7-30 days), stored server-side (DB/Redis), enables silent re-auth.
Issue a new refresh token on every use. Invalidate the old one. Detects token reuse (theft).
OAuth 2.0 is an authorisation framework that lets a third-party application access resources on behalf of a user without sharing the user's password. It defines several grant types for different scenarios.
client_id + client_secretThe implicit flow returned tokens directly in the URL fragment. It is no longer recommended — use Authorization Code + PKCE instead. URL fragments leak through browser history, referer headers, and proxy logs.
Passport.js abstracts OAuth into pluggable strategies. Each strategy handles the redirect, callback, and token exchange. You just configure credentials and a verify callback.
const passport = require('passport');
const GoogleStrategy = require('passport-google-oauth20').Strategy;
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) => {
// Find or create user in database
let user = await User.findOne({
where: { googleId: profile.id }
});
if (!user) {
user = await User.create({
googleId: profile.id,
email: profile.emails[0].value,
name: profile.displayName,
avatar: profile.photos[0].value
});
}
return done(null, user);
}
));
// Serialisation for session persistence
passport.serializeUser((user, done) => done(null, user.id));
passport.deserializeUser(async (id, done) => {
const user = await User.findByPk(id);
done(null, user);
});
// Mount middleware
app.use(passport.initialize());
app.use(passport.session());
// Routes
app.get('/auth/google',
passport.authenticate('google', {
scope: ['profile', 'email']
})
);
app.get('/auth/google/callback',
passport.authenticate('google', {
failureRedirect: '/login'
}),
(req, res) => res.redirect('/dashboard')
);
// GitHub strategy follows the same pattern
// npm install passport-github2
// passport.use(new GitHubStrategy({ ... }))
Add strategies for GitHub, Facebook, Microsoft, Apple, etc. Link accounts by matching email or storing provider IDs per user.
Always validate the state parameter to prevent CSRF. Use HTTPS. Restrict redirect URIs in the provider's console.
Never store passwords in plain text. Use a slow, salted, one-way hash function. The three accepted algorithms are bcrypt, scrypt, and argon2.
| Algorithm | CPU-Hard | Memory-Hard | Output |
|---|---|---|---|
| bcrypt | Yes (cost factor) | No (4 KB) | 60-char string with embedded salt |
| scrypt | Yes (N) | Yes (r, p) | Configurable, built into Node crypto |
| argon2id | Yes (iterations) | Yes (memory) | PHC-format string, OWASP recommended |
// bcrypt — most widely used
const bcrypt = require('bcrypt');
const SALT_ROUNDS = 12; // ~250ms on modern hardware
async function register(email, password) {
const hash = await bcrypt.hash(password, SALT_ROUNDS);
// hash = $2b$12$LJ3m4ys...(60 chars, salt embedded)
await User.create({ email, passwordHash: hash });
}
async function login(email, password) {
const user = await User.findByEmail(email);
if (!user) return null;
// Constant-time comparison prevents timing attacks
const valid = await bcrypt.compare(password, user.passwordHash);
return valid ? user : null;
}
// argon2 — OWASP first choice
const argon2 = require('argon2');
const hash = await argon2.hash(password, {
type: argon2.argon2id,
memoryCost: 65536, // 64 MB
timeCost: 3, // iterations
parallelism: 4
});
// $argon2id$v=19$m=65536,t=3,p=4$salt$hash
const valid = await argon2.verify(hash, password);
// scrypt — built into Node, no npm package needed
const { scrypt, randomBytes } = require('crypto');
const { promisify } = require('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')}`;
}
String comparison (===) short-circuits on first mismatch. bcrypt.compare and argon2.verify use constant-time comparison internally.
MFA requires two or more factors from different categories: something you know (password), something you have (device/key), something you are (biometrics).
const { authenticator } = require('otplib');
// Setup: generate secret + QR code
const secret = authenticator.generateSecret();
const uri = authenticator.keyuri(
user.email, 'MyApp', secret
);
// Verify code from user
const valid = authenticator.verify({
token: req.body.code, secret: user.totpSecret
});
navigator.credentialsconst { generateRegistrationOptions,
verifyRegistrationResponse
} = require('@simplewebauthn/server');
const options = await generateRegistrationOptions({
rpName: 'MyApp',
rpID: 'example.com',
userID: user.id,
userName: user.email
});
NIST SP 800-63B discourages SMS as sole second factor. SIM swapping, SS7 interception, and social engineering make SMS vulnerable.
Cross-Site Request Forgery tricks a user's browser into making an authenticated request to your site from a malicious page. The browser automatically includes cookies, so the request appears legitimate.
const csrf = require('csurf');
app.use(csrf({ cookie: false })); // session-based
app.get('/form', (req, res) => {
res.render('form', {
csrfToken: req.csrfToken()
});
});
// Template: <input type="hidden"
// name="_csrf" value="{{csrfToken}}">
SameSite=Lax blocks cross-site POSTSameSite=None APIsapp.use(session({
cookie: {
sameSite: 'lax', // default in modern browsers
secure: true,
httpOnly: true
}
}));
// Client sends:
// Cookie: csrf=abc123
// X-CSRF-Token: abc123
app.use((req, res, next) => {
if (['POST','PUT','DELETE'].includes(req.method)) {
if (req.cookies.csrf !== req.headers['x-csrf-token'])
return res.status(403).json({ error: 'CSRF' });
}
next();
});
Cross-Origin Resource Sharing controls which domains can call your API from a browser. Without CORS headers, the same-origin policy blocks cross-origin fetch/XHR requests.
const cors = require('cors');
// Simple: allow specific origin with credentials
app.use(cors({
origin: 'https://app.example.com',
credentials: true, // Access-Control-Allow-Credentials
methods: ['GET', 'POST', 'PUT', 'DELETE'],
allowedHeaders: ['Content-Type', 'Authorization'],
maxAge: 86400 // preflight cache (24h)
}));
// Dynamic origin (multiple allowed origins)
const allowedOrigins = [
'https://app.example.com',
'https://admin.example.com'
];
app.use(cors({
origin: (origin, cb) => {
if (!origin || allowedOrigins.includes(origin))
cb(null, true);
else
cb(new Error('Not allowed by CORS'));
},
credentials: true
}));
| Header | Purpose |
|---|---|
Access-Control-Allow-Origin | Which origin(s) can access the response |
Access-Control-Allow-Credentials | Allow cookies / Authorization header |
Access-Control-Allow-Methods | Permitted HTTP methods (preflight) |
Access-Control-Allow-Headers | Permitted request headers (preflight) |
Access-Control-Max-Age | Cache preflight response (seconds) |
Access-Control-Allow-Origin: * cannot be used with credentials: truewithCredentials: true needed on fetch/XHR for cookiesBrowser sends an OPTIONS request before any non-simple request (custom headers, PUT/DELETE, JSON content-type). If CORS headers are missing, the actual request is blocked.
RBAC assigns permissions to roles, then assigns roles to users. It simplifies permission management — you update the role definition, not individual user records.
| Role | Permissions |
|---|---|
| viewer | read:articles, read:comments |
| editor | viewer + write:articles, write:comments |
| admin | editor + manage:users, manage:settings |
| superadmin | All permissions, can manage roles themselves |
// Permission model (DB schema)
// users -> user_roles -> roles -> role_permissions -> permissions
// Many-to-many relationships allow flexible assignment
const ROLES = {
viewer: ['read:articles', 'read:comments'],
editor: ['read:articles', 'read:comments',
'write:articles', 'write:comments'],
admin: ['read:articles', 'read:comments',
'write:articles', 'write:comments',
'manage:users', 'manage:settings'],
};
// Middleware guard factory
function requirePermission(...perms) {
return (req, res, next) => {
const userPerms = ROLES[req.user.role] || [];
const hasAll = perms.every(p =>
userPerms.includes(p)
);
if (!hasAll) {
return res.status(403).json({
error: 'Insufficient permissions'
});
}
next();
};
}
// Usage in routes
app.get('/articles',
authenticate,
requirePermission('read:articles'),
articleController.list
);
app.delete('/users/:id',
authenticate,
requirePermission('manage:users'),
userController.delete
);
ABAC (attribute-based) uses policies with user attributes, resource properties, and environment context. Libraries: casl, casbin, OPA.
Where you store tokens in the browser determines which attacks you're vulnerable to. There is no perfect solution — only trade-offs between XSS and CSRF.
| Storage | XSS Risk | CSRF Risk | Auto-Sent | Recommendation |
|---|---|---|---|---|
localStorage | HIGH — JS can read it | None | No (manual header) | Avoid for auth tokens |
sessionStorage | HIGH — JS can read it | None | No | Avoid for auth tokens |
HttpOnly cookie | LOW — JS can't read it | Medium | Yes (automatic) | Best for session/refresh tokens |
| In-memory (JS variable) | Medium — XSS can access | None | No | OK for short-lived access tokens |
HttpOnly, Secure, SameSite=Strict cookie/refresh to get a new access tokenAuthorization headerlocalStorage or makes API callsSessions must be actively managed: rotated to prevent fixation, invalidated on logout, and expired after inactivity. A session store like Redis provides fast lookups and TTL-based expiry.
// Session rotation after login (prevent fixation)
app.post('/login', async (req, res) => {
const user = await authenticate(req.body);
if (!user) return res.status(401).end();
// Regenerate session ID — old ID is invalidated
req.session.regenerate((err) => {
req.session.userId = user.id;
req.session.role = user.role;
req.session.loginAt = Date.now();
req.session.save(() => res.redirect('/dashboard'));
});
});
// Logout: destroy session
app.post('/logout', (req, res) => {
req.session.destroy((err) => {
res.clearCookie('connect.sid');
res.redirect('/login');
});
});
// Redis session store with TTL
const RedisStore = require('connect-redis').default;
const { createClient } = require('redis');
const redisClient = createClient({ url: process.env.REDIS_URL });
await redisClient.connect();
app.use(session({
store: new RedisStore({
client: redisClient,
prefix: 'sess:',
ttl: 1800 // 30 min TTL, auto-renewed on activity
}),
rolling: true, // reset TTL on every request
secret: process.env.SESSION_SECRET,
resave: false,
saveUninitialized: false,
cookie: { secure: true, httpOnly: true, maxAge: 1800000 }
}));
// Force logout all sessions for a user (e.g., password change)
async function invalidateAllSessions(userId) {
const keys = await redisClient.keys('sess:*');
for (const key of keys) {
const data = JSON.parse(await redisClient.get(key));
if (data.userId === userId) await redisClient.del(key);
}
}
Regenerate session ID after login, privilege escalation, and password change. Prevents session fixation attacks.
Track active sessions per user. Let users view and revoke sessions (like GitHub/Google "active sessions" page).
Even with rolling TTL, enforce a maximum session lifetime (e.g., 24h). Re-authentication required after absolute expiry.
Single Sign-On lets users authenticate once and access multiple applications. The identity is managed by a central Identity Provider (IdP), and applications (Service Providers) trust the IdP's assertions.
// passport-saml
passport.use(new SamlStrategy({
entryPoint: 'https://idp.example.com/sso',
issuer: 'my-app',
cert: IDP_PUBLIC_CERT,
callbackUrl: '/auth/saml/callback'
}, (profile, done) => {
return done(null, profile);
}));
/.well-known/openid-configuration for discoveryopenid profile email// ID Token claims
{
"iss": "https://accounts.google.com",
"sub": "1104327891",
"aud": "my-client-id",
"email": "alice@example.com",
"name": "Alice Smith",
"iat": 1712505600,
"exp": 1712509200
}
| SAML | OIDC | |
|---|---|---|
| Format | XML | JSON/JWT |
| Transport | POST/Redirect | HTTP APIs |
| Best for | Enterprise SSO | Modern apps |
Understanding attack vectors is essential for building secure authentication. These are the most frequently exploited weaknesses in web auth systems.
req.session.regenerate())localStorage exposed to XSS// Rate limiting with express-rate-limit
const rateLimit = require('express-rate-limit');
const loginLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 5, // 5 attempts per window
message: { error: 'Too many login attempts' },
standardHeaders: true,
keyGenerator: (req) => req.body.email || req.ip
});
app.post('/login', loginLimiter, loginHandler);
HttpOnly, Secure, SameSite on all auth cookieshelmet for security headers (CSP, HSTS, X-Frame)otplibbcrypt jsonwebtoken passport express-session connect-redis cors helmet express-rate-limit otplib @simplewebauthn/server