← Back to DevBytes

NestJS Authentication: JWT, Sessions, and OAuth Integration

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

— Ad —

Google AdSense will appear here after approval

← Back to all articles