1. The Hook — Why "Just Use JWT" Isn't Enough
If you've built auth before, you've probably done this:
- User logs in → you generate a JWT → you send it back → done. And it works! For a weekend project. But let me ask you a few questions: What happens if a user's token gets stolen? Can you invalidate it? If a user changes their password, are their other logged-in devices kicked out? Can your users see where they're logged in — phone, laptop, browser — and revoke specific sessions? Do you even know how many active sessions exist right now? If you answered "no" to any of these, this article is for you.
In this post I'll walk through the entire thing — from the problem, to the architecture, to the actual code. By the end, you'll understand exactly how to implement production-grade session management in NestJS.
2. The Problem — What Breaks Without Proper Session Management
Let's start with a quick primer on how JWT auth normally works.
What is a JWT?
A JWT (JSON Web Token) is a string that encodes information — like a user's ID and role — and is cryptographically signed. It looks like this:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyLTEyMyIsImVtYWlsIjoiam9lQGV4YW1wbGUuY29tIn0.abc123
Three parts, separated by dots: header, payload, signature. You don't need to memorize this — just know that:
The payload contains data you encoded (user ID, role, expiry time)
The signature proves it wasn't tampered with
Anyone with a valid token can use it until it expires — you can't cancel it early
That last point is the key problem.
The "Hotel Key Card" Analogy
Think of a JWT like a hotel key card. When you check in, the front desk gives you a card that opens your room. It works for the duration of your stay.
Now imagine someone steals your key card. The hotel can't remotely deactivate that specific card — once it's issued, it opens the door until it expires.
Standard JWT auth has this exact problem:
No revocation. Once issued, a token is valid until expiry. If it's stolen, you can't stop it.
No visibility. You have no idea how many "copies" of a session exist or where they're being used from.
Password changes don't log out other devices. A user changes their password thinking they're secure — but any stolen tokens still work.
No real logout. Traditional JWT "logout" just deletes the token from localStorage. The token itself is still perfectly valid on the server.
What we had before
Here's a basic auth service:
// The "naive" version — functional but not production-safe
async login(email: string, password: string) {
const user = await this.validateUser(email, password);
if (!user) { throw new UnauthorizedException('Invalid credentials');}
const payload = { email: user.email, sub: user.id, role: user.role };
// Access token — valid for 15 minutes
const accessToken = this.jwtService.sign(payload, { expiresIn: '15m' });
// Refresh token — valid for 7 days (more on this below)
const refreshToken = this.jwtService.sign(payload,{
secret: this.configService.get<string>('JWT_REFRESH_SECRET'),
expiresIn: '7d',
});
return { access_token: accessToken, refresh_token: refreshToken };
}
This works, but there's no database record of this session. No way to revoke it. No device info. No IP. If someone grabs that refresh_token,they can silently keep refreshing access for 7 days.
Let's fix that.
3. The Architecture — JWT + Database-Backed Sessions
First: What's the difference between an access token and a refresh token?
This trips up a lot of beginners, so let's nail it before going further.
When a user logs in, we issue two tokens:
Access token — Short-lived (15 minutes). The client sends this with every API request. It's like your boarding pass: valid, fast to check, but expires quickly.
Refresh token — Long-lived (7 days). The client only uses this to request a new access token when the old one expires. It's like the ID you showed at check-in to get the boarding pass.
Why two tokens instead of one long-lived one? Because access tokens are sent constantly — if one leaks, the damage window is small (15 minutes max). The refresh token is only used occasionally and can be stored more securely.
The solution: hybrid JWT + DB sessions
We combine stateless JWTs with stateful database sessions. You get the performance benefits of JWTs for most requests, plus the control of server-side sessions when you need it.
Here's the high-level flow:
User logs in
│
▼
Validate credentials
│
▼
Create a Session record in the DB
(stores: refresh token hash, device info, IP, timestamps)
│
▼
Issue access token (JWT, 15 min) + refresh token (JWT, 7 days)
│
▼
User makes API requests using the short-lived access token
│
▼
Access token expires → client sends refresh token to /auth/refresh
│
▼
Look up session in DB → if found & valid → issue new access token
│
▼
On logout → delete session from DB (refresh token is now worthless)
The key insight: the refresh token is only as good as the session record in your database. Even if someone steals the refresh token, if you delete the session record, the token is dead.
4. The Session Entity — What Gets Stored Per Session
Let's start with the data model using typeOrm as our ORM(Object-Relational Mapper). A session represents one login event — one device, one browser, one app install.
// session.entity.ts
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
ManyToOne,
JoinColumn,
} from 'typeorm';
import { User } from '../users/entities/user.entity';
@Entity('sessions')
export class Session {
@PrimaryGeneratedColumn('uuid')
id: string;
// Which user this session belongs to.
// onDelete: 'CASCADE' means if the user is deleted, their sessions are too.
@ManyToOne(() => User, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'userId' })
user: User;
@Column()
userId: string;
// We store a HASH of the refresh token, never the raw token.
// Same reason you hash passwords — if the DB leaks, tokens are useless.
// select: false means this field is excluded from queries unless explicitly requested.
@Column({ select: false })
refreshTokenHash: string;
// Device & browser info (captured from the User-Agent header on login)
@Column({ nullable: true })
deviceType: string; // e.g. 'mobile', 'desktop', 'tablet'
@Column({ nullable: true })
browser: string; // e.g. 'Chrome', 'Safari', 'Firefox'
@Column({ nullable: true })
os: string; // e.g. 'Windows', 'macOS', 'Android'
// Where the login came from
@Column({ nullable: true })
ipAddress: string;
// The raw User-Agent string, kept for debugging
@Column({ nullable: true })
userAgent: string;
// When does this session expire? (should match the refresh token's expiry)
@Column({ type: 'timestamp' })
expiresAt: Date;
// Last time this session was used to get a new access token
@Column({ type: 'timestamp', nullable: true })
lastUsedAt: Date;
@CreateDateColumn()
createdAt: Date;
// Soft-delete flag: we mark sessions inactive rather than deleting them immediately.
// Useful for audit logs and security investigations.
@Column({ default: true })
isActive: boolean;
}
A few important design decisions here:
We hash the refresh token before storing it, just like passwords. If your database is ever compromised, the raw tokens are never exposed.
isActive flag lets you soft-delete sessions for auditing, rather than hard-deleting immediately.
expiresAt lets you write a cleanup cron job to purge old sessions periodically.
5. DeviceInfoMiddleware — Capturing Device Info on Every Login
When a user logs in from their iPhone, we want to record "iPhone, Safari, iOS 17". When they log in from their laptop, "Chrome, macOS". This is how apps like Google show you "Signed in on 3 devices."
What is a middleware?
In NestJS (and Express), a middleware is a function that runs before your route handler. Think of it as a checkpoint — every request passes through it, and you can attach extra information to the request object.
We get device info from the User-Agent HTTP header — a string the browser automatically sends with every request. You can parse it with the ua-parser-js library:
// device-info.middleware.ts
import { Injectable, NestMiddleware } from '@nestjs/common';
import { Request, Response, NextFunction } from 'express';
import * as UAParser from 'ua-parser-js';
// Extend Express's Request type so TypeScript knows about our custom property.
// This is TypeScript "declaration merging" — we're adding to an existing type.
declare global {
namespace Express {
interface Request {
deviceInfo?: {
deviceType: string;
browser: string;
os: string;
userAgent: string;
ipAddress: string;
};
}}}
@Injectable()
export class DeviceInfoMiddleware implements NestMiddleware {
use(req: Request, res: Response, next: NextFunction) {
const userAgentString = req.headers['user-agent'] || '';
// ua-parser-js parses the User-Agent string into structured data
const parser = new UAParser(userAgentString);
const result = parser.getResult();
// Determine if this is mobile, tablet, or desktop.
// Note: ua-parser-js returns undefined for desktops, so we default to 'desktop'.
const deviceType = result.device.type || 'desktop';
// Get the real IP address.
// When you're behind a proxy or load balancer (like nginx or Cloudflare),
// the real client IP is in the 'x-forwarded-for' header, not req.ip.
const ipAddress =
(req.headers['x-forwarded-for'] as string)?.split(',')[0]?.trim() ||
req.ip ||'unknown';
// Attach device info to the request object.
// Our auth service will read it from here during login.
req.deviceInfo = {
deviceType,
browser: result.browser.name || 'Unknown Browser',
os: result.os.name || 'Unknown OS',
userAgent: userAgentString,
ipAddress,
};
// Call next() to pass control to the next middleware or the route handler
next();
}
}
Register the middleware only on the login route in your auth module:
// auth.module.ts
import {
MiddlewareConsumer,
Module,
NestModule,
RequestMethod,
} from '@nestjs/common';
import { DeviceInfoMiddleware } from './device-info.middleware';
@Module({
// ... imports, providers, exports
})
export class AuthModule implements NestModule {
configure(consumer: MiddlewareConsumer) {
consumer
.apply(DeviceInfoMiddleware)
// Only apply to POST /auth/login
.forRoutes({ path: 'auth/login', method: RequestMethod.POST });
}}
6. The Login Flow — Creating a Session on Login
Now let's update the login method. After validating credentials, we create a session record in the database.
// auth.service.ts — updated login method
async login(
email: string,
password: string,
deviceInfo?: Request['deviceInfo'],
) {
// Step 1: Validate credentials (same as before)
const user = await this.validateUser(email, password);
if (!user) {
throw new UnauthorizedException('Invalid credentials');
}
// The JWT payload — this is what gets encoded inside the token
const payload = { email: user.email, sub: user.id, role: user.role };
// Step 2: Issue both tokens
const accessToken = this.jwtService.sign(payload, {
expiresIn: '15m', // Short-lived
});
const refreshToken = this.jwtService.sign(payload, {
secret: this.configService.get<string>('JWT_REFRESH_SECRET'),
expiresIn: '7d', // Long-lived
});
// Step 3: Hash the refresh token before storing it.
// We use bcrypt here (same library used for passwords).
const refreshTokenHash = await bcrypt.hash(refreshToken, 10);
// Step 4: Calculate when this session should expire (7 days from now)
const expiresAt = new Date();
expiresAt.setDate(expiresAt.getDate() + 7);
// Step 5: Create the session record in the database
const session = this.sessionRepository.create({
userId: user.id,
refreshTokenHash, // store the hash, never the raw token
expiresAt,
// Device info from the middleware (falls back gracefully if not present)
deviceType: deviceInfo?.deviceType || 'unknown',
browser: deviceInfo?.browser || 'unknown',
os: deviceInfo?.os || 'unknown',
userAgent: deviceInfo?.userAgent || '',
ipAddress: deviceInfo?.ipAddress || 'unknown',
isActive: true,
});
await this.sessionRepository.save(session);
return {
access_token: accessToken,
refresh_token: refreshToken, // raw token sent to client; hash stays in DB
expires_in: 900, // 15 minutes in seconds
session_id: session.id, // the client uses this to revoke this specific session later
user: {
id: user.id,
email: user.email,
firstName: user.firstName,
lastName: user.lastName,
role: user.role,
},
};
}
Update the controller to pass device info through:
// auth.controller.ts
@Post('login')
@ApiOperation({ summary: 'Login user' })
async login(
@Body() body: { email: string; password: string },
@Req() req: Request,
) {
// req.deviceInfo was attached by DeviceInfoMiddleware before this handler ran
return this.authService.login(body.email, body.password, req.deviceInfo);
}
We’ve now laid the foundation: why plain JWTs fall short, how database‑backed sessions solve those gaps, and how to capture device info cleanly in NestJS.
By now, you should see that production‑grade authentication isn’t just about issuing tokens — it’s about visibility, control, and security.
We’ve set up the building blocks in this first part.
In Part 2, we’ll go deeper — covering refresh flows, logout mechanics, session revocation,and give users the power to manage their own sessions. Stay tuned!
Follow @bismark66 for more backend engineering deep dives.

Top comments (0)