Understanding Authentication in NestJS
Authentication is the backbone of any secure web application. It verifies the identity of users attempting to access protected resources. NestJS, being a progressive Node.js framework built with TypeScript, provides a robust and modular architecture for implementing authentication through various strategies including JWT (JSON Web Tokens), session-based authentication, and OAuth integration with third-party providers.
At its core, NestJS leverages Passport.js — the most popular authentication middleware for Node.js — by wrapping it into a clean, decorator-driven API. This allows developers to implement complex authentication flows with minimal boilerplate while maintaining full type safety. The framework's dependency injection system makes it easy to swap authentication strategies or combine multiple strategies within a single application.
Why Authentication Strategy Matters
Choosing the right authentication approach directly impacts your application's security model, user experience, and scalability. JWT offers stateless authentication ideal for microservices and mobile apps. Session-based auth provides server-side control perfect for traditional web applications. OAuth enables seamless third-party login experiences that reduce friction for end users. Understanding when and how to implement each strategy is critical for building production-grade NestJS applications.
Setting Up the NestJS Authentication Foundation
Before diving into specific strategies, let's establish the common groundwork. Every authentication system needs user models, password hashing, and a way to validate credentials. Start by installing the core dependencies:
npm install @nestjs/passport passport bcrypt
npm install -D @types/passport @types/bcrypt
Create a User entity. Here we'll use TypeORM with a PostgreSQL database, but the principles apply regardless of your ORM choice:
// user.entity.ts
import { Entity, Column, PrimaryGeneratedColumn, BeforeInsert } from 'typeorm';
import * as bcrypt from 'bcrypt';
@Entity('users')
export class User {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ unique: true })
email: string;
@Column()
password: string;
@Column({ nullable: true })
refreshToken: string;
@Column({ nullable: true })
oauthProvider: string;
@Column({ nullable: true })
oauthId: string;
@BeforeInsert()
async hashPassword() {
if (this.password) {
const salt = await bcrypt.genSalt(10);
this.password = await bcrypt.hash(this.password, salt);
}
}
async validatePassword(plainPassword: string): Promise {
return bcrypt.compare(plainPassword, this.password);
}
}
The UsersService encapsulates all user-related business logic and will be used across every authentication strategy we build:
// users.service.ts
import { Injectable, ConflictException, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { User } from './user.entity';
@Injectable()
export class UsersService {
constructor(
@InjectRepository(User)
private usersRepository: Repository,
) {}
async findByEmail(email: string): Promise {
return this.usersRepository.findOne({ where: { email } });
}
async findById(id: string): Promise {
return this.usersRepository.findOne({ where: { id } });
}
async create(email: string, password: string): Promise {
const existing = await this.findByEmail(email);
if (existing) {
throw new ConflictException('Email already in use');
}
const user = this.usersRepository.create({ email, password });
return this.usersRepository.save(user);
}
async updateRefreshToken(userId: string, refreshToken: string | null): Promise {
await this.usersRepository.update(userId, { refreshToken });
}
async findOrCreateOAuthUser(profile: {
provider: string;
id: string;
email: string;
}): Promise {
const user = await this.usersRepository.findOne({
where: { oauthProvider: profile.provider, oauthId: profile.id },
});
if (user) return user;
const newUser = this.usersRepository.create({
email: profile.email,
oauthProvider: profile.provider,
oauthId: profile.id,
password: '', // OAuth users may not have passwords
});
return this.usersRepository.save(newUser);
}
}
JWT Authentication in NestJS
What is JWT Authentication?
JWT (JSON Web Token) authentication is a stateless mechanism where the server issues a digitally signed token containing user claims. The client stores this token — typically in memory or local storage — and sends it with each request via the Authorization header. The server validates the token's signature and extracts user information without needing to query a session store. This makes JWT ideal for distributed systems, microservices, and mobile backends where server-side session state becomes a scaling bottleneck.
Installing JWT Dependencies
npm install @nestjs/jwt passport-jwt
npm install -D @types/passport-jwt
Creating the JWT Strategy
Passport's JWT strategy extracts the token from the request, verifies its signature using your secret key, and decodes the payload. You then validate whether the user from the payload still exists in your database:
// jwt.strategy.ts
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { UsersService } from '../users/users.service';
import { ConfigService } from '@nestjs/config';
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(
private usersService: UsersService,
private configService: ConfigService,
) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKey: configService.get('JWT_ACCESS_SECRET'),
});
}
async validate(payload: { sub: string; email: string }) {
const user = await this.usersService.findById(payload.sub);
if (!user) {
throw new UnauthorizedException('User no longer exists');
}
// Attach user to request object
return { id: user.id, email: user.email };
}
}
Configuring the Auth Module for JWT
The auth module ties together the strategy, guards, and token generation logic. Notice how we register the JwtModule with secret keys and expiration times:
// auth.module.ts
import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { PassportModule } from '@nestjs/passport';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { AuthService } from './auth.service';
import { AuthController } from './auth.controller';
import { JwtStrategy } from './jwt.strategy';
import { UsersModule } from '../users/users.module';
@Module({
imports: [
UsersModule,
PassportModule.register({ defaultStrategy: 'jwt' }),
JwtModule.registerAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (configService: ConfigService) => ({
secret: configService.get('JWT_ACCESS_SECRET'),
signOptions: {
expiresIn: configService.get('JWT_ACCESS_EXPIRATION', '15m'),
},
}),
}),
],
providers: [AuthService, JwtStrategy],
controllers: [AuthController],
exports: [AuthService],
})
export class AuthModule {}
Building the Auth Service with Token Generation
The auth service handles credential validation and token lifecycle. A critical best practice is issuing two tokens: a short-lived access token (15 minutes) and a long-lived refresh token (7 days). The refresh token is stored securely in the database and can be revoked:
// auth.service.ts
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { UsersService } from '../users/users.service';
import * as bcrypt from 'bcrypt';
import { ConfigService } from '@nestjs/config';
@Injectable()
export class AuthService {
constructor(
private usersService: UsersService,
private jwtService: JwtService,
private configService: ConfigService,
) {}
async validateUser(email: string, password: string): Promise {
const user = await this.usersService.findByEmail(email);
if (!user) {
throw new UnauthorizedException('Invalid credentials');
}
const isPasswordValid = await bcrypt.compare(password, user.password);
if (!isPasswordValid) {
throw new UnauthorizedException('Invalid credentials');
}
const { password: _, refreshToken: __, ...result } = user;
return result;
}
async login(user: any) {
const payload = { sub: user.id, email: user.email };
const accessToken = this.jwtService.sign(payload, {
secret: this.configService.get('JWT_ACCESS_SECRET'),
expiresIn: this.configService.get('JWT_ACCESS_EXPIRATION', '15m'),
});
const refreshToken = this.jwtService.sign(payload, {
secret: this.configService.get('JWT_REFRESH_SECRET'),
expiresIn: this.configService.get('JWT_REFRESH_EXPIRATION', '7d'),
});
// Store hashed refresh token in database
const salt = await bcrypt.genSalt(10);
const hashedRefreshToken = await bcrypt.hash(refreshToken, salt);
await this.usersService.updateRefreshToken(user.id, hashedRefreshToken);
return {
accessToken,
refreshToken,
expiresIn: 900, // 15 minutes in seconds
};
}
async refreshAccessToken(refreshToken: string) {
try {
const payload = this.jwtService.verify(refreshToken, {
secret: this.configService.get('JWT_REFRESH_SECRET'),
});
const user = await this.usersService.findById(payload.sub);
if (!user || !user.refreshToken) {
throw new UnauthorizedException('Invalid refresh token');
}
const isValid = await bcrypt.compare(refreshToken, user.refreshToken);
if (!isValid) {
// Potential token theft — invalidate all tokens
await this.usersService.updateRefreshToken(user.id, null);
throw new UnauthorizedException('Refresh token has been compromised');
}
const newPayload = { sub: user.id, email: user.email };
const newAccessToken = this.jwtService.sign(newPayload, {
secret: this.configService.get('JWT_ACCESS_SECRET'),
expiresIn: this.configService.get('JWT_ACCESS_EXPIRATION', '15m'),
});
return { accessToken: newAccessToken, expiresIn: 900 };
} catch (error) {
throw new UnauthorizedException('Invalid refresh token');
}
}
async logout(userId: string) {
await this.usersService.updateRefreshToken(userId, null);
}
}
Creating JWT Auth Guards and Decorators
NestJS provides a built-in AuthGuard that works seamlessly with Passport strategies. You can also create custom decorators to extract the authenticated user from the request:
// jwt-auth.guard.ts
import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {}
// current-user.decorator.ts
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
export const CurrentUser = createParamDecorator(
(data: string | undefined, ctx: ExecutionContext) => {
const request = ctx.switchToHttp().getRequest();
const user = request.user;
return data ? user?.[data] : user;
},
);
Auth Controller with JWT Endpoints
// auth.controller.ts
import {
Controller, Post, Body, UseGuards, HttpCode, HttpStatus
} from '@nestjs/common';
import { AuthService } from './auth.service';
import { JwtAuthGuard } from './jwt-auth.guard';
import { CurrentUser } from './current-user.decorator';
@Controller('auth')
export class AuthController {
constructor(private authService: AuthService) {}
@Post('login')
@HttpCode(HttpStatus.OK)
async login(@Body() body: { email: string; password: string }) {
const user = await this.authService.validateUser(body.email, body.password);
return this.authService.login(user);
}
@Post('refresh')
@HttpCode(HttpStatus.OK)
async refresh(@Body() body: { refreshToken: string }) {
return this.authService.refreshAccessToken(body.refreshToken);
}
@UseGuards(JwtAuthGuard)
@Post('logout')
@HttpCode(HttpStatus.OK)
async logout(@CurrentUser() user: { id: string }) {
await this.authService.logout(user.id);
return { message: 'Logged out successfully' };
}
@UseGuards(JwtAuthGuard)
@Post('me')
@HttpCode(HttpStatus.OK)
getProfile(@CurrentUser() user: { id: string; email: string }) {
return user;
}
}
Session-Based Authentication
What is Session Authentication?
Session-based authentication maintains server-side state for each authenticated user. When a user logs in, the server creates a session — storing user data in memory, a database, or a cache like Redis — and sends a session ID cookie to the client. Subsequent requests include this cookie, allowing the server to look up the session and identify the user. This approach provides immediate session invalidation (simply delete the server-side record), works naturally with browser security features like HttpOnly cookies, and is the traditional choice for server-rendered web applications.
Installing Session Dependencies
npm install express-session passport-local connect-redis redis
npm install -D @types/express-session @types/passport-local @types/connect-redis
Creating the Local Strategy for Session Auth
The Passport local strategy validates username/password credentials. For session-based auth, Passport serializes and deserializes user instances to and from the session store:
// local.strategy.ts
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { Strategy } from 'passport-local';
import { AuthService } from './auth.service';
@Injectable()
export class LocalStrategy extends PassportStrategy(Strategy) {
constructor(private authService: AuthService) {
super({ usernameField: 'email' }); // Use email as the username field
}
async validate(email: string, password: string): Promise {
const user = await this.authService.validateUser(email, password);
if (!user) {
throw new UnauthorizedException('Invalid email or password');
}
return user; // This gets attached to req.user and serialized into session
}
}
Serialization and Deserialization
These methods convert user objects to and from the session store. Keep serialized data minimal — typically just the user ID — and fetch full user details during deserialization:
// session.serializer.ts
import { Injectable } from '@nestjs/common';
import { PassportSerializer } from '@nestjs/passport';
import { UsersService } from '../users/users.service';
@Injectable()
export class SessionSerializer extends PassportSerializer {
constructor(private usersService: UsersService) {
super();
}
serializeUser(user: any, done: (err: Error | null, userId: string) => void) {
// Store only the user ID in the session
done(null, user.id);
}
async deserializeUser(
userId: string,
done: (err: Error | null, user: any) => void,
) {
try {
const user = await this.usersService.findById(userId);
if (!user) {
return done(null, null);
}
const { password, refreshToken, ...safeUser } = user;
done(null, safeUser);
} catch (error) {
done(error, null);
}
}
}
Configuring the Session Store with Redis
For production, always use a persistent session store. Redis is the gold standard — it's fast, supports clustering, and allows session sharing across multiple server instances:
// main.ts (session configuration)
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import * as session from 'express-session';
import * as passport from 'passport';
import RedisStore from 'connect-redis';
import { createClient } from 'redis';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
const redisClient = createClient({
url: process.env.REDIS_URL || 'redis://localhost:6379',
});
redisClient.connect().catch(console.error);
const redisStore = new RedisStore({
client: redisClient,
prefix: 'sess:',
});
app.use(
session({
store: redisStore,
secret: process.env.SESSION_SECRET || 'your-secret-key-min-32-chars',
resave: false,
saveUninitialized: false,
cookie: {
maxAge: 1000 * 60 * 60 * 24, // 24 hours
httpOnly: true, // Prevents client-side JavaScript access
secure: process.env.NODE_ENV === 'production', // HTTPS only in production
sameSite: 'lax', // CSRF protection
},
}),
);
app.use(passport.initialize());
app.use(passport.session());
await app.listen(3000);
}
bootstrap();
Session Auth Guard and Controller
// session-auth.guard.ts
import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
@Injectable()
export class SessionAuthGuard extends AuthGuard('local') {}
// login-session.guard.ts
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
@Injectable()
export class AuthenticatedGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean {
const request = context.switchToHttp().getRequest();
return request.isAuthenticated(); // Passport session method
}
}
// session-auth.controller.ts
import {
Controller, Post, Get, UseGuards, Body, Req, Res
} from '@nestjs/common';
import { Request, Response } from 'express';
import { SessionAuthGuard } from './session-auth.guard';
import { AuthenticatedGuard } from './login-session.guard';
@Controller('session-auth')
export class SessionAuthController {
@UseGuards(SessionAuthGuard)
@Post('login')
async login(@Req() req: Request, @Res() res: Response) {
// Passport automatically establishes the session upon successful login
res.json({
message: 'Login successful',
user: req.user,
sessionId: req.sessionID,
});
}
@UseGuards(AuthenticatedGuard)
@Get('profile')
getProfile(@Req() req: Request) {
return { user: req.user, sessionId: req.sessionID };
}
@UseGuards(AuthenticatedGuard)
@Post('logout')
async logout(@Req() req: Request, @Res() res: Response) {
req.logout((err) => {
if (err) {
return res.status(500).json({ message: 'Logout failed' });
}
req.session.destroy((sessionErr) => {
if (sessionErr) {
return res.status(500).json({ message: 'Session destruction failed' });
}
res.clearCookie('connect.sid');
res.json({ message: 'Logged out successfully' });
});
});
}
}
OAuth Integration in NestJS
What is OAuth Integration?
OAuth 2.0 allows users to authenticate using trusted third-party providers like Google, GitHub, or Facebook without sharing credentials with your application. The flow works like this: your app redirects the user to the provider's authorization page; the user grants consent; the provider redirects back to your callback URL with an authorization code; your server exchanges this code for an access token; then you use that token to fetch the user's profile information. NestJS with Passport makes this surprisingly straightforward.
Installing OAuth Dependencies
npm install passport-google-oauth20 passport-github2
npm install -D @types/passport-google-oauth20 @types/passport-github2
Google OAuth Strategy
The Google strategy handles the entire OAuth dance. You provide your Google Cloud Console credentials (Client ID and Client Secret), define the scopes you need, and implement the verification callback where you create or retrieve the user in your database:
// google.strategy.ts
import { Injectable } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { Strategy, VerifyCallback } from 'passport-google-oauth20';
import { ConfigService } from '@nestjs/config';
import { UsersService } from '../users/users.service';
@Injectable()
export class GoogleStrategy extends PassportStrategy(Strategy, 'google') {
constructor(
private configService: ConfigService,
private usersService: UsersService,
) {
super({
clientID: configService.get('GOOGLE_CLIENT_ID'),
clientSecret: configService.get('GOOGLE_CLIENT_SECRET'),
callbackURL: configService.get('GOOGLE_CALLBACK_URL'),
scope: ['email', 'profile'],
passReqToCallback: false,
});
}
async validate(
accessToken: string,
refreshToken: string,
profile: any,
done: VerifyCallback,
): Promise {
const { id, displayName, emails } = profile;
const email = emails?.[0]?.value;
const user = await this.usersService.findOrCreateOAuthUser({
provider: 'google',
id,
email,
});
done(null, user);
}
}
GitHub OAuth Strategy
GitHub OAuth follows the same pattern but with different profile fields. You'll need a GitHub OAuth App registered in your GitHub Developer Settings:
// github.strategy.ts
import { Injectable } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { Strategy } from 'passport-github2';
import { ConfigService } from '@nestjs/config';
import { UsersService } from '../users/users.service';
@Injectable()
export class GitHubStrategy extends PassportStrategy(Strategy, 'github') {
constructor(
private configService: ConfigService,
private usersService: UsersService,
) {
super({
clientID: configService.get('GITHUB_CLIENT_ID'),
clientSecret: configService.get('GITHUB_CLIENT_SECRET'),
callbackURL: configService.get('GITHUB_CALLBACK_URL'),
scope: ['user:email'],
});
}
async validate(
accessToken: string,
refreshToken: string,
profile: any,
done: Function,
): Promise {
const { id, displayName, emails, username } = profile;
// GitHub may not return email if it's private; fetch it separately if needed
const email = emails?.[0]?.value || `${username}@github.com`;
const user = await this.usersService.findOrCreateOAuthUser({
provider: 'github',
id,
email,
});
done(null, user);
}
}
OAuth Guard and Controller
OAuth flows require two routes per provider: one to initiate the redirect and one to handle the callback. The guard triggers the Passport strategy which automatically redirects the user:
// oauth-auth.guard.ts
import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
@Injectable()
export class GoogleOAuthGuard extends AuthGuard('google') {}
@Injectable()
export class GitHubOAuthGuard extends AuthGuard('github') {}
// oauth.controller.ts
import { Controller, Get, UseGuards, Req, Res } from '@nestjs/common';
import { Request, Response } from 'express';
import { GoogleOAuthGuard, GitHubOAuthGuard } from './oauth-auth.guard';
import { AuthService } from './auth.service';
import { ConfigService } from '@nestjs/config';
@Controller('oauth')
export class OAuthController {
constructor(
private authService: AuthService,
private configService: ConfigService,
) {}
// Initiates Google OAuth flow
@Get('google')
@UseGuards(GoogleOAuthGuard)
async googleAuth() {
// Guard automatically redirects to Google consent screen
// This method body never executes
}
// Handles Google callback
@Get('google/callback')
@UseGuards(GoogleOAuthGuard)
async googleCallback(@Req() req: Request, @Res() res: Response) {
const user = req.user as any;
// Generate JWT tokens for the OAuth user
const tokens = await this.authService.login(user);
// Redirect to frontend with tokens (or set HttpOnly cookie)
const frontendUrl = this.configService.get('FRONTEND_URL');
res.redirect(
`${frontendUrl}/oauth-success?` +
`accessToken=${tokens.accessToken}&` +
`refreshToken=${tokens.refreshToken}`
);
}
@Get('github')
@UseGuards(GitHubOAuthGuard)
async githubAuth() {
// Guard automatically redirects to GitHub
}
@Get('github/callback')
@UseGuards(GitHubOAuthGuard)
async githubCallback(@Req() req: Request, @Res() res: Response) {
const user = req.user as any;
const tokens = await this.authService.login(user);
const frontendUrl = this.configService.get('FRONTEND_URL');
res.redirect(
`${frontendUrl}/oauth-success?` +
`accessToken=${tokens.accessToken}&` +
`refreshToken=${tokens.refreshToken}`
);
}
}
Environment Configuration for OAuth
Store all OAuth credentials in environment variables. Never hardcode secrets. Here's a sample .env file:
# .env
GOOGLE_CLIENT_ID=123456789-abcdefghijklmnop.apps.googleusercontent.com
GOOGLE_CLIENT_SECRET=GOCSPX-xxxxxxxxxxxxxxxxxxxx
GOOGLE_CALLBACK_URL=http://localhost:3000/oauth/google/callback
GITHUB_CLIENT_ID=Iv1.xxxxxxxxxxxx
GITHUB_CLIENT_SECRET=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
GITHUB_CALLBACK_URL=http://localhost:3000/oauth/github/callback
FRONTEND_URL=http://localhost:5173
JWT_ACCESS_SECRET=your-access-secret-at-least-32-characters
JWT_REFRESH_SECRET=your-refresh-secret-at-least-32-characters
JWT_ACCESS_EXPIRATION=15m
JWT_REFRESH_EXPIRATION=7d
REDIS_URL=redis://localhost:6379
SESSION_SECRET=your-session-secret-at-least-32-characters
Combining Multiple Authentication Strategies
In real-world applications, you often need to support multiple authentication methods simultaneously. NestJS makes this clean with composable guards. You can create a composite guard that tries JWT first, falls back to session validation, or supports OAuth alongside traditional login:
// multi-auth.guard.ts
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
import { JwtAuthGuard } from './jwt-auth.guard';
import { AuthenticatedGuard } from './login-session.guard';
@Injectable()
export class MultiAuthGuard implements CanActivate {
constructor(
private jwtGuard: JwtAuthGuard,
private sessionGuard: AuthenticatedGuard,
) {}
async canActivate(context: ExecutionContext): Promise {
const request = context.switchToHttp().getRequest();
// Try JWT first (for API clients and mobile apps)
if (request.headers.authorization?.startsWith('Bearer ')) {
try {
return await this.jwtGuard.canActivate(context) as boolean;
} catch {
// JWT failed, try session next
}
}
// Fall back to session-based auth (for browser clients)
try {
return this.sessionGuard.canActivate(context);
} catch {
return false;
}
}
}
Best Practices for NestJS Authentication
1. Never Store Plaintext Tokens or Passwords
Always hash refresh tokens before storing them in the database using bcrypt. If an attacker gains read access to your database, hashed tokens are useless. Similarly, passwords should only exist as salted hashes. The bcrypt library with a salt factor of 10–12 provides excellent protection against rainbow table attacks.
2. Use HttpOnly, Secure, and SameSite Cookies
For session-based auth, configure cookies with httpOnly: true to prevent JavaScript access, secure: true in production to enforce HTTPS-only transmission, and sameSite: 'lax' or 'strict' to mitigate CSRF attacks. Never store JWT tokens in localStorage — they're vulnerable to XSS attacks. Instead, store them in HttpOnly cookies or keep them in memory with a service worker for SPAs.
3. Implement Token Rotation and Refresh Token Detection
With JWT, implement refresh token rotation where each use of a refresh token invalidates the previous one and issues a new refresh token. This detects token theft: if an attacker steals a refresh token and uses it, the legitimate user's subsequent refresh attempt will fail because their token has been invalidated, alerting you to the breach. The code in our auth service above implements this pattern.
4. Keep Secrets Out of Source Code
Use environment variables with NestJS's ConfigModule. For production, consider a secrets manager like HashiCorp Vault, AWS Secrets Manager, or Azure Key Vault. Rotate JWT signing keys regularly — you can use key identifiers (kid) in JWT headers to support multiple valid keys during rotation windows.
5. Validate All OAuth Redirect URIs
Whitelist exact redirect URIs in your OAuth provider settings. Open redirectors can be exploited in phishing attacks. Never allow wildcard redirect patterns in production. Validate the state parameter in OAuth flows to prevent CSRF attacks during the authorization code exchange.
6. Implement Rate Limiting on Auth Endpoints
Protect login, refresh, and OAuth callback endpoints with rate limiting to prevent brute force attacks. NestJS works well with packages like @nestjs/throttler:
npm install @nestjs/throttler
// auth.controller.ts (with throttling)
import { Throttle, SkipThrottle } from '@nestjs/throttler';
@Controller('auth')
export class AuthController {
@Throttle({ default: { limit: 5, ttl: 60000 } }) // 5 attempts per minute
@Post('login')
async login(@Body() body: { email: string; password: string }) {
// Login logic
}
@Throttle({ default: { limit: 3, ttl: 300000 } }) // 3 attempts per 5 min
@Post('refresh')
async refresh(@Body() body: { refreshToken: string }) {
// Refresh logic
}
}
7. Use Short-Lived Access Tokens
Keep JWT access token lifetimes short — 15 minutes is standard. This minimizes the damage window if a token is compromised. The refresh token mechanism handles obtaining new access tokens without requiring the user to re-authenticate frequently.
8. Audit and Log Authentication Events
Log all login attempts (successful and failed), token refreshes, and OAuth callbacks. Include metadata like IP address, user agent, and timestamp. This creates an audit trail for security investigations:
// auth-logger.service.ts
import { Injectable, Logger } from '@nestjs/common';
@Injectable()
export class AuthLoggerService {
private logger = new Logger('Auth');
logLoginAttempt(email: string, success: boolean, ip: string, userAgent: string) {
this.logger.log(
`Login attempt: email=${email}, success=${success}, ip=${ip}, ` +
`userAgent=${userAgent.substring(0, 100)}`
);
}
logTokenRefresh(userId: string, success: boolean, ip: string) {
this.logger.log(
`Token refresh: userId=${userId}, success=${success}, ip=${ip}`
);
}
logOAuthLogin(provider: string, email: string, success: boolean) {
this.logger.log(
`OAuth login: provider=${provider}, email=${email}, success=${success}`
);
}
}
9. Implement Proper CORS Configuration
When your API serves a frontend on a different origin, configure CORS carefully. Never use credentials: true with a wildcard origin. Instead, whitelist specific origins:
// main.ts
app.enableCors({
origin: [
'https://your-production-frontend.com',
'http://localhost:5173',
],
credentials: true, // Allow cookies and auth headers
methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH'],
allowedHeaders: ['Content-Type', 'Authorization'],
});
10. Test Authentication Flows Thoroughly
Write integration tests for