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.