DEV Community

bismark66
bismark66

Posted on

Building Secure Session Management in NestJS - Refresh Tokens, Device Tracking & Session Revocation(PART 2)

Authentication practices, session revocation, token validation, session management

1. The Refresh Token Flow — Validating Against the DB

This is where the real security upgrade happens. Instead of just verifying the JWT signature, we now check the database to confirm the session still exists and hasn't been revoked.

// auth.service.ts — updated refreshToken method
async refreshToken(rawRefreshToken: string) {

// Step 1: Verify the JWT signature and check expiry.
// jwtService.verify() throws an error if the token is expired or the signature is invalid.


let payload: any;
try {

payload = this.jwtService.verify(rawRefreshToken, {
secret: this.configService.get<string>('JWT_REFRESH_SECRET'),
});
} catch {
// The token is either expired or was tampered with
throw new UnauthorizedException('Refresh token is invalid or expired');
}


// Step 2: Load all active sessions for this user from the database.
// We need to find the specific session that matches this refresh token.

// Note: we explicitly request 'refreshTokenHash' since it has select: false on the entity.

const sessions = await this.sessionRepository.find({
where: { userId: payload.sub, isActive: true },
select: ['id', 'refreshTokenHash', 'expiresAt', 'isActive'],
});

// Step 3: Compare the incoming token against each session's stored hash.
// bcrypt.compare() handles this — it hashes the raw token and checks if it matches.

let matchingSession: Session | null = null;

for (const session of sessions) {
const isMatch = await bcrypt.compare(
rawRefreshToken,
session.refreshTokenHash,
);

if (isMatch) {
matchingSession = session;
break; // Found it — no need to check further
}
}

// Step 4: If no matching session exists, it was revoked (user logged out)

if (!matchingSession) {
throw new UnauthorizedException('Session not found or has been revoked');
}

// Step 5: Double-check the session's own expiry timestamp

if (new Date() > matchingSession.expiresAt) {
await this.sessionRepository.update(matchingSession.id, {
isActive: false,
});

throw new UnauthorizedException('Session has expired. Please log in again.',);
}

// Step 6: Record that this session was just used (for "last active" display)

await this.sessionRepository.update(matchingSession.id, {
lastUsedAt: new Date(),
});

// Step 7: Issue a fresh access token

const user = await this.usersService.findOne(payload.sub);
if (!user) throw new UnauthorizedException('User not found');

const newPayload = { email: user.email, sub: user.id, role: user.role };
const accessToken = this.jwtService.sign(newPayload, { expiresIn: '15m' });
return {
access_token: accessToken,
expires_in: 900,
};
}
Enter fullscreen mode Exit fullscreen mode

Why this matters: A stolen refresh token is now useless the moment the legitimate user logs out. The database is the source of truth — not just the JWT signature.

Performance note: If a user has many sessions (lots of devices), iterating through all of them to bcrypt-compare can be slow. A common optimization is to include the session ID in the refresh token's JWT payload, so you can look up the exact session directly and only do one bcrypt comparison.

2. Session Revocation — Logout (Single) and Logout-All

Single Device Logout
When a user logs out from one device, we mark that specific session as inactive. The refresh token is now dead — even if it hasn't reached its JWT expiry date.

// auth.service.ts
async logout(userId: string, sessionId: string) {
// Find the session — and confirm it actually belongs to this user.
// Never skip this check! Otherwise, user A could log out user B.

const session = await this.sessionRepository.findOne({
where: { id: sessionId, userId, isActive: true },
});

if (!session) {
throw new BadRequestException('Session not found');
}

// Mark as inactive (soft delete — we keep the row for audit purposes)

await this.sessionRepository.update(session.id, { isActive: false });
return { message: 'Logged out successfully' };
}
Enter fullscreen mode Exit fullscreen mode
// auth.controller.ts
@Post('logout')
@UseGuards(JwtAuthGuard) // Must be authenticated to logout
@ApiBearerAuth()
async logout(@Req() req, @Body() body: { session_id: string }) {
// req.user.userId is populated by JwtStrategy.validate()
return this.authService.logout(req.user.userId, body.session_id);
}
Enter fullscreen mode Exit fullscreen mode

Logout From All Devices
This is the "I think my account was compromised" button. One call, every session gone.

// auth.service.ts
async logoutAll(userId: string) {
// Deactivate every active session for this user in one query

await this.sessionRepository.update(
{ userId, isActive: true },
{ isActive: false },
);

return { message: 'Logged out from all devices successfully' };
}
Enter fullscreen mode Exit fullscreen mode
// auth.controller.ts
@Post('logout-all')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOperation({ summary: 'Revoke all active sessions for the current user' })
async logoutAll(@Req() req) {
return this.authService.logoutAll(req.user.userId);
}
Enter fullscreen mode Exit fullscreen mode

3. Password Change = Revoke All Sessions

This one is critical and often missed. When a user changes their password, you should immediately invalidate all their existing sessions.

Here's why this matters: imagine a user named Kofi suspects his account has been compromised. He changes his password. But without session revocation, any attacker who already has a valid refresh token can keep getting new access tokens for up to 7 more days -completely unaffected by the password change.

// auth.service.ts
async changePassword(
userId: string,
currentPassword: string,
newPassword: string
) {
// Get user including the password field (it has select: false on the entity)

const user = await this.usersService.findOneWithPassword(userId);

if (!user || !user.password) {
throw new BadRequestException('User not found');
}

// Verify the current password is correct before allowing the change

const isCurrentPasswordValid =  bcrypt.compare(currentPassword,user.password);

if (!isCurrentPasswordValid) {
throw new UnauthorizedException('Current password is incorrect');
}

// Hash and save the new password

const hashedNewPassword = await bcrypt.hash(newPassword, 10);
await this.usersService.updatePassword(userId, hashedNewPassword);

// ✅ CRITICAL STEP: Revoke ALL sessions.
// Any stolen refresh tokens are now completely useless.

await this.sessionRepository.update(
{ userId, isActive: true },
{ isActive: false },
);

return {
message:'Password changed successfully. You have been logged out from all devices. Please log in again.',
};
}
Enter fullscreen mode Exit fullscreen mode

The user will need to log in again on all their devices — which is expected and correct. Security over convenience, always.

4. Listing Active Sessions - Letting Users See Where They're Logged In

This is the feature users love. You've seen it in Google Account settings: "Your account is currently active on these devices." Here's how to build it:

// auth.service.ts
async getActiveSessions(userId: string) {
const sessions = await this.sessionRepository.find({
where: { userId, isActive: true },
order: { createdAt: 'DESC' }, // most recent login first
});

// Map to a clean format for the client.
// We never expose the refreshTokenHash.

return sessions.map((session) => ({
id: session.id, // client uses this to revoke a specific session
device: ${session.browser} on ${session.os}, // "Chrome on macOS"
deviceType: session.deviceType,
ipAddress: session.ipAddress,
lastUsed: session.lastUsedAt || session.createdAt,
loggedInAt: session.createdAt,
expiresAt: session.expiresAt,
}));
}
Enter fullscreen mode Exit fullscreen mode
// auth.controller.ts
@Get('sessions')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOperation({ summary: 'List all active sessions for the current user' })
async getSessions(@Req() req) {
return this.authService.getActiveSessions(req.user.userId);
}
Enter fullscreen mode Exit fullscreen mode

A sample response:

[
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"device": "Chrome on macOS",
"deviceType": "desktop",
"ipAddress": "41.66.12.34",
"lastUsed": "2026-03-02T09:45:00.000Z",
"loggedInAt": "2026-03-01T08:00:00.000Z",
"expiresAt": "2026-03-08T08:00:00.000Z"
},
{
"id": "660e8400-e29b-41d4-a716-446655440001",
"device": "Mobile Safari on iOS",
"deviceType": "mobile",
"ipAddress": "41.66.12.35",
"lastUsed": "2026-03-01T22:10:00.000Z",
"loggedInAt": "2026-02-28T14:22:00.000Z",
"expiresAt": "2026-03-07T14:22:00.000Z"
}
]
Enter fullscreen mode Exit fullscreen mode

Users can pass the session id to POST /auth/logout to revoke any specific session they don't recognize.

5. Tradeoffs & Considerations

Nothing is free. Here's what you're taking on with this approach:

What you gain

  • Real logout — revocation actually works
  • Per-device visibility for users
  • Protection against token theft
  • Password change security (all sessions wiped)
  • Audit trail of login activity

What you're taking on
Database hit on every token refresh. Every time a client needs a new access token (up to every 15 minutes per active user), you query the database. For large-scale apps, this adds up.

Mitigation: cache session lookups in Redis with a short TTL, or embed the session ID in the JWT payload to enable a direct lookup instead of scanning all user sessions.
You need to clean up old sessions. Sessions pile up over time. Set up a scheduled cleanup job using @nestjs/schedule:

// sessions-cleanup.service.ts
import { Injectable } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, LessThan } from 'typeorm';
import { Session } from './session.entity';

@Injectable()
export class SessionsCleanupService {

constructor(
@InjectRepository(Session)
private sessionRepository: Repository<Session>,
) {}

// Runs every night at midnight

@Cron(CronExpression.EVERY_DAY_AT_MIDNIGHT)
async cleanupExpiredSessions() {
// Delete sessions that have passed their expiresAt date

const result = await this.sessionRepository.delete({
expiresAt: LessThan(new Date()),
});
console.log(Cleaned up ${result.affected} expired sessions);
}
}
Enter fullscreen mode Exit fullscreen mode

Refresh token rotation (optional but recommended). For maximum security, issue a new refresh token on every /auth/refresh call and invalidate the old one. This means if a refresh token is stolen and used before the legitimate user uses it, the legitimate user's next refresh will fail — an early signal that something is wrong. This is more complex but closes a subtle replay attack window. User-Agent parsing is heuristic. The ua-parser-js library is good, but not perfect. Some browsers misreport themselves, and bots can spoof any user-agent string. Treat device info as helpful metadata for display purposes, not as a hard security boundary.

6. ** Conclusion + Key Takeaways**

Here's a quick recap of what we built:

Feature Implementation
Revocable sessions Session entity in DB; logout marks isActive = false
Device tracking DeviceInfoMiddleware parses User-Agent header on login
Secure token storage bcrypt hash stored in DB; raw token never persisted
Single-device logout POST /auth/logout with session_id
Logout all devices POST /auth/logout-all bulk-deactivates all sessions
Password change security changePassword revokes all sessions automatically
Active sessions screen GET /auth/sessions returns clean session list

The core mental model to remember: JWTs are your fast lane; database sessions are your checkpoint. Short-lived access tokens (15 min) keep performance high because you only check the DB occasionally. Database-backed sessions give you the control to say "no" to a refresh token that was supposed to be dead.

If you're starting out: the most impactful things to implement first are the session entity, the DB lookup on refresh, and the revoke-all-on-password-change. Everything else — device tracking, cleanup jobs, session listing — is valuable but additive.
Your users are trusting you with their accounts.
Follow for more backend engineering deep dives.

Top comments (0)