How I Handle User Sessions in NestJS
Authentication answers the question of who the user is. Session handling answers a different question: should this user still be considered logged in right now? That distinction matters a lot in real applications. In one of my NestJS projects, I wanted sessions to be more than a long-lived token sitting on the client. I wanted the backend to be able to validate, rotate, revoke, and invalidate sessions when sensitive actions happen. That led me to a simple but useful session model built around refresh tokens and a dedicated user_sessions table.
Store sessions in the database, not only in the token
One of the key decisions in my implementation was not relying only on JWTs as the entire source of truth. Instead, I store each refresh-token-based session in the database.
That gives the backend real control. A session can be revoked, checked for expiration, or invalidated after a password change. Without that database record, the backend would have fewer options once a token is issued.
@Entity('user_sessions')export class UserSession {@PrimaryGeneratedColumn('uuid')id: string;@Column({ name: 'user_id', type: 'uuid' })userId: string;@Column({ name: 'refresh_token_hash', type: 'text' })refreshTokenHash: string;@Column({ name: 'expires_at', type: 'timestamptz' })expiresAt: Date;@Column({ name: 'revoked_at', type: 'timestamptz', nullable: true })revokedAt: Date | null;}
Create a session when the user authenticates successfully
After login or registration succeeds, I create a new session record and issue both an access token and a refresh token. The access token is short-lived and used for normal API requests. The refresh token is longer-lived and is tied to the session record in the database.
One small implementation detail I like here is that the session is created first, then its database ID is included in both JWT payloads as sessionId. That means every future request can be tied back to a concrete stored session.
export interface JwtPayload {sub: string;email: string;sessionId: string;}
private async createAuthSession(userId: string,email: string,firstName: string | null,lastName: string | null,) {const accessExpiresIn = this.getAccessTokenExpiresIn();const refreshExpiresIn = this.getRefreshTokenExpiresIn();const expiresAt = this.getRefreshTokenExpiryDate(refreshExpiresIn);const temporarySession = await this.usersService.createSession({userId,refreshTokenHash: 'pending',expiresAt,});const payload: JwtPayload = {sub: userId,email,sessionId: temporarySession.id,};const accessToken = await this.jwtService.signAsync(payload, {secret: this.configService.get<string>('JWT_ACCESS_SECRET'),expiresIn: accessExpiresIn,});const refreshToken = await this.jwtService.signAsync(payload, {secret: this.configService.get<string>('JWT_REFRESH_SECRET'),expiresIn: refreshExpiresIn,});const refreshTokenHash = await argon2.hash(refreshToken);await this.usersService.updateSessionTokenHash(temporarySession.id,refreshTokenHash,);return {accessToken,refreshToken,user: {id: userId,email,firstName,lastName,},};}
Hash refresh tokens before storing them
A pattern I strongly prefer is hashing refresh tokens before saving them to the database. That is similar to how passwords are handled. If the database were ever exposed, raw refresh tokens should not be readable in plain form.
In this setup, the client keeps the raw refresh token, while the backend stores only a hashed version. Later, when the client sends the refresh token to request new access, the backend verifies it against the stored hash.
Refresh flow should rotate the session, not just extend it blindly
The refresh endpoint is where session handling becomes more than basic JWT usage. In my implementation, the backend first verifies the refresh token signature, loads the related user and session, checks whether the session is revoked or expired, and then verifies the token against the stored token hash.
Once that succeeds, I revoke the old session and create a brand new one. That means refresh token usage rotates the session instead of endlessly reusing the same stored token state.
This gives better control and reduces the chance of a stolen refresh token staying useful for too long.
async refresh(dto: RefreshTokenDto) {const payload = await this.verifyRefreshToken(dto.refreshToken);const user = await this.usersService.findById(payload.sub);if (!user) {throw new UnauthorizedException('Invalid refresh token');}const session = await this.usersService.findSessionById(payload.sessionId);if (!session || session.revokedAt) {throw new UnauthorizedException('Session is invalid');}if (session.expiresAt.getTime() < Date.now()) {throw new UnauthorizedException('Refresh token expired');}const isRefreshTokenValid = await argon2.verify(session.refreshTokenHash,dto.refreshToken,);if (!isRefreshTokenValid) {throw new UnauthorizedException('Invalid refresh token');}await this.usersService.revokeSession(session.id);return this.createAuthSession(user.id,user.email,user.firstName,user.lastName,);}
Validate the session on every protected request
A very useful part of this setup is that access-token validation is not based only on token signature and expiry. The JWT strategy also checks the related session record in the database.
That means even a valid-looking access token should not be trusted if its session has already been revoked or expired. This is one of the main benefits of combining JWT auth with database-backed sessions.
async validateSessionById(sessionId: string, userId: string) {const session = await this.usersService.findSessionById(sessionId);if (!session) {return null;}if (session.userId !== userId) {return null;}if (session.revokedAt) {return null;}if (session.expiresAt.getTime() < Date.now()) {return null;}return session;}
Logout and password changes should revoke sessions
A session system becomes really useful when the backend can actively kill sessions. In my implementation, logout revokes the current session, while password reset and password change revoke all active sessions for that user.
That is a strong real-world security improvement. If a user changes their password because of suspicion or account recovery, all older sessions should stop being trusted.
async logout(dto: LogoutDto) {const payload = await this.verifyRefreshToken(dto.refreshToken);const session = await this.usersService.findSessionById(payload.sessionId);if (session && !session.revokedAt) {await this.usersService.revokeSession(session.id);}return { success: true };}
async revokeAllSessionsForUser(userId: string) {await this.sessionsRepository.createQueryBuilder().update().set({ revokedAt: new Date() }).where('user_id = :userId', { userId }).andWhere('revoked_at IS NULL').execute();}
async changePassword(userId: string,currentPassword: string,newPassword: string,) {const user = await this.usersService.findById(userId);if (!user) {throw new UnauthorizedException('User not found');}const isCurrentPasswordValid = await argon2.verify(user.passwordHash,currentPassword,);if (!isCurrentPasswordValid) {throw new UnauthorizedException('Current password is incorrect');}const passwordHash = await argon2.hash(newPassword);await this.usersService.updatePassword(userId, passwordHash);await this.usersService.revokeAllSessionsForUser(userId);return {message: 'Password changed successfully. Please log in again.',};}
Why I prefer this session model
What I like about this approach is that it stays understandable while giving the backend meaningful control over user sessions. It supports token rotation, revocation, forced logout, and session-aware request validation without becoming too complex.
It also mirrors the way I like to design backend systems in general: keep the flow explicit, store security-critical state intentionally, and avoid pretending that a JWT by itself solves the whole session problem.
For me, that is the real value of session handling in NestJS. It is not just about keeping a user logged in. It is about making that state trustworthy.
Continue exploring
More articles and full stack work
If this article was useful, you can also explore more writing on backend architecture, database-driven applications, and full stack product development.