Securing User Sessions: How Modern Authentication Works
Most auth vulnerabilities aren't in the login form — they're in how tokens are issued, stored, and invalidated. This breaks down access/refresh token flows, blacklisting strategies, and the production edge cases that keep user sessions genuinely secure.
JSON Web Tokens (JWT) are the industry standard for stateless authentication in modern web applications. This comprehensive guide covers JWT fundamentals, secure token generation and validation, refresh token strategies, and production-ready patterns for NestJS and React applications.
What is JWT?
A JWT is a self-contained, cryptographically signed token that carries user information (claims) without requiring server-side session storage. Unlike traditional session-based authentication, JWTs enable stateless, scalable authentication.
JWT vs Session Cookies
| Feature | Session Cookies | JWT |
|---|---|---|
| Storage | Server-side (Redis, DB) | Client-side (localStorage, cookies) |
| Scalability | ❌ Requires sticky sessions | ✅ Stateless, any server can verify |
| Revocation | ✅ Immediate (delete from store) | ❌ Requires blacklist or short expiry |
| Cross-Domain | ❌ Same-origin only | ✅ Works across domains |
| Mobile Apps | ⚠️ Complex cookie handling | ✅ Simple Authorization header |
| Size | ✅ Small session ID | ⚠️ Larger payload (200-500 bytes) |
JWT Structure
A JWT consists of three parts separated by dots: header.payload.signature
JWT STRUCTURE
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
└─────────────┬─────────────────┘ └─────────────────────────────────────────────────────────┘ └──────────────────────┬─────────────────────┘
HEADER PAYLOAD SIGNATURE
1. HEADER (Base64URL encoded JSON)
{
"alg": "HS256", // Algorithm: HMAC-SHA256
"typ": "JWT" // Type: JWT
}
2. PAYLOAD (Base64URL encoded JSON)
{
"userId": "1234567890",
"name": "John Doe",
"iat": 1516239022, // Issued At (timestamp)
"exp": 1516242622 // Expiration (timestamp)
}
3. SIGNATURE (HMACSHA256)
HMACSHA256(
base64UrlEncode(header) + "." + base64UrlEncode(payload),
secret
)
VERIFICATION FLOW
┌────────────────────────────────────────────────┐
│ 1. Split JWT into header, payload, signature │
│ 2. Decode header and payload (Base64URL) │
│ 3. Verify signature: │
│ - Compute HMAC of header + payload │
│ - Compare with provided signature │
│ 4. Check expiration (exp claim) │
│ 5. Extract user data from payload │
└────────────────────────────────────────────────┘
Backend Implementation (NestJS)
Dependencies
npm install @nestjs/jwt bcrypt
npm install -D @types/bcrypt
JWT Module Configuration
import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { AuthService } from './auth.service';
import { AuthController } from './auth.controller';
@Module({
imports: [
JwtModule.register({
global: true,
secret: process.env.JWT_SECRET,
signOptions: { expiresIn: '15m' },
}),
],
providers: [AuthService],
controllers: [AuthController],
})
export class AuthModule {}
AuthService: Token Generation
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import * as bcrypt from 'bcrypt';
import { PrismaService } from './prisma.service';
interface JwtPayload {
userId: string;
email: string;
role: string;
}
@Injectable()
export class AuthService {
constructor(
private jwtService: JwtService,
private prisma: PrismaService,
) {}
generateAccessToken(user: { id: string; email: string; role: string }): string {
const payload: JwtPayload = { userId: user.id, email: user.email, role: user.role };
return this.jwtService.sign(payload);
}
generateRefreshToken(user: { id: string }): string {
return this.jwtService.sign(
{ userId: user.id, tokenType: 'refresh' },
{ secret: process.env.JWT_REFRESH_SECRET, expiresIn: '7d' }
);
}
async validateUser(email: string, password: string) {
const user = await this.prisma.user.findUnique({ where: { email } });
if (!user) return null;
const isPasswordValid = await bcrypt.compare(password, user.password);
if (!isPasswordValid) return null;
const { password: _, ...result } = user;
return result;
}
async login(email: string, password: string) {
const user = await this.validateUser(email, password);
if (!user) throw new UnauthorizedException('Invalid credentials');
const accessToken = this.generateAccessToken(user);
const refreshToken = this.generateRefreshToken(user);
await this.prisma.refreshToken.create({
data: {
token: refreshToken,
userId: user.id,
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
},
});
return { accessToken, refreshToken, user };
}
async verifyAccessToken(token: string): Promise<JwtPayload> {
try {
return this.jwtService.verify(token);
} catch {
throw new UnauthorizedException('Invalid token');
}
}
async refreshAccessToken(refreshToken: string) {
try {
const payload = this.jwtService.verify(refreshToken, {
secret: process.env.JWT_REFRESH_SECRET,
});
const storedToken = await this.prisma.refreshToken.findFirst({
where: {
token: refreshToken,
userId: payload.userId,
revoked: false,
expiresAt: { gt: new Date() },
},
include: { user: true },
});
if (!storedToken) throw new UnauthorizedException('Invalid refresh token');
return { accessToken: this.generateAccessToken(storedToken.user) };
} catch {
throw new UnauthorizedException('Invalid refresh token');
}
}
async logout(refreshToken: string) {
await this.prisma.refreshToken.updateMany({
where: { token: refreshToken },
data: { revoked: true },
});
}
}
JWT Auth Guard
import { CanActivate, ExecutionContext, Injectable, UnauthorizedException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { Request } from 'express';
@Injectable()
export class JwtAuthGuard implements CanActivate {
constructor(private jwtService: JwtService) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest();
const token = this.extractTokenFromHeader(request);
if (!token) throw new UnauthorizedException('No token provided');
try {
request.user = await this.jwtService.verifyAsync(token);
} catch {
throw new UnauthorizedException('Invalid token');
}
return true;
}
private extractTokenFromHeader(request: Request): string | undefined {
const [type, token] = request.headers.authorization?.split(' ') ?? [];
return type === 'Bearer' ? token : undefined;
}
}
Role-Based Access Control
import { SetMetadata, Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
export const Roles = (...roles: string[]) => SetMetadata('roles', roles);
@Injectable()
export class RolesGuard implements CanActivate {
constructor(private reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const requiredRoles = this.reflector.get<string[]>('roles', context.getHandler());
if (!requiredRoles) return true;
const { user } = context.switchToHttp().getRequest();
return requiredRoles.includes(user.role);
}
}
// Usage
@Controller('admin')
@UseGuards(JwtAuthGuard, RolesGuard)
export class AdminController {
@Get('users')
@Roles('admin', 'moderator')
getUsers() {
return 'Admin only route';
}
}
AuthController
import { Controller, Post, Body, Get, UseGuards, Req } from '@nestjs/common';
import { AuthService } from './auth.service';
import { JwtAuthGuard } from './jwt-auth.guard';
@Controller('auth')
export class AuthController {
constructor(private authService: AuthService) {}
@Post('login')
async login(@Body() body: { email: string; password: string }) {
return this.authService.login(body.email, body.password);
}
@Post('refresh')
async refresh(@Body() body: { refreshToken: string }) {
return this.authService.refreshAccessToken(body.refreshToken);
}
@Post('logout')
@UseGuards(JwtAuthGuard)
async logout(@Body() body: { refreshToken: string }) {
return this.authService.logout(body.refreshToken);
}
@Get('me')
@UseGuards(JwtAuthGuard)
async getProfile(@Req() req: any) {
return req.user;
}
}
Frontend Implementation (React)
Auth Context
import { createContext, useContext, useState, useEffect, ReactNode } from 'react';
import { jwtDecode } from 'jwt-decode';
interface User {
userId: string;
email: string;
role: string;
}
interface AuthContextValue {
user: User | null;
accessToken: string | null;
login: (email: string, password: string) => Promise;
logout: () => Promise;
isLoading: boolean;
}
const AuthContext = createContext(null);
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState(null);
const [accessToken, setAccessToken] = useState(null);
const [isLoading, setIsLoading] = useState(true);
// Initialize auth on mount
useEffect(() => {
const token = localStorage.getItem('accessToken');
const refresh = localStorage.getItem('refreshToken');
if (token && refresh) {
try {
const decoded = jwtDecode(token);
// Check if token is expired
if (decoded.exp && decoded.exp * 1000 < Date.now()) {
// Token expired, try to refresh
refreshAccessToken();
} else {
setUser(decoded);
setAccessToken(token);
}
} catch (error) {
console.error('Invalid token');
clearTokens();
}
}
setIsLoading(false);
}, []);
const login = async (email: string, password: string) => {
const response = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
});
if (!response.ok) {
throw new Error('Login failed');
}
const data = await response.json();
localStorage.setItem('accessToken', data.accessToken);
localStorage.setItem('refreshToken', data.refreshToken);
const decoded = jwtDecode(data.accessToken);
setUser(decoded);
setAccessToken(data.accessToken);
};
const logout = async () => {
const refreshToken = localStorage.getItem('refreshToken');
if (refreshToken) {
await fetch('/api/auth/logout', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refreshToken }),
});
}
clearTokens();
};
const refreshAccessToken = async () => {
const refreshToken = localStorage.getItem('refreshToken');
if (!refreshToken) {
clearTokens();
return;
}
try {
const response = await fetch('/api/auth/refresh', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refreshToken }),
});
if (!response.ok) {
throw new Error('Refresh failed');
}
const data = await response.json();
localStorage.setItem('accessToken', data.accessToken);
const decoded = jwtDecode(data.accessToken);
setUser(decoded);
setAccessToken(data.accessToken);
} catch (error) {
console.error('Failed to refresh token');
clearTokens();
}
};
const clearTokens = () => {
localStorage.removeItem('accessToken');
localStorage.removeItem('refreshToken');
setUser(null);
setAccessToken(null);
};
return (
{children}
);
}
export function useAuth() {
const context = useContext(AuthContext);
if (!context) throw new Error('useAuth must be used within AuthProvider');
return context;
}
API Client with Auto-Refresh
let isRefreshing = false;
let refreshSubscribers: ((token: string) => void)[] = [];
function subscribeTokenRefresh(cb: (token: string) => void) {
refreshSubscribers.push(cb);
}
function onTokenRefreshed(token: string) {
refreshSubscribers.forEach(cb => cb(token));
refreshSubscribers = [];
}
export async function apiClient(url: string, options: RequestInit = {}) {
const accessToken = localStorage.getItem('accessToken');
// Add Authorization header
const headers = {
...options.headers,
Authorization: `Bearer ${accessToken}`,
};
let response = await fetch(url, { ...options, headers });
// If 401, try to refresh token
if (response.status === 401 && !isRefreshing) {
isRefreshing = true;
try {
const refreshToken = localStorage.getItem('refreshToken');
const refreshResponse = await fetch('/api/auth/refresh', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refreshToken }),
});
if (!refreshResponse.ok) {
throw new Error('Refresh failed');
}
const data = await refreshResponse.json();
localStorage.setItem('accessToken', data.accessToken);
isRefreshing = false;
onTokenRefreshed(data.accessToken);
// Retry original request with new token
headers.Authorization = `Bearer ${data.accessToken}`;
response = await fetch(url, { ...options, headers });
} catch (error) {
isRefreshing = false;
localStorage.clear();
window.location.href = '/login';
throw error;
}
}
// If still refreshing, queue this request
if (isRefreshing) {
return new Promise((resolve) => {
subscribeTokenRefresh((token: string) => {
headers.Authorization = `Bearer ${token}`;
resolve(fetch(url, { ...options, headers }));
});
});
}
return response;
}
Security Best Practices
1. Use Strong Secrets
# Generate strong random secret
node -e "console.log(require('crypto').randomBytes(64).toString('hex'))"
# .env
JWT_SECRET=a8f5f167f44f4964e6c998dee827110c03d3fb8f36f8c1bd3f8c2f8f6f8d8f8e
JWT_REFRESH_SECRET=b9g6g278g55g5075f7d009eef938221d04e4gc9g47g9d2ce4g9d3g9g7g9e9g9f
2. Short Access Token Expiry
- Access tokens: 15 minutes (reduces window for token theft)
- Refresh tokens: 7 days (balance security and UX)
3. Secure Storage
| Storage Method | Security | Notes |
|---|---|---|
| localStorage | ⚠️ XSS vulnerable | JavaScript can access |
| HttpOnly Cookie | ✅ XSS protected | ⚠️ CSRF vulnerable (use CSRF tokens) |
| Memory only | ✅ Most secure | ❌ Lost on page refresh |
4. Minimal Payload
// ❌ Bad: Sensitive data in JWT
const payload = {
userId: user.id,
email: user.email,
password: user.password, // Never!
creditCard: user.creditCard, // Never!
permissions: user.permissions, // Bloats token
};
// ✅ Good: Minimal claims
const payload = {
userId: user.id,
email: user.email,
role: user.role,
};
// Fetch additional data from database when needed
5. Algorithm Validation
// Prevent "alg: none" attack
JwtModule.register({
secret: process.env.JWT_SECRET,
signOptions: {
algorithm: 'HS256', // Explicitly set algorithm
},
verifyOptions: {
algorithms: ['HS256'], // Only allow HS256
},
});
Conclusion
JWTs provide a scalable, stateless authentication mechanism perfect for modern microservices and mobile apps. Combined with refresh tokens and secure storage practices, you can build production-grade authentication systems.
Key takeaways:
- Stateless verification: Any server can validate JWTs without database lookups
- Access + Refresh pattern: Short-lived access tokens with long-lived refresh tokens
- Auto-refresh: Seamlessly refresh expired tokens client-side
- Security first: Use strong secrets, minimal payloads, and HttpOnly cookies when possible
- Role-based access: Embed user roles in JWTs for authorization
Understanding JWT fundamentals and implementing them securely is critical for building authentication systems that scale from startups to enterprise applications.
Explore the Code
Check out the ft_transcendence repository on GitHub to see these patterns in a real-world multiplayer game.
Written by

Technical Lead and Full Stack Engineer leading a 5-engineer team at Fygurs (Paris, Remote) on Azure cloud-native SaaS. Graduate of 1337 Coding School (42 Network / UM6P). Writes about architecture, cloud infrastructure, and engineering leadership.