JWT vs Sessions: Pick the Right Auth Strategy (2026 Deep Dive)
JWT and sessions both handle authentication — but they make completely different trade-offs. Here's when to use each, what can go wrong, and how I implement auth in production NestJS projects.
JWT and sessions solve the same problem differently. Here's a practical breakdown of when to use each — with real trade-offs, security considerations, and code examples.
The Core Difference
Both JWT and sessions answer the same question: how does the server know who you are after you log in?
They just answer it in completely different ways.
SESSION FLOW:
User logs in
↓
Server creates session in database
↓
Server sends session ID to client (cookie)
↓
Client sends session ID on every request
↓
Server looks up session ID in database
↓
Server knows who you are ✅
JWT FLOW:
User logs in
↓
Server creates signed token containing user data
↓
Server sends token to client (localStorage or cookie)
↓
Client sends token on every request
↓
Server verifies signature — no database lookup needed
↓
Server knows who you are ✅
One approach stores state on the server. The other stores it in the token itself.
How JWT Works
A JWT has three parts separated by dots:
eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ1c2VyXzEyMyJ9.abc123signature
↑ ↑ ↑
Header Payload Signature
(algorithm) (your user data) (tamper-proof)
The payload is just base64 encoded — not encrypted. Anyone can decode it.
// Decode the payload (no secret needed)
const payload = JSON.parse(atob("eyJzdWIiOiJ1c2VyXzEyMyJ9"));
// { sub: "user_123", role: "admin", iat: 1234567890 }
The signature is what makes it secure — it's created using your secret key. If anyone tampers with the payload, the signature won't match and the token is rejected.
JWT Implementation in NestJS
npm install @nestjs/jwt passport-jwt @nestjs/passport
// auth.module.ts
@Module({
imports: [
JwtModule.registerAsync({
useFactory: (config: ConfigService) => ({
secret: config.get("JWT_SECRET"),
signOptions: { expiresIn: "15m" }, // short-lived access token
}),
inject: [ConfigService],
}),
],
})
export class AuthModule {}
// auth.service.ts
@Injectable()
export class AuthService {
constructor(
private prisma: PrismaService,
private jwtService: JwtService,
) {}
async login(dto: LoginDto) {
const user = await this.prisma.user.findUnique({
where: { email: dto.email },
});
if (!user || !(await bcrypt.compare(dto.password, user.password))) {
throw new UnauthorizedException("Invalid credentials");
}
// Access token — short lived
const accessToken = this.jwtService.sign({
sub: user.id,
email: user.email,
role: user.role,
});
// Refresh token — long lived, stored in DB
const refreshToken = this.jwtService.sign(
{ sub: user.id },
{ expiresIn: "7d" },
);
// Store hashed refresh token in database
await this.prisma.user.update({
where: { id: user.id },
data: {
refreshToken: await bcrypt.hash(refreshToken, 10),
},
});
return { accessToken, refreshToken };
}
async refreshTokens(userId: string, refreshToken: string) {
const user = await this.prisma.user.findUnique({
where: { id: userId },
});
if (!user?.refreshToken) {
throw new UnauthorizedException("Access denied");
}
// Verify refresh token matches stored hash
const matches = await bcrypt.compare(refreshToken, user.refreshToken);
if (!matches) throw new UnauthorizedException("Access denied");
// Issue new tokens
return this.login({ email: user.email, password: "" });
}
async logout(userId: string) {
// Invalidate refresh token
await this.prisma.user.update({
where: { id: userId },
data: { refreshToken: null },
});
}
}
// jwt.strategy.ts
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(config: ConfigService) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
secretOrKey: config.get("JWT_SECRET"),
});
}
async validate(payload: any) {
return {
id: payload.sub,
email: payload.email,
role: payload.role,
};
}
}
Session Implementation in NestJS
npm install express-session connect-pg-simple @types/express-session
// main.ts
import * as session from "express-session";
import * as connectPg from "connect-pg-simple";
const PgStore = connectPg(session);
app.use(
session({
store: new PgStore({
conString: process.env.DATABASE_URL,
tableName: "sessions",
}),
secret: process.env.SESSION_SECRET,
resave: false,
saveUninitialized: false,
cookie: {
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
httpOnly: true, // prevents XSS
secure: true, // HTTPS only
sameSite: "strict", // prevents CSRF
},
}),
);
// auth.service.ts — session-based login
async login(dto: LoginDto, session: Record<string, any>) {
const user = await this.prisma.user.findUnique({
where: { email: dto.email },
});
if (!user || !(await bcrypt.compare(dto.password, user.password))) {
throw new UnauthorizedException('Invalid credentials');
}
// Store user data in session
session.userId = user.id;
session.role = user.role;
return { message: 'Logged in successfully' };
}
async logout(session: Record<string, any>) {
return new Promise((resolve, reject) => {
session.destroy((err) => {
if (err) reject(new InternalServerErrorException());
resolve({ message: 'Logged out' });
});
});
}
Building something like this? Hire me to build your backend — NestJS, Node.js, PostgreSQL & Redis, shipped production-ready.
The Real Trade-offs
JWT Advantages
No database lookup on every request:
Session: every request → database query (check if session valid)
JWT: every request → signature verification (CPU only, no DB)
For high-traffic APIs this matters. 10,000 requests/second with sessions = 10,000 DB queries/second just for auth.
Works perfectly for microservices:
Client → Service A → verifies JWT locally ✅
→ Service B → verifies JWT locally ✅
→ Service C → verifies JWT locally ✅
Each service verifies the token independently. No shared session store needed.
Stateless — scales horizontally:
Load Balancer
↓ ↓ ↓
Server1 Server2 Server3
Any server can verify any JWT. Sessions require sticky sessions or a shared store.
JWT Disadvantages
You cannot invalidate a token before it expires:
// User changes password — old JWT still works until expiry ❌
// User gets banned — their JWT still works until expiry ❌
// User logs out — their JWT still works until expiry ❌
This is the biggest problem with JWT. The common workaround:
// Token blocklist in Redis
async logout(token: string) {
const decoded = this.jwtService.decode(token);
const ttl = decoded.exp - Math.floor(Date.now() / 1000);
// Blocklist token until it naturally expires
await this.redis.setex(`blocklist:${token}`, ttl, '1');
}
// Check blocklist in every request
async validateToken(token: string) {
const isBlocklisted = await this.redis.get(`blocklist:${token}`);
if (isBlocklisted) throw new UnauthorizedException('Token invalidated');
// ... rest of validation
}
But now you're doing a Redis lookup on every request anyway — partially defeating the stateless advantage.
Tokens can be stolen:
If someone gets your JWT — from localStorage, logs, or a man-in-the-middle attack — they have full access until expiry. Sessions can be invalidated server-side immediately.
Session Advantages
Instant revocation:
// Ban a user → delete their session → they're logged out immediately ✅
await this.prisma.session.deleteMany({ where: { userId: bannedUserId } });
Sensitive data never leaves the server:
JWT: role, permissions, email → all in the token → readable by anyone
Session: session ID only → actual data stays on server → more secure
Simpler mental model:
No access tokens, refresh tokens, token rotation, or expiry management. One session, one source of truth.
Session Disadvantages
Database dependency:
Every request hits the session store. If your session store goes down, authentication fails for all users.
Doesn't work well across domains:
app.yoursite.com → session cookie ✅
api.yoursite.com → different domain → cookie not sent ❌
mobile app → no cookies → sessions don't apply ❌
Sessions work great for server-rendered apps. They're painful for APIs consumed by mobile apps or third-party clients.
When to Use Which
Use JWT when:
✅ Building a REST API consumed by mobile apps
✅ Microservices architecture
✅ Multiple domains or subdomains
✅ High-traffic APIs where DB queries are expensive
✅ Third-party clients need to authenticate
Use Sessions when:
✅ Traditional server-rendered web app (same domain)
✅ You need instant token revocation
✅ Banking, healthcare, or high-security applications
✅ Simple CRUD app with a small user base
✅ Admin panels where security > performance
What I Use in Production
For most of my NestJS freelance projects — which are REST APIs consumed by React or Flutter frontends — I use JWT with refresh tokens:
Access token → 15 minutes expiry → sent in Authorization header
Refresh token → 7 days expiry → stored in httpOnly cookie
// The access token is short-lived — limits damage if stolen
// The refresh token is httpOnly — not accessible via JavaScript
// Refresh tokens are stored hashed in DB — can be revoked instantly
// When access token expires:
// Frontend calls /auth/refresh with the cookie
// Server verifies refresh token against DB hash
// Issues new access token + rotates refresh token
This gives you:
- Stateless access token verification (fast)
- Ability to revoke sessions via refresh token invalidation
- XSS protection via httpOnly cookie for refresh token
- Short damage window if access token is stolen (15 min)
Common Security Mistakes
// ❌ Never store JWT in localStorage — XSS vulnerable
localStorage.setItem('token', accessToken);
// ✅ Store refresh token in httpOnly cookie
res.cookie('refresh_token', refreshToken, {
httpOnly: true,
secure: true,
sameSite: 'strict',
maxAge: 7 * 24 * 60 * 60 * 1000,
});
// ❌ Never put sensitive data in JWT payload
const token = jwt.sign({
userId, password, creditCard // ❌ readable by anyone
});
// ✅ Only put non-sensitive identifiers
const token = jwt.sign({
sub: userId,
role: user.role, // ✅ not sensitive
});
// ❌ Never use weak secrets
JWT_SECRET=secret123 // ❌ brute-forceable
// ✅ Use a strong random secret
JWT_SECRET=openssl rand -hex 64 // ✅ 512-bit random string
Final Recommendation
| Scenario | Use |
|---|---|
| REST API + mobile app | JWT + refresh tokens |
| Microservices | JWT |
| Server-rendered web app | Sessions |
| High-security app (banking) | Sessions |
| Simple CRUD app | Sessions (simpler) |
| Multiple frontends/domains | JWT |
There is no universally correct answer. Pick based on your architecture, not hype.
JWT is not more secure than sessions. Sessions are not more scalable than JWT. Each has a context where it wins.
For the full NestJS setup this fits into, see NestJS + Prisma + PostgreSQL: My Freelance Backend Stack.
If you're also handling high-traffic auth requests, combining JWT with Redis caching for session blocklists keeps everything fast.
Need auth implemented correctly for your project? Let's talk.
Have questions about the implementation? Drop a message