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
Build Production-Ready Event-Driven Architecture: Node.js, Redis Streams, TypeScript Guide

Learn to build scalable event-driven systems with Node.js, Redis Streams & TypeScript. Master event sourcing, error handling, and production deployment.

Blog Image
Event Sourcing with Node.js, TypeScript & PostgreSQL: Complete Implementation Guide 2024

Master Event Sourcing with Node.js, TypeScript & PostgreSQL. Learn to build event stores, handle aggregates, implement projections, and manage concurrency. Complete tutorial with practical examples.

Blog Image
Node.js Event-Driven Microservices: Complete RabbitMQ MongoDB Architecture Tutorial 2024

Learn to build scalable event-driven microservices with Node.js, RabbitMQ & MongoDB. Master message queues, Saga patterns, error handling & deployment strategies.

Blog Image
Build Type-Safe GraphQL APIs with NestJS, Prisma, and Code-First Approach: Complete Guide

Learn to build type-safe GraphQL APIs using NestJS, Prisma, and code-first approach. Master resolvers, auth, query optimization, and testing. Start building now!

Blog Image
Build High-Performance File Upload Service: Fastify, Multipart Streams, and S3 Integration Guide

Learn to build a scalable file upload service using Fastify multipart streams and direct S3 integration. Complete guide with TypeScript, validation, and production best practices.

Blog Image
Build High-Performance GraphQL API with NestJS, TypeORM, and Redis Caching

Learn to build a high-performance GraphQL API with NestJS, TypeORM, and Redis caching. Master database optimization, DataLoader, authentication, and deployment strategies.