Skip to main content

Authentication and Authorization: JWT, OAuth 2.0, and Session Management

Master authentication and authorization with JWT, OAuth 2.0, and session management. Learn security best practices, implementation patterns, and when to use each approach.

Table of Contents

Introduction

Authentication and authorization are the foundation of secure web applications. Every application that handles user data needs to verify who users are (authentication) and control what they can access (authorization). Yet, implementing these systems correctly is one of the most challenging aspects of backend development, with security implications that can make or break your application.

The web development ecosystem offers multiple approaches to authentication: traditional session-based authentication using cookies, modern token-based authentication with JSON Web Tokens (JWT), and delegated authentication through OAuth 2.0. Each approach has its strengths, trade-offs, and ideal use cases. Understanding when and how to use each method is crucial for building secure, scalable applications.

This comprehensive guide will teach you everything you need to know about authentication and authorization. You’ll learn how session-based authentication works, understand JWT tokens and their security implications, master OAuth 2.0 flows, and discover best practices for implementing each approach securely. By the end, you’ll be able to choose the right authentication strategy for your application and implement it correctly.


Understanding Authentication vs Authorization

Before diving into specific authentication methods, it’s essential to understand the fundamental difference between authentication and authorization, as these terms are often confused.

Authentication: Who Are You?

Authentication is the process of verifying a user’s identity. It answers the question: “Who are you?” Authentication typically involves:

  • Credentials: Username/password, email/password, or biometric data
  • Verification: Checking credentials against stored data
  • Session Creation: Establishing a trusted session after successful verification
// Authentication example: Verifying user credentials
async function authenticateUser(email, password) {
// 1. Find user by email
const user = await db.users.findOne({ email });
if (!user) {
throw new Error("Invalid credentials");
}
// 2. Verify password
const isValid = await bcrypt.compare(password, user.hashedPassword);
if (!isValid) {
throw new Error("Invalid credentials");
}
// 3. Authentication successful - user identity verified
return user;
}

Authorization: What Can You Do?

Authorization is the process of determining what resources and actions a user can access. It answers the question: “What are you allowed to do?” Authorization typically involves:

  • Roles: Admin, User, Moderator, etc.
  • Permissions: Specific actions like “read”, “write”, “delete”
  • Resource Access: Which resources a user can access
// Authorization example: Checking if user can access a resource
function authorizeUser(user, resource, action) {
// Check user role
if (user.role === "admin") {
return true; // Admins can do anything
}
// Check specific permissions
if (user.permissions.includes(`${resource}:${action}`)) {
return true;
}
// Check resource ownership
if (resource.ownerId === user.id) {
return true;
}
return false; // Access denied
}

The Relationship Between Authentication and Authorization

Authentication must happen before authorization. You can’t determine what a user can do until you know who they are:

// Complete flow: Authentication → Authorization
async function handleRequest(req, res) {
try {
// Step 1: Authenticate - verify who the user is
const user = await authenticateUser(req.body.email, req.body.password);
// Step 2: Authorize - check what they can do
if (!authorizeUser(user, "posts", "create")) {
return res.status(403).json({
error: "Insufficient permissions",
});
}
// Step 3: Perform authorized action
const post = await createPost(req.body.content, user.id);
res.json(post);
} catch (error) {
res.status(401).json({ error: "Authentication failed" });
}
}

Session-Based Authentication

Session-based authentication is the traditional approach where the server maintains session state and uses cookies to identify authenticated users. This method has been the standard for web applications for decades.

How Session-Based Authentication Works

Session-based authentication follows this flow:

  1. User Login: User submits credentials
  2. Server Verification: Server verifies credentials
  3. Session Creation: Server creates a session and stores it
  4. Cookie Set: Server sends session ID in a cookie
  5. Subsequent Requests: Browser automatically sends cookie
  6. Session Validation: Server validates session on each request
// Express.js session-based authentication example
const express = require("express");
const session = require("express-session");
const bcrypt = require("bcrypt");
const app = express();
// Configure session middleware
app.use(
session({
secret: "your-secret-key-change-in-production",
resave: false,
saveUninitialized: false,
cookie: {
secure: true, // HTTPS only
httpOnly: true, // Prevent XSS attacks
maxAge: 24 * 60 * 60 * 1000, // 24 hours
},
}),
);
// Login endpoint
app.post("/login", async (req, res) => {
const { email, password } = req.body;
// Find user
const user = await db.users.findOne({ email });
if (!user) {
return res.status(401).json({ error: "Invalid credentials" });
}
// Verify password
const isValid = await bcrypt.compare(password, user.hashedPassword);
if (!isValid) {
return res.status(401).json({ error: "Invalid credentials" });
}
// Create session
req.session.userId = user.id;
req.session.email = user.email;
req.session.role = user.role;
res.json({
message: "Login successful",
user: { id: user.id, email: user.email },
});
});
// Protected route middleware
function requireAuth(req, res, next) {
if (!req.session.userId) {
return res.status(401).json({ error: "Authentication required" });
}
next();
}
// Protected route
app.get("/profile", requireAuth, async (req, res) => {
const user = await db.users.findById(req.session.userId);
res.json(user);
});
// Logout endpoint
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: "Logout successful" });
});
});

Session Storage Options

Sessions can be stored in different ways, each with trade-offs:

1. In-Memory Storage (Default)

// ❌ Not suitable for production - lost on server restart
app.use(
session({
secret: "secret-key",
}),
);

2. Database Storage

// ✅ Persistent across server restarts
const MySQLStore = require("express-mysql-session")(session);
app.use(
session({
secret: "secret-key",
store: new MySQLStore({
host: "localhost",
port: 3306,
user: "root",
password: "password",
database: "sessions",
}),
}),
);

3. Redis Storage (Recommended for Production)

// ✅ Fast, scalable, supports clustering
const RedisStore = require("connect-redis")(session);
const redis = require("redis");
const redisClient = redis.createClient({
host: "localhost",
port: 6379,
});
app.use(
session({
secret: "secret-key",
store: new RedisStore({ client: redisClient }),
resave: false,
saveUninitialized: false,
}),
);

Advantages of Session-Based Authentication

Server Control: Server can invalidate sessions immediately ✅ Security: Sensitive data never leaves the server ✅ Simple: Easy to understand and implement ✅ Stateful: Server knows about active sessions ✅ CSRF Protection: Can use CSRF tokens effectively

Disadvantages of Session-Based Authentication

Scalability: Requires shared session storage in distributed systems ❌ Cookie Limitations: CORS issues with cross-domain requests ❌ Mobile Apps: Cookies don’t work well with native mobile apps ❌ Stateless APIs: Doesn’t work well for stateless REST APIs


Token-Based Authentication with JWT

JSON Web Tokens (JWT) provide a stateless authentication mechanism where the token itself contains all necessary information. This makes JWT ideal for distributed systems and stateless APIs.

Understanding JWT Structure

A JWT consists of three parts separated by dots:

header.payload.signature

1. Header: Contains token type and signing algorithm

{
"alg": "HS256",
"typ": "JWT"
}

2. Payload: Contains claims (user data)

{
"sub": "1234567890",
"email": "user@example.com",
"role": "user",
"iat": 1516239022,
"exp": 1516242622
}

3. Signature: Used to verify token integrity

HMACSHA256(
base64UrlEncode(header) + "." +
base64UrlEncode(payload),
secret
)

Implementing JWT Authentication

const jwt = require("jsonwebtoken");
const bcrypt = require("bcrypt");
const SECRET_KEY = process.env.JWT_SECRET || "change-this-secret";
// Login endpoint - issue JWT
app.post("/login", async (req, res) => {
const { email, password } = req.body;
// Find and verify user
const user = await db.users.findOne({ email });
if (!user) {
return res.status(401).json({ error: "Invalid credentials" });
}
const isValid = await bcrypt.compare(password, user.hashedPassword);
if (!isValid) {
return res.status(401).json({ error: "Invalid credentials" });
}
// Create JWT payload
const payload = {
userId: user.id,
email: user.email,
role: user.role,
};
// Sign token with expiration
const token = jwt.sign(payload, SECRET_KEY, {
expiresIn: "24h", // Token expires in 24 hours
issuer: "your-app-name",
audience: "your-app-users",
});
res.json({
token,
user: {
id: user.id,
email: user.email,
role: user.role,
},
});
});
// Middleware to verify JWT
function authenticateToken(req, res, next) {
// Get token from Authorization header
const authHeader = req.headers["authorization"];
const token = authHeader && authHeader.split(" ")[1]; // Bearer TOKEN
if (!token) {
return res.status(401).json({ error: "Access token required" });
}
// Verify token
jwt.verify(token, SECRET_KEY, (err, decoded) => {
if (err) {
return res.status(403).json({ error: "Invalid or expired token" });
}
// Attach user info to request
req.user = decoded;
next();
});
}
// Protected route
app.get("/profile", authenticateToken, async (req, res) => {
const user = await db.users.findById(req.user.userId);
res.json(user);
});

JWT Refresh Tokens

For better security, implement refresh tokens alongside access tokens:

// Login with refresh token
app.post("/login", async (req, res) => {
const { email, password } = req.body;
// ... verify credentials ...
// Short-lived access token (15 minutes)
const accessToken = jwt.sign(
{ userId: user.id, email: user.email },
SECRET_KEY,
{ expiresIn: "15m" },
);
// Long-lived refresh token (7 days)
const refreshToken = jwt.sign({ userId: user.id }, REFRESH_SECRET, {
expiresIn: "7d",
});
// Store refresh token in database
await db.refreshTokens.create({
userId: user.id,
token: refreshToken,
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
});
res.json({
accessToken,
refreshToken,
expiresIn: 900, // 15 minutes in seconds
});
});
// Refresh access token endpoint
app.post("/refresh", async (req, res) => {
const { refreshToken } = req.body;
if (!refreshToken) {
return res.status(401).json({ error: "Refresh token required" });
}
// Verify refresh token
let decoded;
try {
decoded = jwt.verify(refreshToken, REFRESH_SECRET);
} catch (err) {
return res.status(403).json({ error: "Invalid refresh token" });
}
// Check if refresh token exists in database
const storedToken = await db.refreshTokens.findOne({
userId: decoded.userId,
token: refreshToken,
});
if (!storedToken || storedToken.expiresAt < new Date()) {
return res.status(403).json({ error: "Refresh token expired" });
}
// Get user data
const user = await db.users.findById(decoded.userId);
// Issue new access token
const accessToken = jwt.sign(
{ userId: user.id, email: user.email },
SECRET_KEY,
{ expiresIn: "15m" },
);
res.json({
accessToken,
expiresIn: 900,
});
});

Advantages of JWT

Stateless: No server-side session storage needed ✅ Scalable: Works across multiple servers without shared storage ✅ Cross-Domain: Works with CORS and mobile apps ✅ Self-Contained: Token contains user information ✅ Flexible: Can include custom claims

Disadvantages of JWT

Token Size: Larger than session IDs (can impact performance) ❌ Revocation: Hard to invalidate tokens before expiration ❌ Security: If secret is compromised, all tokens are compromised ❌ No Server Control: Can’t easily revoke or update tokens ❌ XSS Vulnerability: Stored in localStorage, vulnerable to XSS


OAuth 2.0 and OpenID Connect

OAuth 2.0 is an authorization framework that allows third-party applications to obtain limited access to user accounts. OpenID Connect (OIDC) extends OAuth 2.0 to provide authentication capabilities.

Understanding OAuth 2.0 Flows

OAuth 2.0 defines several authorization flows for different use cases:

1. Authorization Code Flow (Most Common) Best for web applications with a backend server.

2. Implicit Flow (Deprecated) Previously used for single-page applications, now deprecated.

3. Client Credentials Flow For server-to-server communication.

4. Resource Owner Password Credentials Flow For trusted applications only.

5. Device Flow For devices with limited input capabilities.

Authorization Code Flow Implementation

// OAuth 2.0 Authorization Server (Provider)
const express = require("express");
const crypto = require("crypto");
const app = express();
// Store authorization codes temporarily
const authCodes = new Map();
// Step 1: Authorization endpoint
app.get("/oauth/authorize", (req, res) => {
const { client_id, redirect_uri, response_type, scope, state } = req.query;
// Validate client
if (!validateClient(client_id, redirect_uri)) {
return res.status(400).json({ error: "invalid_client" });
}
// Check if user is authenticated (simplified)
if (!req.session.userId) {
// Redirect to login page
return res.redirect(`/login?redirect=${encodeURIComponent(req.url)}`);
}
// Generate authorization code
const authCode = crypto.randomBytes(32).toString("hex");
authCodes.set(authCode, {
userId: req.session.userId,
clientId: client_id,
redirectUri: redirect_uri,
scope: scope,
expiresAt: Date.now() + 600000, // 10 minutes
});
// Redirect back to client with authorization code
res.redirect(`${redirect_uri}?code=${authCode}&state=${state}`);
});
// Step 2: Token endpoint
app.post("/oauth/token", async (req, res) => {
const { grant_type, code, redirect_uri, client_id, client_secret } = req.body;
if (grant_type !== "authorization_code") {
return res.status(400).json({ error: "unsupported_grant_type" });
}
// Validate client credentials
if (!validateClientCredentials(client_id, client_secret)) {
return res.status(401).json({ error: "invalid_client" });
}
// Validate authorization code
const authCodeData = authCodes.get(code);
if (!authCodeData || authCodeData.expiresAt < Date.now()) {
return res.status(400).json({ error: "invalid_grant" });
}
// Validate redirect URI matches
if (authCodeData.redirectUri !== redirect_uri) {
return res.status(400).json({ error: "invalid_grant" });
}
// Delete used authorization code
authCodes.delete(code);
// Generate access token
const accessToken = jwt.sign(
{
userId: authCodeData.userId,
clientId: client_id,
scope: authCodeData.scope,
},
SECRET_KEY,
{ expiresIn: "1h" },
);
// Generate refresh token
const refreshToken = jwt.sign(
{ userId: authCodeData.userId, clientId: client_id },
REFRESH_SECRET,
{ expiresIn: "30d" },
);
res.json({
access_token: accessToken,
token_type: "Bearer",
expires_in: 3600,
refresh_token: refreshToken,
scope: authCodeData.scope,
});
});

OAuth 2.0 Client Implementation

// OAuth 2.0 Client Application
class OAuth2Client {
constructor(config) {
this.clientId = config.clientId;
this.clientSecret = config.clientSecret;
this.redirectUri = config.redirectUri;
this.authorizationUrl = config.authorizationUrl;
this.tokenUrl = config.tokenUrl;
}
// Step 1: Redirect user to authorization server
getAuthorizationUrl(state) {
const params = new URLSearchParams({
response_type: "code",
client_id: this.clientId,
redirect_uri: this.redirectUri,
scope: "read write",
state: state,
});
return `${this.authorizationUrl}?${params.toString()}`;
}
// Step 2: Exchange authorization code for tokens
async exchangeCode(code) {
const response = await fetch(this.tokenUrl, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Authorization: `Basic ${Buffer.from(`${this.clientId}:${this.clientSecret}`).toString("base64")}`,
},
body: new URLSearchParams({
grant_type: "authorization_code",
code: code,
redirect_uri: this.redirectUri,
}),
});
return await response.json();
}
// Use access token to make API requests
async makeAuthenticatedRequest(url, accessToken) {
const response = await fetch(url, {
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
return await response.json();
}
}
// Usage in Express app
app.get("/oauth/callback", async (req, res) => {
const { code, state } = req.query;
// Verify state to prevent CSRF
if (state !== req.session.oauthState) {
return res.status(400).json({ error: "Invalid state" });
}
const oauthClient = new OAuth2Client({
clientId: process.env.OAUTH_CLIENT_ID,
clientSecret: process.env.OAUTH_CLIENT_SECRET,
redirectUri: "http://localhost:3000/oauth/callback",
authorizationUrl: "https://oauth-provider.com/oauth/authorize",
tokenUrl: "https://oauth-provider.com/oauth/token",
});
try {
const tokens = await oauthClient.exchangeCode(code);
// Store tokens securely (in database, encrypted)
await db.userTokens.create({
userId: req.session.userId,
accessToken: encrypt(tokens.access_token),
refreshToken: encrypt(tokens.refresh_token),
expiresAt: new Date(Date.now() + tokens.expires_in * 1000),
});
res.redirect("/dashboard");
} catch (error) {
res.status(500).json({ error: "OAuth flow failed" });
}
});

OpenID Connect (OIDC)

OpenID Connect extends OAuth 2.0 to provide authentication. It adds an ID token (JWT) that contains user identity information:

// OIDC adds ID token to OAuth 2.0 response
app.post("/oauth/token", async (req, res) => {
// ... OAuth 2.0 token generation ...
// Generate ID token (OpenID Connect)
const idToken = jwt.sign(
{
iss: "https://your-provider.com", // Issuer
sub: authCodeData.userId, // Subject (user ID)
aud: client_id, // Audience
exp: Math.floor(Date.now() / 1000) + 3600, // Expiration
iat: Math.floor(Date.now() / 1000), // Issued at
email: user.email,
name: user.name,
},
SECRET_KEY,
{ algorithm: "HS256" },
);
res.json({
access_token: accessToken,
token_type: "Bearer",
expires_in: 3600,
id_token: idToken, // OpenID Connect addition
refresh_token: refreshToken,
});
});

Advantages of OAuth 2.0

Delegated Authorization: Users can grant limited access without sharing passwords ✅ Standardized: Widely adopted standard ✅ Flexible: Multiple flows for different use cases ✅ Third-Party Integration: Perfect for “Sign in with Google/Facebook” ✅ Revocable: Users can revoke access at any time

Disadvantages of OAuth 2.0

Complexity: More complex than simple authentication ❌ Implementation Risk: Easy to implement incorrectly ❌ Token Management: Requires secure token storage ❌ User Experience: Multiple redirects can confuse users


Comparing Authentication Methods

Each authentication method has its ideal use case. Here’s a comparison to help you choose:

FeatureSessionsJWTOAuth 2.0
Stateless❌ No✅ Yes✅ Yes
Scalability⚠️ Requires shared storage✅ Excellent✅ Excellent
Mobile Apps❌ Poor✅ Good✅ Excellent
Cross-Domain⚠️ Complex✅ Good✅ Excellent
Revocation✅ Immediate❌ Difficult✅ Possible
Security✅ High⚠️ Medium✅ High
Complexity✅ Low✅ Medium❌ High
Use CaseTraditional web appsAPIs, SPAsThird-party integration

When to Use Session-Based Authentication

Traditional Web Applications: Server-rendered apps with cookies ✅ Single Domain: All requests from same domain ✅ Server Control: Need immediate session revocation ✅ CSRF Protection: Can use CSRF tokens effectively

// Example: E-commerce website
// Sessions work perfectly for shopping carts, user preferences
app.use(
session({
secret: process.env.SESSION_SECRET,
store: new RedisStore({ client: redisClient }),
}),
);

When to Use JWT

REST APIs: Stateless API endpoints ✅ Microservices: Distributed systems without shared storage ✅ Mobile Apps: Native mobile applications ✅ SPAs: Single-page applications with separate API

// Example: Mobile app backend API
// JWT tokens work great for mobile apps
app.post("/api/login", async (req, res) => {
// ... verify credentials ...
const token = jwt.sign({ userId: user.id }, SECRET_KEY);
res.json({ token });
});

When to Use OAuth 2.0

Third-Party Integration: “Sign in with Google/Facebook” ✅ API Access: Allowing third parties to access your API ✅ Delegated Authorization: Users granting access to other apps ✅ Enterprise SSO: Single sign-on for organizations

// Example: Allowing users to connect their Google account
app.get("/auth/google", (req, res) => {
const authUrl = oauthClient.getAuthorizationUrl();
res.redirect(authUrl);
});

Security Best Practices

Regardless of which authentication method you choose, following security best practices is crucial.

Password Security

Hash Passwords: Never store plain text passwords

const bcrypt = require("bcrypt");
const saltRounds = 12;
// Hash password before storing
const hashedPassword = await bcrypt.hash(password, saltRounds);
// Verify password
const isValid = await bcrypt.compare(password, hashedPassword);

Use Strong Passwords: Enforce password policies

function validatePassword(password) {
const minLength = 8;
const hasUpperCase = /[A-Z]/.test(password);
const hasLowerCase = /[a-z]/.test(password);
const hasNumbers = /\d/.test(password);
const hasSpecialChar = /[!@#$%^&*]/.test(password);
return (
password.length >= minLength &&
hasUpperCase &&
hasLowerCase &&
hasNumbers &&
hasSpecialChar
);
}

Rate Limiting: Prevent brute force attacks

const rateLimit = require("express-rate-limit");
const loginLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 5, // 5 attempts per window
message: "Too many login attempts, please try again later",
});
app.post("/login", loginLimiter, async (req, res) => {
// ... login logic ...
});

Token Security

Use HTTPS: Always use HTTPS in production

// Enforce HTTPS
if (process.env.NODE_ENV === "production") {
app.use((req, res, next) => {
if (req.header("x-forwarded-proto") !== "https") {
res.redirect(`https://${req.header("host")}${req.url}`);
} else {
next();
}
});
}

Short Token Expiration: Use short-lived access tokens

// Access token: 15 minutes
const accessToken = jwt.sign(payload, SECRET_KEY, {
expiresIn: "15m",
});
// Refresh token: 7 days
const refreshToken = jwt.sign(payload, REFRESH_SECRET, {
expiresIn: "7d",
});

Secure Token Storage: Store tokens securely

// ❌ Don't store in localStorage (vulnerable to XSS)
localStorage.setItem("token", token);
// ✅ Store in httpOnly cookie (more secure)
res.cookie("token", token, {
httpOnly: true,
secure: true,
sameSite: "strict",
});

Token Rotation: Rotate refresh tokens

app.post("/refresh", async (req, res) => {
const { refreshToken } = req.body;
// Verify old refresh token
const decoded = jwt.verify(refreshToken, REFRESH_SECRET);
// Delete old refresh token
await db.refreshTokens.delete({ token: refreshToken });
// Issue new access token and refresh token
const newAccessToken = jwt.sign(payload, SECRET_KEY, { expiresIn: "15m" });
const newRefreshToken = jwt.sign(payload, REFRESH_SECRET, {
expiresIn: "7d",
});
// Store new refresh token
await db.refreshTokens.create({
token: newRefreshToken,
userId: decoded.userId,
});
res.json({
accessToken: newAccessToken,
refreshToken: newRefreshToken,
});
});

Session Security

Secure Cookies: Configure cookies securely

app.use(
session({
secret: process.env.SESSION_SECRET,
cookie: {
secure: true, // HTTPS only
httpOnly: true, // Prevent XSS
sameSite: "strict", // CSRF protection
maxAge: 24 * 60 * 60 * 1000, // 24 hours
},
}),
);

Session Regeneration: Regenerate session ID after login

app.post("/login", async (req, res) => {
// ... verify credentials ...
// Regenerate session to prevent session fixation
req.session.regenerate((err) => {
if (err) throw err;
req.session.userId = user.id;
res.json({ message: "Login successful" });
});
});

Additional Security Measures

CSRF Protection: Protect against CSRF attacks

const csrf = require("csurf");
const csrfProtection = csrf({ cookie: true });
app.use(csrfProtection);
// Include CSRF token in forms
app.get("/form", (req, res) => {
res.render("form", { csrfToken: req.csrfToken() });
});

CORS Configuration: Configure CORS properly

const cors = require("cors");
app.use(
cors({
origin: process.env.ALLOWED_ORIGINS.split(","),
credentials: true,
methods: ["GET", "POST", "PUT", "DELETE"],
allowedHeaders: ["Content-Type", "Authorization"],
}),
);

Security Headers: Add security headers

const helmet = require("helmet");
app.use(
helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
styleSrc: ["'self'", "'unsafe-inline'"],
scriptSrc: ["'self'"],
imgSrc: ["'self'", "data:", "https:"],
},
},
}),
);

Implementation Examples

Let’s look at complete implementation examples for each authentication method.

Complete Session-Based Authentication

const express = require("express");
const session = require("express-session");
const RedisStore = require("connect-redis")(session);
const redis = require("redis");
const bcrypt = require("bcrypt");
const rateLimit = require("express-rate-limit");
const app = express();
const redisClient = redis.createClient();
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
// Session configuration
app.use(
session({
store: new RedisStore({ client: redisClient }),
secret: process.env.SESSION_SECRET,
resave: false,
saveUninitialized: false,
name: "sessionId", // Don't use default 'connect.sid'
cookie: {
secure: process.env.NODE_ENV === "production",
httpOnly: true,
sameSite: "strict",
maxAge: 24 * 60 * 60 * 1000, // 24 hours
},
}),
);
// Rate limiting
const loginLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 5,
message: "Too many login attempts",
});
// Registration
app.post("/register", async (req, res) => {
const { email, password, name } = req.body;
// Validate input
if (!email || !password || !name) {
return res.status(400).json({ error: "All fields required" });
}
// Check if user exists
const existingUser = await db.users.findOne({ email });
if (existingUser) {
return res.status(409).json({ error: "User already exists" });
}
// Hash password
const hashedPassword = await bcrypt.hash(password, 12);
// Create user
const user = await db.users.create({
email,
password: hashedPassword,
name,
});
res.status(201).json({ message: "User created", userId: user.id });
});
// Login
app.post("/login", loginLimiter, async (req, res) => {
const { email, password } = req.body;
// Find user
const user = await db.users.findOne({ email });
if (!user) {
return res.status(401).json({ error: "Invalid credentials" });
}
// Verify password
const isValid = await bcrypt.compare(password, user.password);
if (!isValid) {
return res.status(401).json({ error: "Invalid credentials" });
}
// Regenerate session
req.session.regenerate((err) => {
if (err) {
return res.status(500).json({ error: "Login failed" });
}
req.session.userId = user.id;
req.session.email = user.email;
req.session.role = user.role;
res.json({
message: "Login successful",
user: {
id: user.id,
email: user.email,
name: user.name,
},
});
});
});
// Authentication middleware
function requireAuth(req, res, next) {
if (!req.session.userId) {
return res.status(401).json({ error: "Authentication required" });
}
next();
}
// Protected route
app.get("/profile", requireAuth, async (req, res) => {
const user = await db.users.findById(req.session.userId);
res.json({
id: user.id,
email: user.email,
name: user.name,
role: user.role,
});
});
// Logout
app.post("/logout", (req, res) => {
req.session.destroy((err) => {
if (err) {
return res.status(500).json({ error: "Logout failed" });
}
res.clearCookie("sessionId");
res.json({ message: "Logout successful" });
});
});
app.listen(3000, () => {
console.log("Server running on port 3000");
});

Complete JWT Authentication

const express = require("express");
const jwt = require("jsonwebtoken");
const bcrypt = require("bcrypt");
const rateLimit = require("express-rate-limit");
const app = express();
const ACCESS_SECRET = process.env.JWT_ACCESS_SECRET;
const REFRESH_SECRET = process.env.JWT_REFRESH_SECRET;
app.use(express.json());
// Rate limiting
const loginLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 5,
});
// Register
app.post("/register", async (req, res) => {
const { email, password, name } = req.body;
if (!email || !password || !name) {
return res.status(400).json({ error: "All fields required" });
}
const existingUser = await db.users.findOne({ email });
if (existingUser) {
return res.status(409).json({ error: "User already exists" });
}
const hashedPassword = await bcrypt.hash(password, 12);
const user = await db.users.create({
email,
password: hashedPassword,
name,
});
res.status(201).json({ message: "User created", userId: user.id });
});
// Login
app.post("/login", loginLimiter, async (req, res) => {
const { email, password } = req.body;
const user = await db.users.findOne({ email });
if (!user) {
return res.status(401).json({ error: "Invalid credentials" });
}
const isValid = await bcrypt.compare(password, user.password);
if (!isValid) {
return res.status(401).json({ error: "Invalid credentials" });
}
// Generate tokens
const accessToken = jwt.sign(
{ userId: user.id, email: user.email, role: user.role },
ACCESS_SECRET,
{ expiresIn: "15m" },
);
const refreshToken = jwt.sign({ userId: user.id }, REFRESH_SECRET, {
expiresIn: "7d",
});
// Store refresh token
await db.refreshTokens.create({
userId: user.id,
token: refreshToken,
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
});
res.json({
accessToken,
refreshToken,
expiresIn: 900,
user: {
id: user.id,
email: user.email,
name: user.name,
},
});
});
// Refresh token
app.post("/refresh", async (req, res) => {
const { refreshToken } = req.body;
if (!refreshToken) {
return res.status(401).json({ error: "Refresh token required" });
}
let decoded;
try {
decoded = jwt.verify(refreshToken, REFRESH_SECRET);
} catch (err) {
return res.status(403).json({ error: "Invalid refresh token" });
}
const storedToken = await db.refreshTokens.findOne({
userId: decoded.userId,
token: refreshToken,
});
if (!storedToken || storedToken.expiresAt < new Date()) {
return res.status(403).json({ error: "Refresh token expired" });
}
const user = await db.users.findById(decoded.userId);
// Issue new access token
const accessToken = jwt.sign(
{ userId: user.id, email: user.email, role: user.role },
ACCESS_SECRET,
{ expiresIn: "15m" },
);
res.json({
accessToken,
expiresIn: 900,
});
});
// Authentication middleware
function authenticateToken(req, res, next) {
const authHeader = req.headers["authorization"];
const token = authHeader && authHeader.split(" ")[1];
if (!token) {
return res.status(401).json({ error: "Access token required" });
}
jwt.verify(token, ACCESS_SECRET, (err, decoded) => {
if (err) {
return res.status(403).json({ error: "Invalid or expired token" });
}
req.user = decoded;
next();
});
}
// Protected route
app.get("/profile", authenticateToken, async (req, res) => {
const user = await db.users.findById(req.user.userId);
res.json({
id: user.id,
email: user.email,
name: user.name,
role: user.role,
});
});
// Logout (revoke refresh token)
app.post("/logout", authenticateToken, async (req, res) => {
const { refreshToken } = req.body;
if (refreshToken) {
await db.refreshTokens.delete({ token: refreshToken });
}
res.json({ message: "Logout successful" });
});
app.listen(3000, () => {
console.log("Server running on port 3000");
});

Common Pitfalls and Anti-Patterns

Avoid these common mistakes when implementing authentication:

❌ Storing Passwords in Plain Text

// ❌ NEVER do this
await db.users.create({
email: email,
password: password, // Plain text password!
});
// ✅ Always hash passwords
const hashedPassword = await bcrypt.hash(password, 12);
await db.users.create({
email: email,
password: hashedPassword,
});

❌ Weak Session Secrets

// ❌ Weak secret
app.use(
session({
secret: "secret123", // Too weak!
}),
);
// ✅ Strong, random secret from environment
app.use(
session({
secret: process.env.SESSION_SECRET, // Long, random string
}),
);

❌ JWT Secret in Code

// ❌ Secret in code
const token = jwt.sign(payload, "my-secret-key");
// ✅ Secret from environment
const token = jwt.sign(payload, process.env.JWT_SECRET);

❌ Storing JWT in localStorage

// ❌ Vulnerable to XSS
localStorage.setItem("token", token);
// ✅ Use httpOnly cookies or secure storage
res.cookie("token", token, {
httpOnly: true,
secure: true,
sameSite: "strict",
});

❌ Not Validating Tokens

// ❌ Trusting token without validation
const decoded = jwt.decode(token); // Doesn't verify signature!
// ✅ Always verify tokens
const decoded = jwt.verify(token, SECRET_KEY);

❌ Long Token Expiration

// ❌ Tokens that never expire
const token = jwt.sign(payload, SECRET_KEY); // No expiration!
// ✅ Short-lived access tokens
const token = jwt.sign(payload, SECRET_KEY, {
expiresIn: "15m", // 15 minutes
});

❌ Not Handling Token Expiration

// ❌ No error handling
app.get("/profile", (req, res) => {
const decoded = jwt.verify(req.headers.authorization, SECRET_KEY);
// Will crash if token is expired
});
// ✅ Proper error handling
app.get("/profile", (req, res) => {
try {
const token = req.headers.authorization.split(" ")[1];
const decoded = jwt.verify(token, SECRET_KEY);
// Use decoded data
} catch (err) {
if (err.name === "TokenExpiredError") {
return res.status(401).json({ error: "Token expired" });
}
return res.status(403).json({ error: "Invalid token" });
}
});

❌ Not Implementing Rate Limiting

// ❌ No rate limiting - vulnerable to brute force
app.post("/login", async (req, res) => {
// Login logic
});
// ✅ Rate limiting to prevent brute force
const loginLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 5,
});
app.post("/login", loginLimiter, async (req, res) => {
// Login logic
});

Conclusion

Authentication and authorization are critical components of secure web applications. Understanding the different approaches—session-based authentication, JWT tokens, and OAuth 2.0—allows you to choose the right solution for your specific use case.

Key Takeaways:

  • Sessions work best for traditional web applications with server-side rendering
  • JWT excels in stateless APIs, microservices, and mobile applications
  • OAuth 2.0 is ideal for third-party integrations and delegated authorization
  • Security is paramount: always hash passwords, use HTTPS, implement rate limiting, and follow best practices
  • Choose wisely: Consider your application’s architecture, scalability needs, and security requirements

Remember that no single authentication method is perfect for all scenarios. The best approach depends on your application’s specific requirements, architecture, and security needs. By understanding the trade-offs and implementing security best practices, you can build robust authentication systems that protect your users and your application.

For more security best practices, check out our guide on web security best practices. If you’re working with APIs, our GraphQL vs REST API comparison can help you choose the right API architecture.

Next Steps:

  1. Implement authentication in your application using the method that best fits your needs
  2. Add proper security measures: rate limiting, CSRF protection, secure headers
  3. Test your implementation thoroughly, including edge cases and security scenarios
  4. Monitor authentication failures and implement logging for security analysis
  5. Keep dependencies updated and follow security advisories

Authentication is not a one-time implementation—it requires ongoing attention, monitoring, and updates to stay secure against evolving threats.