js

Build Complete NestJS Authentication System with Refresh Tokens, Prisma, and Redis

Learn to build a complete authentication system with JWT refresh tokens using NestJS, Prisma, and Redis. Includes secure session management, token rotation, and guards.

Build Complete NestJS Authentication System with Refresh Tokens, Prisma, and Redis

I’ve been building web applications for years, and authentication remains one of those critical systems that can make or break your project. Security breaches from poorly implemented auth systems keep me up at night. That’s why I want to share a battle-tested approach using NestJS, Prisma, and Redis - tools I’ve come to trust for production-grade solutions. Let’s create something secure together.

How do refresh tokens actually improve security? They limit exposure by separating short-lived access tokens from longer-lived refresh tokens. This means if an access token is compromised, the attacker has limited time to misuse it. Here’s how we implement it:

// src/auth/auth.service.ts
async generateTokens(userId: string): Promise<TokenPair> {
  const accessToken = this.jwtService.sign(
    { sub: userId },
    { secret: this.config.get('jwt.accessSecret'), expiresIn: '15m' }
  );
  
  const refreshToken = this.jwtService.sign(
    { sub: userId },
    { secret: this.config.get('jwt.refreshSecret'), expiresIn: '7d' }
  );
  
  await this.prisma.refreshToken.create({
    data: {
      token: refreshToken,
      userId,
      expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000)
    }
  });
  
  return { accessToken, refreshToken };
}

Notice how we store refresh tokens in PostgreSQL via Prisma while keeping access tokens stateless. This separation gives us flexibility - we can revoke refresh tokens anytime while keeping JWT validation lightweight. But why use Redis here? It becomes crucial for managing active sessions and handling token blacklisting efficiently.

When a user logs out, we need to properly invalidate their tokens. Here’s how we handle it:

// src/auth/auth.service.ts
async logout(userId: string, accessToken: string): Promise<void> {
  const now = new Date();
  
  // Invalidate all refresh tokens for this user
  await this.prisma.refreshToken.updateMany({
    where: { userId, isRevoked: false },
    data: { isRevoked: true }
  });

  // Blacklist current access token until expiration
  const decoded = this.jwtService.decode(accessToken);
  const ttl = decoded.exp - Math.floor(Date.now() / 1000);
  
  if (ttl > 0) {
    await this.redis.blacklistToken(accessToken, ttl);
  }
  
  // Clear session data
  await this.redis.deleteUserSession(userId);
}

See how we combine database updates with Redis operations? This layered approach ensures we cover both token types properly. The access token blacklisting uses Redis’ time-to-live feature to automatically clean up expired entries.

For session management, Redis shines by providing fast access to user data. When a user logs in, we store their session like this:

// src/auth/auth.service.ts
async login(loginDto: LoginDto): Promise<AuthResponse> {
  const user = await this.validateUser(loginDto.email, loginDto.password);
  const tokens = await this.generateTokens(user.id);
  
  await this.redis.setUserSession(
    user.id, 
    { 
      email: user.email,
      lastAccess: new Date().toISOString()
    },
    60 * 60 * 24 // 24 hours TTL
  );
  
  return {
    user: { id: user.id, email: user.email, firstName: user.firstName, lastName: user.lastName },
    tokens
  };
}

What happens if someone tries to reuse an old refresh token? We implement token rotation to prevent this. Each refresh request generates a new token pair and revokes the previous one:

// src/auth/auth.service.ts
async refreshTokens(refreshTokenDto: RefreshTokenDto): Promise<TokenPair> {
  const { token } = refreshTokenDto;
  const payload = this.jwtService.verify(token, { secret: this.config.get('jwt.refreshSecret') });
  
  const storedToken = await this.prisma.refreshToken.findUnique({
    where: { token },
    include: { user: true }
  });
  
  if (!storedToken || storedToken.isRevoked || storedToken.expiresAt < new Date()) {
    throw new UnauthorizedException('Invalid refresh token');
  }
  
  // Revoke current refresh token
  await this.prisma.refreshToken.update({
    where: { id: storedToken.id },
    data: { isRevoked: true }
  });
  
  // Generate new token pair
  return this.generateTokens(payload.sub);
}

This rotation strategy means each refresh token can only be used once, significantly reducing the impact of token leakage. It’s like changing locks every time someone uses a key.

Security isn’t just about tokens though. We need proper guards to protect our routes. Here’s a custom guard that checks both JWT validity and Redis blacklist:

// src/auth/guards/jwt-auth.guard.ts
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {
  constructor(
    private reflector: Reflector,
    private redis: RedisService
  ) {
    super();
  }

  async canActivate(context: ExecutionContext): Promise<boolean> {
    await super.canActivate(context);
    
    const request = context.switchToHttp().getRequest();
    const token = this.extractToken(request);
    
    if (await this.redis.isTokenBlacklisted(token)) {
      throw new UnauthorizedException('Token revoked');
    }
    
    return true;
  }

  private extractToken(request: Request): string {
    return request.headers.authorization?.split(' ')[1] || '';
  }
}

Notice how we extend NestJS’s built-in JWT guard? This gives us both standard verification and our custom blacklist check. The guard first validates the token signature and expiration, then checks Redis for revocation status.

For sensitive operations like password changes, we can add another layer:

// src/auth/guards/password-change.guard.ts
@Injectable()
export class PasswordChangeGuard implements CanActivate {
  constructor(private reflector: Reflector) {}

  canActivate(context: ExecutionContext): boolean {
    const request = context.switchToHttp().getRequest();
    const newPassword = request.body.newPassword;
    
    // Prevent password reuse
    if (newPassword === request.user.lastPassword) {
      throw new BadRequestException('Cannot reuse previous password');
    }
    
    return true;
  }
}

Ever wondered how to access user information in your controllers? We create a custom decorator:

// src/auth/decorators/current-user.decorator.ts
export const CurrentUser = createParamDecorator(
  (data: unknown, ctx: ExecutionContext) => {
    const request = ctx.switchToHttp().getRequest();
    return request.user;
  }
);

// Usage in controller
@Get('profile')
@UseGuards(JwtAuthGuard)
getProfile(@CurrentUser() user: User) {
  return user;
}

This clean approach keeps our controllers focused on business logic rather than authentication mechanics.

Testing is crucial for security systems. Here’s how I verify token generation:

// auth.service.spec.ts
describe('generateTokens', () => {
  it('should return valid tokens', async () => {
    const userId = 'user123';
    const tokens = await service.generateTokens(userId);
    
    expect(tokens.accessToken).toBeDefined();
    expect(tokens.refreshToken).toBeDefined();
    
    const accessPayload = service.jwtService.decode(tokens.accessToken);
    expect(accessPayload.sub).toBe(userId);
    
    const refreshToken = await prisma.refreshToken.findFirst();
    expect(refreshToken.token).toBe(tokens.refreshToken);
  });
});

These tests give me confidence that tokens are properly linked to users and stored correctly. I always include tests for edge cases like expired tokens and revocation scenarios.

Security headers are often overlooked. Add this middleware to protect against common attacks:

// main.ts
app.use(helmet());
app.use(csurf({ cookie: true }));
app.use(
  rateLimit({
    windowMs: 15 * 60 * 1000, // 15 minutes
    max: 100 // limit each IP to 100 requests per window
  })
);

The helmet package automatically sets security headers like XSS protection and frame prevention. Combined with CSRF tokens and rate limiting, we create multiple defensive layers.

Remember that security isn’t a one-time setup. Here are practices I follow:

  • Rotate secrets quarterly
  • Audit sessions monthly
  • Monitor failed login attempts
  • Keep dependencies updated
  • Conduct penetration tests annually

Implementing proper authentication takes effort, but the peace of mind is worth it. This system has protected my applications from real-world attacks, and I’m confident it can do the same for yours. What security measures do you find most effective? Share your experiences below - I’d love to hear what’s worked in your projects. If this guide helped you, please consider sharing it with others who might benefit.

Keywords: NestJS authentication, JWT refresh tokens, Prisma ORM, Redis session management, TypeScript auth system, secure token rotation, authentication guards, NestJS JWT strategy, refresh token flow, authentication middleware



Similar Posts
Blog Image
Complete Guide to Next.js Prisma Integration: Build Type-Safe Full-Stack Applications in 2024

Learn how to integrate Next.js with Prisma ORM for type-safe full-stack applications. Master database operations, schema management, and seamless API development.

Blog Image
Complete Guide to Integrating Next.js with Prisma for Full-Stack Development in 2024

Learn how to integrate Next.js with Prisma ORM for powerful full-stack applications with end-to-end type safety, seamless API routes, and optimized performance.

Blog Image
Build Real-Time Web Apps: Complete Svelte and Supabase Integration Guide for Modern Developers

Learn to integrate Svelte with Supabase for powerful real-time web applications. Build reactive dashboards, chat apps & collaborative tools with minimal code.

Blog Image
Complete Guide to Integrating Next.js with Prisma ORM for Full-Stack Development

Learn to integrate Next.js with Prisma ORM for powerful full-stack development. Build type-safe web apps with seamless database management and optimal performance.

Blog Image
Complete Guide to Integrating Next.js with Prisma ORM for Type-Safe Database Applications

Learn how to seamlessly integrate Next.js with Prisma ORM for type-safe, full-stack web applications. Build powerful database-driven apps with enhanced developer experience.

Blog Image
Build Real-time Collaborative Text Editor with Operational Transform Node.js Socket.io Redis Complete Guide

Learn to build a real-time collaborative text editor using Operational Transform in Node.js & Socket.io. Master OT algorithms, WebSocket servers, Redis scaling & more.