JWT authentication is one of those things that looks simple but has a dozen ways to go wrong. After implementing it across many NestJS services β and seeing what AI generators like PromptForge produce β here's the pattern that works reliably in production.
The Core Architecture
A production-ready JWT setup requires two tokens, not one:
- Access token: Short-lived (15 minutes to 1 hour). Sent with every API request. Stateless β no database lookup required to validate.
- Refresh token: Long-lived (7β30 days). Used only to obtain a new access token. Should be stored securely (httpOnly cookie or secure storage).
Most tutorials stop at the access token. That's fine for learning, but in production it means users get logged out every hour and have no way to silently re-authenticate.
Setting Up Passport and JWT in NestJS
// auth.module.ts
@Module({
imports: [
JwtModule.registerAsync({
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
secret: config.get('JWT_SECRET'),
signOptions: { expiresIn: '1h' },
}),
}),
PassportModule,
UsersModule,
],
providers: [AuthService, JwtStrategy],
exports: [AuthService],
})
export class AuthModule {}
The JWT Strategy
// jwt.strategy.ts
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(config: ConfigService) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
secretOrKey: config.get('JWT_SECRET'),
});
}
async validate(payload: { sub: string; email: string; type: string }) {
if (payload.type !== 'access') throw new UnauthorizedException();
return { id: payload.sub, email: payload.email };
}
}
Notice the type claim check. This prevents a refresh token from being used as an access token β a subtle but real security issue if you don't distinguish them.
Generating Tokens
private generateAccessToken(userId: string, email: string): string {
return this.jwtService.sign(
{ sub: userId, email, type: 'access' },
{ expiresIn: '1h' }
);
}
private generateRefreshToken(userId: string, email: string): string {
return this.jwtService.sign(
{ sub: userId, email, type: 'refresh' },
{ expiresIn: '30d' }
);
}
The Refresh Endpoint
@Post('refresh')
async refresh(@Body() body: { refreshToken: string }) {
const payload = this.jwtService.verify(body.refreshToken, {
secret: this.config.get('JWT_SECRET'),
});
if (payload.type !== 'refresh') {
throw new UnauthorizedException('Invalid refresh token');
}
const user = await this.usersService.findById(payload.sub);
if (!user?.isActive) throw new UnauthorizedException();
return { token: this.generateAccessToken(user.id, user.email) };
}
Common Pitfalls
1. Using the same secret for access and refresh tokens. Fine for many applications, but if you want to revoke refresh tokens independently, use separate secrets.
2. Not checking isActive on the refresh endpoint. If a user is banned but has a valid refresh token, they'll keep generating new access tokens indefinitely unless you check their status on each refresh.
3. Storing refresh tokens in localStorage. Vulnerable to XSS. Use httpOnly cookies for the refresh token, and keep the access token in memory (or sessionStorage at minimum).
4. Not handling token expiry on the client. Intercept 401 responses, call the refresh endpoint, and retry the original request. Without this, users get logged out silently whenever their access token expires.
Generated vs Hand-Written
PromptForge generates this full authentication setup automatically β including the refresh token flow, the JWT guard, the strategy, and the client-side interceptor β as part of every project. For most applications, the generated code handles these cases correctly out of the box. You only need to customize when your requirements diverge from the standard pattern.