Understanding Web Authentication: Sessions, Cookies, JWT Tokens & OAuth2 Introduction
Authentication is the cornerstone of web security. Every time you log into your email, social media account, or banking website, authentication mechanisms are working behind the scenes to verify your identity. Understanding how these mechanisms work is essential for building secure, scalable web applications.
This comprehensive guide covers four fundamental authentication approaches: sessions, cookies, JWT tokens, and OAuth2. We’ll explore how each works, their strengths and weaknesses, security implications, and when to use each approach.
Table of Contents
- Why Authentication Matters
- Sessions: Server-Side Authentication
- Cookies: The Storage Mechanism
- JWT Tokens: Stateless Authentication
- OAuth2: Delegated Authorization
- Comparing Authentication Approaches
- Security Considerations
- Best Practices & Recommendations
- Conclusion
Why Authentication Matters
Before diving into specific mechanisms, let’s understand the problem authentication solves.
The Core Challenge
When a user submits their username and password to your web application, you need to:
- Verify their identity - Confirm they are who they claim to be
- Maintain that identity - Remember they’re logged in across subsequent requests
- Protect against impersonation - Prevent other users from pretending to be them
HTTP is a stateless protocol, meaning each request is independent. Your server doesn’t inherently know that the person making request #2 is the same person who made request #1. Authentication mechanisms solve this by creating a persistent identity across stateless HTTP requests.
Authentication vs Authorization
These terms are often confused:
- Authentication: Verifying who you are (identity)
- Authorization: Determining what you’re allowed to do (permissions)
This guide focuses on authentication. Authorization typically builds on top of whatever authentication mechanism you choose.
Sessions: Server-Side Authentication
Sessions are the traditional, battle-tested approach to maintaining user identity on the web. A session represents an authenticated user’s interaction with your application over time.
How Sessions Work
User submits credentials (username/password)
โ
Server validates credentials against database
โ
Server creates a session: generates unique session ID
โ
Server stores session data (user ID, login time, etc.) in memory/database
โ
Server returns session ID to client (typically via cookie)
โ
Client stores session ID and sends it with every subsequent request
โ
Server looks up session ID to retrieve user information
Session Architecture Diagram
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ Client Browser โ
โ Session ID (stored in cookie or localStorage) โ
โโโโโโโโโโโโโโโโโโโโโโฌโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ
โ Sends session ID with each request
โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ Web Server โ
โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ
โ โ Session Store (In-Memory / Redis / DB) โ โ
โ โ Session ID โ {user_id, login_time, data} โ โ
โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
Practical Example: Node.js with Express
const express = require('express');
const session = require('express-session');
const RedisStore = require('connect-redis').default;
const { createClient } = require('redis');
const app = express();
// Create Redis client for session storage
const redisClient = createClient();
redisClient.connect();
// Configure session middleware
app.use(session({
store: new RedisStore({ client: redisClient }),
secret: process.env.SESSION_SECRET,
resave: false,
saveUninitialized: false,
cookie: {
httpOnly: true, // Prevent JavaScript access
secure: true, // HTTPS only
sameSite: 'Strict', // CSRF protection
maxAge: 1000 * 60 * 60 * 24 // 24 hours
}
}));
// Login endpoint
app.post('/login', async (req, res) => {
const { username, password } = req.body;
// Verify credentials (bcrypt recommended for production)
const user = await User.findOne({ username });
if (!user || !bcrypt.compareSync(password, user.passwordHash)) {
return res.status(401).json({ error: 'Invalid credentials' });
}
// Create session - data is automatically saved to session store
req.session.userId = user.id;
req.session.username = user.username;
res.json({ message: 'Logged in successfully' });
});
// Protected endpoint - check if session exists
app.get('/profile', (req, res) => {
if (!req.session.userId) {
return res.status(401).json({ error: 'Not authenticated' });
}
res.json({
userId: req.session.userId,
username: req.session.username
});
});
// Logout endpoint
app.post('/logout', (req, res) => {
req.session.destroy((err) => {
if (err) return res.status(500).json({ error: 'Logout failed' });
res.json({ message: 'Logged out successfully' });
});
});
Advantages of Sessions
- Server control: Server can invalidate sessions immediately (logout, security issues)
- Scalable security: Server-side logic can enforce complex authorization rules
- Easy revocation: Simply delete the session to log out a user
- Flexible data storage: Can store any data related to the session
- Proven approach: Decades of real-world production use
Disadvantages of Sessions
- Server-side storage: Requires memory/database space for each active session
- Scaling challenges: Multi-server deployments need shared session storage (Redis, database)
- Cookie dependency: Relies on cookies for transport (can be disabled by users)
- Reduced mobility: Harder to share authentication across multiple domains/applications
When to Use Sessions
- Traditional monolithic web applications
- Applications where immediate logout/revocation is critical
- Systems where server has complete control over authorization
- Single-server deployments or applications with Redis/memcached
Cookies: The Storage Mechanism
Cookies are not an authentication mechanism themselvesโthey’re a transport mechanism for authentication data. However, understanding cookies is crucial because they’re the primary way browsers store and transmit session IDs and other authentication tokens.
What Are Cookies?
A cookie is a small piece of data (max 4KB) that the server instructs the browser to store. The browser automatically includes cookies with every request to the domain that set them.
How Cookies Work
Server sends HTTP response header: Set-Cookie: sessionId=abc123
โ
Browser stores the cookie
โ
Browser automatically includes in next request: Cookie: sessionId=abc123
โ
Server reads the cookie from the request
Cookie Attributes Explained
When setting a cookie, the server specifies important attributes:
// Example: Setting a cookie in Node.js/Express
res.cookie('sessionId', 'abc123def456', {
httpOnly: true, // JavaScript cannot access (prevents XSS theft)
secure: true, // HTTPS only (prevents network interception)
sameSite: 'Strict', // Not sent in cross-site requests (prevents CSRF)
maxAge: 86400000, // Expires in 24 hours (milliseconds)
path: '/', // Sent with all requests to this domain
domain: 'example.com' // Sent only to example.com, not subdomains
});
Cookie vs Session Storage
Developers sometimes confuse cookies with other client-side storage options:
| Storage | Size | Scope | Expiration | Sent with HTTP? |
|---|---|---|---|---|
| Cookies | 4KB | Per domain | User-controlled | Yes (automatic) |
| localStorage | 5-10MB | Per domain | Persistent | No (manual) |
| sessionStorage | 5-10MB | Per tab | Tab closed | No (manual) |
| IndexedDB | 50MB+ | Per domain | Persistent | No (manual) |
For authentication, cookies are preferred because:
- Automatically sent with every request
- Can be marked
HttpOnlyto prevent JavaScript access - Can be marked
Secureto enforce HTTPS - Can be marked
SameSiteto prevent CSRF attacks
Security Best Practices for Cookies
// Production-ready cookie configuration
const secureCookie = {
httpOnly: true, // Essential: prevents XSS attacks
secure: true, // Essential: HTTPS only
sameSite: 'Lax', // Prevents CSRF in most cases ('Strict' for banking)
maxAge: 1000 * 60 * 60 * 24 * 7, // 7 days
path: '/',
// Note: Do NOT set domain if you want subdomain isolation
};
// For development without HTTPS
const devCookie = {
httpOnly: true,
secure: false, // Allow HTTP in development
sameSite: 'Lax',
maxAge: 1000 * 60 * 60 * 24 * 7
};
Common Cookie Attacks & Prevention
| Attack | How It Works | Prevention |
|---|---|---|
| XSS | JavaScript steals cookie | httpOnly: true |
| Network Interception | Man-in-the-middle reads cookie | secure: true (HTTPS) |
| CSRF | Cross-site form submission | sameSite: 'Strict'/'Lax' |
| Session Fixation | Attacker sets known session ID | Regenerate session ID after login |
JWT Tokens: Stateless Authentication
JWT (JSON Web Token) is a modern, stateless authentication approach that’s particularly popular in mobile apps and APIs. Unlike sessions, JWTs are self-contained and verified using cryptographic signatures.
What Is a JWT?
A JWT is a digitally signed JSON object that contains encoded user information. It has three parts, separated by periods:
Header.Payload.Signature
Example JWT:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.
eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.
SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
Breaking it down:
// Header (base64url encoded)
{
"alg": "HS256", // Algorithm: HMAC SHA-256
"typ": "JWT"
}
// Payload (base64url encoded)
{
"sub": "1234567890", // Subject (typically user ID)
"name": "John Doe",
"iat": 1516239022, // Issued At (timestamp)
"exp": 1516325422 // Expiration time
}
// Signature (created by server)
HMACSHA256(base64urlEncode(header) + "." + base64urlEncode(payload), secret)
How JWT Authentication Works
1. User submits credentials (username/password)
โ
2. Server validates credentials against database
โ
3. Server creates JWT:
- Encodes user data in payload
- Signs with server's secret key
โ
4. Server returns JWT to client
โ
5. Client stores JWT (localStorage, sessionStorage, or cookie)
โ
6. Client sends JWT in HTTP header: Authorization: Bearer <token>
โ
7. Server verifies JWT signature with its secret key
- If signature is valid, token wasn't tampered with
- If signature is valid, user data in token can be trusted
โ
8. Server grants access to protected resource
Practical Example: Node.js with JWT
const express = require('express');
const jwt = require('jsonwebtoken');
const bcrypt = require('bcrypt');
const app = express();
const JWT_SECRET = process.env.JWT_SECRET; // Keep this secure!
// Login endpoint - issue JWT
app.post('/login', async (req, res) => {
const { username, password } = req.body;
// Find user in database
const user = await User.findOne({ username });
if (!user || !bcrypt.compareSync(password, user.passwordHash)) {
return res.status(401).json({ error: 'Invalid credentials' });
}
// Create JWT token
const token = jwt.sign(
{
userId: user.id,
username: user.username,
email: user.email,
role: user.role
},
JWT_SECRET,
{
expiresIn: '24h', // Token expires in 24 hours
issuer: 'myapp', // Who issued this token
audience: 'myapp-users' // Who can use this token
}
);
// Return token to client
res.json({
token,
expiresIn: '24h',
user: {
id: user.id,
username: user.username,
email: user.email
}
});
});
// Middleware to verify JWT
const verifyToken = (req, res, next) => {
// Token comes in Authorization header: "Bearer <token>"
const authHeader = req.headers.authorization;
const token = authHeader && authHeader.split(' ')[1];
if (!token) {
return res.status(401).json({ error: 'No token provided' });
}
try {
// Verify signature and expiration
const decoded = jwt.verify(token, JWT_SECRET);
req.user = decoded;
next();
} catch (error) {
if (error.name === 'TokenExpiredError') {
return res.status(401).json({ error: 'Token expired' });
}
return res.status(403).json({ error: 'Invalid token' });
}
};
// Protected endpoint
app.get('/profile', verifyToken, (req, res) => {
res.json({
message: 'Access granted',
user: req.user
});
});
// Logout (client-side)
app.post('/logout', verifyToken, (req, res) => {
// In JWT, you don't "log out" on the server
// Client simply deletes the token
// For additional security, server can maintain a token blacklist
res.json({ message: 'Logout successful (delete token on client)' });
});
JWT in HTTP Requests
When using JWT, the token is typically sent in the Authorization header:
GET /api/profile HTTP/1.1
Host: api.example.com
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
JWT vs Opaque Sessions
| Aspect | JWT | Opaque Session ID |
|---|---|---|
| Storage | Server-side (optional) | Server-side (required) |
| Verification | Signature validation | Database lookup |
| Scalability | Excellent (no DB lookup) | Requires shared store (Redis) |
| Revocation | Difficult (use blacklist) | Immediate (delete session) |
| Data | Self-contained | Requires server lookup |
| Cross-domain | Easy (no cookies needed) | Difficult (cookie restrictions) |
| Mobile | Excellent | Good (with CORS) |
Advantages of JWT
- Stateless: Server doesn’t need to store sessions; reduces server memory
- Scalable: No shared session storage needed; each server can verify tokens independently
- Mobile-friendly: Doesn’t rely on cookies; works great for mobile apps and SPAs
- Cross-domain: Can be used across multiple domains/APIs
- Self-contained: Token contains user data; no need for database lookup after verification
Disadvantages of JWT
- Token size: Larger than session IDs (affects bandwidth for high-traffic APIs)
- Revocation difficulty: Can’t immediately invalidate a token (token remains valid until expiration)
- Data exposure: Payload is base64-encoded, not encrypted (don’t put secrets in JWT)
- Refresh token complexity: Handling token expiration requires refresh token pattern
- Logout complexity: Requires token blacklist or other mechanisms
When to Use JWT
- Mobile apps and native applications
- REST APIs and microservices
- Single-Page Applications (SPAs) with client-side routing
- Cross-domain authentication
- Scenarios where server scalability is critical
OAuth2: Delegated Authorization
OAuth2 is a standard for delegated authorization, allowing users to grant third-party applications access to their data without sharing passwords. It’s fundamentally different from the previous three approachesโit’s not primarily about authentication, but about authorization.
OAuth2 vs Authentication
Important distinction:
- OAuth2 is an authorization protocol (what you can do)
- Authentication is about verifying identity (who you are)
However, OAuth2 can be used with an authentication protocol (OpenID Connect, discussed briefly below) to achieve both authentication and authorization.
The OAuth2 Scenario
You visit a photo printing website (example.com).
The site says: "Log in with Google" or "Log in with Facebook"
โ
You click the button
โ
You're redirected to Google's login page
โ
You enter your Google credentials to Google (not to example.com)
โ
Google asks: "example.com wants access to your profile. Allow?"
โ
You click "Allow"
โ
Google redirects you back to example.com with an authorization code
โ
example.com exchanges the code for an access token
โ
example.com uses the token to fetch your profile from Google
โ
example.com logs you in
OAuth2 Components
Resource Owner: You (the user)
Client: The application requesting access (example.com)
Authorization Server: Issues tokens (Google, Facebook, GitHub)
Resource Server: Hosts protected resources (your Google Drive files, GitHub repos)
In many cases, the Authorization Server and Resource Server are the same service (e.g., Google), but they can be separate.
OAuth2 Authorization Code Flow (Most Secure)
โโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโโ
โ Browser โ โ Web App โ โ OAuth2 โ
โ (User) โ โ (Client) โ โ Provider โ
โโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโโ โ (Google) โ
โ โ โโโโโโโโโโโโโโโโ
โ Click "Login with Google" โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ>โ
โ Redirects to Google login โ
โ<โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโค
โ โ
โ Enters credentials at Google โ
โ โ
โ<โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโค
โ Asks for permission (scope) โ
โ โ
โ Grants permission, Google redirects back โ
โ with authorization code โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ>โ
โ โ
โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ>โ
โ โ POST /token with code โ
โ โ (backend-to-backend) โ
โ โ<โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโค
โ โ Returns access_token โ
โ
โ Logged in! Session established
โ
Practical Example: OAuth2 with Google (Node.js)
const express = require('express');
const passport = require('passport');
const GoogleStrategy = require('passport-google-oauth20').Strategy;
const session = require('express-session');
const app = express();
// Passport configuration for Google OAuth2
passport.use(new GoogleStrategy({
clientID: process.env.GOOGLE_CLIENT_ID,
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
callbackURL: 'http://localhost:3000/auth/google/callback'
},
// Verification callback - called after successful Google authentication
async (accessToken, refreshToken, profile, done) => {
try {
// Find or create user in your database
let user = await User.findOne({ googleId: profile.id });
if (!user) {
user = await User.create({
googleId: profile.id,
displayName: profile.displayName,
email: profile.emails[0].value,
profilePicture: profile.photos[0].value
});
}
return done(null, user);
} catch (error) {
return done(error);
}
}
));
// Serialize user for session
passport.serializeUser((user, done) => {
done(null, user.id);
});
// Deserialize user from session
passport.deserializeUser(async (id, done) => {
const user = await User.findById(id);
done(null, user);
});
// Session configuration
app.use(session({
secret: process.env.SESSION_SECRET,
resave: false,
saveUninitialized: false
}));
app.use(passport.initialize());
app.use(passport.session());
// OAuth2 routes
// Redirects user to Google login
app.get('/auth/google',
passport.authenticate('google', { scope: ['profile', 'email'] })
);
// Callback after Google authentication
app.get('/auth/google/callback',
passport.authenticate('google', { failureRedirect: '/login' }),
(req, res) => {
// Successful authentication, user is logged in via session
res.redirect('/dashboard');
}
);
// Protected route
app.get('/dashboard', (req, res) => {
if (!req.isAuthenticated()) {
return res.redirect('/login');
}
res.json({
message: 'Welcome to dashboard',
user: req.user
});
});
// Logout
app.get('/logout', (req, res) => {
req.logout((err) => {
if (err) return res.status(500).json({ error: 'Logout failed' });
res.redirect('/');
});
});
OAuth2 Scopes
When requesting access, the client specifies scopesโpermissions the user is granting:
// Request specific scopes from Google
passport.authenticate('google', {
scope: [
'profile', // Access basic profile info
'email', // Access email address
'https://www.googleapis.com/auth/calendar.readonly' // Google Calendar
]
})
The user sees these requested permissions and can accept or deny. The app only receives the access token if the user grants the requested scopes.
Advantages of OAuth2
- User privacy: Users don’t share passwords with third-party apps
- Selective permissions: Users grant only the scopes they’re comfortable with
- Revocable: Users can revoke app access at any time in their provider settings
- Reduced security burden: Let identity providers handle authentication
- Rich user data: Access to user’s data from the provider
- Social login: Familiar login experience for users
Disadvantages of OAuth2
- Dependency: Relies on external provider (service outage = users can’t log in)
- Complexity: More moving parts than simple sessions
- Vendor control: Limited by provider’s policies and features
- Data sharing: Your app receives information from the provider
- Learning curve: More complex to implement from scratch
- Limited to specific providers: Only works with services that support OAuth2
When to Use OAuth2
- SaaS applications where social login is a feature
- Third-party integrations (accessing user’s Google Drive, GitHub repos, etc.)
- When you want to reduce password management burden
- Applications serving global audiences
- When you need selective permissions/scopes
OpenID Connect: OAuth2 + Authentication
OpenID Connect (OIDC) builds on top of OAuth2 to add an authentication layer. It introduces an id_token (a JWT) that proves the user’s identity, in addition to the access_token used for authorization.
// With OIDC, you also get an ID token that contains user claims
{
"iss": "https://accounts.google.com",
"azp": "client_id.apps.googleusercontent.com",
"aud": "client_id.apps.googleusercontent.com",
"sub": "110169547472869293633", // User ID
"email": "[email protected]",
"email_verified": true,
"at_hash": "...",
"iat": 1516239022,
"exp": 1516325422
}
Many modern OAuth2 providers support OIDC, making OAuth2 suitable for both authorization and authentication.
Comparing Authentication Approaches
Decision Matrix
| Factor | Sessions | Cookies | JWT | OAuth2 |
|---|---|---|---|---|
| Stateless | No | No | Yes | Yes (with ID token) |
| Server Storage | Required | No | Optional | No |
| Immediate Logout | Yes | No | No | No |
| Scalability | Medium | N/A | High | High |
| Mobile Apps | Poor | No | Excellent | Excellent |
| Cross-domain | Difficult | No | Easy | Easy |
| Password sharing | No | No | No | No |
| Third-party access | No | No | No | Yes |
| Learning curve | Low | Low | Medium | High |
| Production ready | Yes | Yes | Yes | Yes |
Flowchart: Choosing an Authentication Mechanism
Start: Choosing Authentication for Your App
โ
โโ Single-Page App (SPA) or Mobile App?
โ โโ YES โ Use JWT or OAuth2
โ
โโ Traditional Server-Rendered App?
โ โโ YES โ Use Sessions + Cookies
โ
โโ Microservices / API Gateway?
โ โโ YES โ Use JWT
โ
โโ Need Third-Party Access (Google, GitHub login)?
โ โโ YES โ Use OAuth2 (with OpenID Connect for auth)
โ
โโ Need Immediate Token Revocation?
โ โโ YES โ Sessions or maintain token blacklist
โ
โโ Global Scaling with Minimal Server Resources?
โโ YES โ Use JWT or OAuth2
Architecture Comparison
Traditional Session Architecture:
Client โ HTTP โ> Server โ DB/Redis โ> Session Store
(with session cookie)
JWT Architecture:
Client โ HTTP โ> Server
(with JWT in header)
OAuth2 Architecture:
Client โ> OAuth2 Provider (Login)
โ
โโ> Client App โ> OAuth2 Provider (Token Exchange)
โ
โโ> Resource Server (Access Resources)
Real-World Examples
Best fit for Sessions:
- GitHub-style issue tracking website
- Internal company tool
- Content management system
- Applications with complex authorization rules
Best fit for JWT:
- Mobile app backend API
- Microservices
- Real-time applications (WebSocket APIs)
- Stateless serverless functions
Best fit for OAuth2:
- Third-party integrations
- “Log in with Google” features
- Applications needing access to user’s cloud storage
- Cross-platform authentication
Security Considerations
Attack Vectors & Prevention
1. Cross-Site Scripting (XSS)
Attack: Malicious JavaScript steals authentication tokens from localStorage.
Prevention:
- For sessions: Use
httpOnlycookies - For JWT: Store in
httpOnlycookies instead of localStorage - Validate and sanitize all user inputs
- Use Content Security Policy (CSP) headers
// Vulnerable: JWT in localStorage can be stolen by XSS
localStorage.setItem('token', jwt);
// Better: JWT in httpOnly cookie (from server)
res.cookie('token', jwt, { httpOnly: true, secure: true });
2. Cross-Site Request Forgery (CSRF)
Attack: Attacker tricks your browser into making unwanted requests to a website where you’re authenticated.
Prevention:
- Use
SameSitecookie attribute - Implement CSRF tokens for state-changing operations
- Require authentication for sensitive operations
// Prevention: SameSite attribute
res.cookie('sessionId', id, {
sameSite: 'Strict', // Not sent in cross-site requests
secure: true,
httpOnly: true
});
// Additional: CSRF token for forms
app.post('/transfer-money', (req, res) => {
// Verify CSRF token in addition to session
if (!verifyCsrfToken(req)) {
return res.status(403).json({ error: 'CSRF token invalid' });
}
// Process transfer
});
3. Session Fixation
Attack: Attacker tricks user into using a known session ID.
Prevention:
- Generate new session ID after successful login
- Use cryptographically secure random generators
// Regenerate session after login (express-session does this)
req.session.regenerate((err) => {
if (err) return res.status(500).json({ error: 'Login failed' });
req.session.userId = user.id;
res.json({ message: 'Logged in' });
});
4. Token Leakage
Attack: Tokens exposed in logs, error messages, or transmitted over HTTP.
Prevention:
- Use HTTPS for all authentication traffic
- Don’t log sensitive tokens
- Implement proper error handling (don’t expose token details)
- Use token expiration (short-lived tokens)
// Never log tokens
console.log(`Token: ${token}`); // โ WRONG
// Rotate tokens frequently
const token = jwt.sign(data, secret, { expiresIn: '15m' });
// Use HTTPS in production
app.use((req, res, next) => {
if (process.env.NODE_ENV === 'production' && !req.secure) {
return res.redirect('https://' + req.get('host') + req.url);
}
next();
});
5. Password Storage
Attack: Weak password hashing allows attackers to crack user passwords.
Prevention:
- Use strong, modern hashing algorithms (bcrypt, Argon2)
- Never store plaintext passwords
- Use salt to prevent rainbow table attacks
const bcrypt = require('bcrypt');
// Hash password during registration
const saltRounds = 10; // Higher = slower but more secure
const passwordHash = await bcrypt.hash(password, saltRounds);
// Verify password during login
const isValid = await bcrypt.compare(inputPassword, passwordHash);
// Never do this:
// db.save({ username, password }); // โ WRONG
HTTPS: Non-Negotiable
All authentication must occur over HTTPS. HTTP exposes credentials and tokens to network interception.
// Enforce HTTPS
app.use((req, res, next) => {
if (!req.secure && process.env.NODE_ENV === 'production') {
return res.redirect('https://' + req.get('host') + req.url);
}
next();
});
// Use secure headers
const helmet = require('helmet');
app.use(helmet()); // Sets various security headers
Best Practices & Recommendations
General Principles
- Defense in Depth: Use multiple layers of security
- Least Privilege: Give users only the permissions they need
- Fail Secure: When in doubt, deny access
- Regular Updates: Keep dependencies patched
- Monitoring: Log and alert on suspicious authentication activity
Implementation Checklist
- Use HTTPS everywhere (enforce with HSTS headers)
- Hash passwords with bcrypt or Argon2
- Use httpOnly and Secure flags for authentication cookies
- Implement SameSite cookie attribute for CSRF protection
- Add appropriate Content-Security-Policy headers
- Implement rate limiting on login endpoints
- Log authentication events (without logging sensitive data)
- Use environment variables for secrets (never commit to git)
- Implement token expiration
- Provide users with logout/session management options
- Require strong passwords or use passkeys/biometric auth
- Implement optional multi-factor authentication (MFA)
Session Management Best Practices
// Complete session configuration
app.use(session({
store: new RedisStore({ client: redisClient }),
secret: process.env.SESSION_SECRET, // Use env variable
resave: false,
saveUninitialized: false,
cookie: {
httpOnly: true, // Prevent XSS
secure: true, // HTTPS only
sameSite: 'Strict', // Prevent CSRF
maxAge: 1000 * 60 * 60 * 24, // 24 hours
domain: 'example.com' // Restrict to domain
}
}));
// Regenerate session after login
app.post('/login', async (req, res) => {
// ... validate credentials ...
req.session.regenerate((err) => {
if (err) return res.status(500).json({ error: 'Login failed' });
req.session.userId = user.id;
req.session.lastActivity = Date.now();
res.json({ message: 'Logged in' });
});
});
// Destroy session on logout
app.post('/logout', (req, res) => {
req.session.destroy((err) => {
if (err) return res.status(500).json({ error: 'Logout failed' });
res.clearCookie('connect.sid');
res.json({ message: 'Logged out' });
});
});
JWT Best Practices
// Complete JWT implementation
const jwt = require('jsonwebtoken');
// Issue token with reasonable expiration
const issueToken = (userId) => {
return jwt.sign(
{ userId, iat: Math.floor(Date.now() / 1000) }, // Include issued-at time
process.env.JWT_SECRET,
{
expiresIn: '15m', // Short-lived access token
issuer: 'myapp',
audience: 'myapp-users'
}
);
};
// Issue refresh token for long-lived authentication
const issueRefreshToken = (userId) => {
return jwt.sign(
{ userId, type: 'refresh' },
process.env.JWT_REFRESH_SECRET,
{ expiresIn: '7d' }
);
};
// Verify token with all checks
const verifyToken = (token) => {
try {
return jwt.verify(token, process.env.JWT_SECRET, {
issuer: 'myapp',
audience: 'myapp-users'
});
} catch (error) {
if (error.name === 'TokenExpiredError') {
throw new Error('Token expired');
}
throw new Error('Invalid token');
}
};
// Handle token refresh
app.post('/refresh', (req, res) => {
const { refreshToken } = req.body;
try {
const decoded = jwt.verify(refreshToken, process.env.JWT_REFRESH_SECRET);
// Issue new access token
const newAccessToken = issueToken(decoded.userId);
res.json({ accessToken: newAccessToken });
} catch (error) {
res.status(401).json({ error: 'Refresh failed' });
}
});
Multi-Factor Authentication (MFA)
While not covered in detail here, MFA is increasingly important:
// After password authentication succeeds
app.post('/login', async (req, res) => {
// ... validate password ...
if (user.mfaEnabled) {
// Send MFA code via email/SMS
const mfaCode = generateSecureCode();
await sendMfaCode(user.email, mfaCode);
// Store temporary session without userId
req.session.pendingMfa = true;
req.session.pendingUserId = user.id;
return res.json({ message: 'MFA code sent' });
}
// ... create authenticated session ...
});
// Verify MFA code
app.post('/verify-mfa', (req, res) => {
if (!req.session.pendingMfa) {
return res.status(401).json({ error: 'No pending MFA' });
}
const { mfaCode } = req.body;
if (!verifyMfaCode(req.session.pendingUserId, mfaCode)) {
return res.status(401).json({ error: 'Invalid MFA code' });
}
// Complete authentication
req.session.userId = req.session.pendingUserId;
delete req.session.pendingMfa;
res.json({ message: 'Authentication complete' });
});
Conclusion
Each authentication mechanism serves different purposes and works best in different contexts:
-
Sessions remain the gold standard for traditional web applications, offering simplicity, immediate revocation, and server-side control.
-
Cookies are the primary transport mechanism for sessions, and understanding their security attributes is crucial for any web developer.
-
JWT tokens excel in modern architectures: mobile apps, single-page applications, microservices, and APIs. They provide stateless authentication at scale.
-
OAuth2 solves a different problemโit allows users to grant third-party applications access to their data without sharing passwords. When combined with OpenID Connect, it also provides authentication.
Choosing the Right Approach
Use Sessions if:
- Building a traditional server-rendered web application
- You need immediate logout/token revocation
- Your server infrastructure supports shared session storage
- Your application has complex authorization logic
Use JWT if:
- Building a mobile app, SPA, or API
- You need to scale horizontally without shared session storage
- Your application will be accessed from multiple domains
- You can tolerate tokens being valid until expiration
Use OAuth2 if:
- You want to offer “Login with Google/GitHub/Facebook”
- You need to access user data from external providers
- You’re building an API that third parties will integrate with
- You want to reduce password management burden
Real-world recommendation: Many modern applications use a hybrid approach:
- OAuth2 or sessions for initial authentication
- JWT tokens for API access and mobile clients
- Sessions for traditional web page access
- Refresh tokens to handle token expiration securely
Security should always be your top priority. When in doubt, refer to OWASP guidelines, keep dependencies updated, use HTTPS, and implement defense-in-depth with multiple security layers.
Further Reading & Resources
- OWASP: Authentication Cheat Sheet
- RFC 7519: JSON Web Token (JWT)
- RFC 6749: OAuth 2.0 Authorization Framework
- OpenID Connect Core 1.0
- NIST: Digital Identity Guidelines
- MDN Web Docs: HTTP Cookies
- Node.js Security Best Practices
- Auth0 Blog: Authentication & Authorization
- Passport.js Strategies
- JWT Best Practices
Comments