Back to Blog

JWT Authentication in NestJS

Implementing Access & Refresh Tokens the Right Way

JWT  Authentication in NestJS
nest.jstypescript

Authentication is one of those areas where it's easy to get things wrong. In this post, I'll show you how I implemented JWT-based authentication with access and refresh tokens in my NestJS backend – including the pitfalls I encountered along the way.

Why Two Tokens in the First Place?

Before diving into the code: why not just use a single token with a long lifespan? The answer lies in the security-UX tradeoff.

An access token is sent with every API request. If it gets intercepted, an attacker gains access to your system. That's why it should be short-lived – in my case, 15 minutes. The problem: nobody wants to log in every 15 minutes.

This is where the refresh token comes in. It's long-lived (7 days) but is only used to obtain new access tokens. It never leaves the client's secure storage except for this single purpose. If the access token gets compromised, the damage is time-limited. If the refresh token gets compromised, it can be invalidated without forcing the user to change their password.

Architecture Overview

┌─────────────┐     ┌─────────────┐     ┌─────────────┐
│   Client    │────▶│  Controller │────▶│   Service   │
│  (Frontend) │◀────│   + Guards  │◀────│  + JWT      │
└─────────────┘     └─────────────┘     └─────────────┘
                           │                    │
                           ▼                    ▼
                    ┌─────────────┐     ┌─────────────┐
                    │  Strategy   │     │   Users     │
                    │  (Passport) │     │   Service   │
                    └─────────────┘     └─────────────┘

NestJS makes this relatively straightforward with the Passport module. The core components are the AuthService for token logic, the JwtStrategy for validation, and Guards for route protection.

Setup

First, the required packages:

bash

npm install @nestjs/jwt @nestjs/passport passport passport-jwt bcrypt
npm install -D @types/passport-jwt @types/bcrypt

And two environment variables for the secrets – it's crucial that access and refresh tokens use different secrets:

env

# Generate with: openssl rand -base64 32
JWT_ACCESS_SECRET=your-access-secret-here
JWT_REFRESH_SECRET=your-refresh-secret-here

User Entity: Storing Passwords Correctly

First rule: never store passwords in plain text. We hash with bcrypt:

typescript

@Entity('users')
export class User {
  @PrimaryGeneratedColumn('uuid') 
  id!: string;

  @Index({ unique: true })
  @Column({ length: 120 })
  email!: string;

  @Column({ length: 200 })
  passwordHash!: string;

  @Column({ default: false })
  isAdmin!: boolean;

  @CreateDateColumn({ type: 'timestamptz' })
  createdAt!: Date;
}

Users Service: Hashing and Verification

The UsersService handles hashing during registration and verification during login:

typescript

import * as bcrypt from 'bcrypt';

@Injectable()
export class UsersService {
  constructor(
    @InjectRepository(User) private repo: Repository<User>
  ) {}

  async create(data: { email: string; password: string; isAdmin?: boolean }) {
    // Cost factor 12: good balance between security and performance
    const passwordHash = await bcrypt.hash(data.password, 12);
    
    const user = this.repo.create({
      email: data.email,
      passwordHash,
      isAdmin: data.isAdmin || false,
    });
    
    return this.repo.save(user);
  }

  async verify(email: string, password: string) {
    const user = await this.findByEmail(email);
    if (!user) return null;

    // bcrypt.compare is timing-safe against timing attacks
    const valid = await bcrypt.compare(password, user.passwordHash);
    return valid ? user : null;
  }

  findByEmail(email: string) {
    return this.repo.findOne({ where: { email } });
  }

  findById(id: string) {
    return this.repo.findOne({ where: { id } });
  }
}

The cost factor of 12 for bcrypt is a solid middle ground. Higher means more security but also longer wait times during login. With 12, the hash time is around 250ms – noticeable enough to slow down brute-force attacks, but not so long that users get impatient.

Auth Service: The Heart of It All

This is where the actual token magic happens:

typescript

@Injectable()
export class AuthService {
  constructor(
    private users: UsersService,
    private jwt: JwtService,
    private cfg: ConfigService,
  ) {}

  async login(email: string, password: string) {
    const user = await this.users.verify(email, password);
    if (!user) {
      throw new UnauthorizedException('Invalid credentials');
    }

    // Access token: contains user info, short-lived
    const accessPayload = { 
      sub: user.id, 
      email: user.email, 
      isAdmin: user.isAdmin 
    };
    
    const accessToken = await this.jwt.signAsync(accessPayload, {
      secret: this.cfg.get<string>('JWT_ACCESS_SECRET'),
      expiresIn: '15m',
    });

    // Refresh token: minimal payload, long-lived
    const refreshToken = await this.jwt.signAsync(
      { sub: user.id },
      {
        secret: this.cfg.get<string>('JWT_REFRESH_SECRET'),
        expiresIn: '7d',
      },
    );

    return {
      accessToken,
      refreshToken,
      user: { id: user.id, email: user.email, isAdmin: user.isAdmin },
    };
  }

  async refresh(refreshToken: string) {
    // Validate with the OTHER secret!
    const payload = await this.jwt.verifyAsync<{ sub: string }>(
      refreshToken,
      { secret: this.cfg.getOrThrow<string>('JWT_REFRESH_SECRET') },
    );

    // User still exists? Could have been deleted
    const user = await this.users.findById(payload.sub);
    if (!user) {
      throw new UnauthorizedException('User not found');
    }

    // Token rotation: completely new token pair
    const newAccessToken = await this.jwt.signAsync(
      { sub: user.id, email: user.email, isAdmin: user.isAdmin },
      {
        secret: this.cfg.getOrThrow<string>('JWT_ACCESS_SECRET'),
        expiresIn: '15m',
      },
    );
    
    const newRefreshToken = await this.jwt.signAsync(
      { sub: user.id },
      {
        secret: this.cfg.get<string>('JWT_REFRESH_SECRET'),
        expiresIn: '7d',
      },
    );

    return { accessToken: newAccessToken, refreshToken: newRefreshToken };
  }
}

An important detail: in refresh(), we reload the user from the database. This ensures the new access token contains current data – for example, if their admin status has changed.

JWT Strategy: Token Validation with Passport

The strategy is automatically invoked when a guard protects a route:

typescript

@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
  constructor(cfg: ConfigService) {
    super({
      jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
      secretOrKey: cfg.getOrThrow<string>('JWT_ACCESS_SECRET'),
      ignoreExpiration: false,
    });
  }

  validate(payload: { sub: string; email: string; isAdmin: boolean }) {
    // Whatever is returned here ends up in req.user
    return { 
      id: payload.sub, 
      email: payload.email, 
      isAdmin: payload.isAdmin 
    };
  }
}

Auth Controller

The controller exposes the endpoints:

typescript

@ApiTags('auth')
@Controller('auth')
export class AuthController {
  constructor(private auth: AuthService) {}

  @Post('login')
  @HttpCode(HttpStatus.OK)
  @UseGuards(ThrottlerGuard) // Rate limiting against brute-force
  @UsePipes(new ValidationPipe({ whitelist: true }))
  async login(@Body() dto: LoginDto) {
    return this.auth.login(dto.email, dto.password);
  }

  @Post('refresh')
  @HttpCode(HttpStatus.OK)
  refresh(@Body() body: { refreshToken: string }) {
    return this.auth.refresh(body.refreshToken);
  }

  @Get('me')
  @UseGuards(AuthGuard('jwt'))
  @ApiBearerAuth('JWT-auth')
  getMe(@Request() req: Request & { user: { id: string; email: string } }) {
    return req.user;
  }
}

The @HttpCode(HttpStatus.OK) on POST requests is important – by default, NestJS returns 201 for POST, but with login/refresh we're not actually creating anything new.

Admin Guard: Role-Based Access

For admin-only routes, we need a custom guard:

typescript

@Injectable()
export class AdminGuard extends AuthGuard('jwt') {
  async canActivate(context: ExecutionContext): Promise<boolean> {
    // First check JWT
    const isAuthenticated = await super.canActivate(context);
    if (!isAuthenticated) {
      throw new UnauthorizedException('Authentication required');
    }

    // Then check admin status
    const request = context.switchToHttp().getRequest();
    const user = request.user;

    if (!user?.isAdmin) {
      throw new ForbiddenException('Admin access required');
    }

    return true;
  }
}

The distinction between 401 (Unauthorized) and 403 (Forbidden) matters: 401 means "you're not logged in", 403 means "you're logged in but still not allowed to do this".

typescript

// Usage
@Delete(':id')
@UseGuards(AdminGuard)
deleteProject(@Param('id') id: string) {
  // Only admins reach this point
}

The Typical Auth Flow

Here's what the complete flow looks like in practice:

1. Login

POST /auth/login
Body: { "email": "user@example.com", "password": "..." }

Response: {
  "accessToken": "eyJhbG...",
  "refreshToken": "eyJhbG...",
  "user": { "id": "...", "email": "...", "isAdmin": false }
}

2. API Requests

GET /projects
Header: Authorization: Bearer eyJhbG...

3. Token Refresh (when access token expires)

POST /auth/refresh
Body: { "refreshToken": "eyJhbG..." }

Response: {
  "accessToken": "eyJhbG...",  // New!
  "refreshToken": "eyJhbG..."  // Also new (token rotation)
}

4. Re-Login (when refresh token expires)

After 7 days, the user needs to log in again.

Common Pitfalls

"Token expired" immediately after login

For me, this was caused by server time being off. JWTs are time-based – if your server's clock is wrong, tokens are immediately expired. Solution: enable NTP.

Secrets in code

A classic mistake. Never do this:

typescript

// ❌ 
secret: 'my-secret-key'

// ✅ 
secret: this.cfg.getOrThrow<string>('JWT_ACCESS_SECRET')

Stale user data in token

That's why we reload the user from the database in refresh(). This way, changes to the user profile are reflected in the token within 15 minutes at most.

Security Checklist

AspectImplementationPassword storagebcrypt, cost factor 12Token lifespanAccess: 15min, Refresh: 7dSeparate secretsDifferent keys for both token typesToken rotationNew refresh token on every refreshRate limitingThrottlerGuard on loginMinimal payloadRefresh token only contains subHTTP status codesCorrectly distinguish 401 vs 403

Conclusion

JWT authentication isn't rocket science, but the details make the difference between "works somehow" and "actually secure". The combination of short-lived access tokens and long-lived refresh tokens with token rotation offers a solid compromise between security and user experience.

The complete code is part of my portfolio backend – if you have questions or something's unclear, feel free to reach out.