Skip to content
ExponaBLOG
NESTJS · DAY 022 / 100

JWT authentication with Passport: lessons from production

The standard NestJS auth setup, done properly.

Expona Admin8 min read

JWT authentication with Passport

After 5+ years building production platforms, I've watched teams spend weeks debugging auth issues that could've been prevented with a solid foundation. JWT authentication with Passport in NestJS is the standard for good reason—but only when you implement it correctly. Today I'll walk you through the setup that actually scales, from strategy validation through token refresh logic, and show you the mistakes I see most often.

Why JWT and Passport Matter

When you're running a microservices architecture or a distributed system, session-based auth breaks down fast. You can't reliably share session state across services without adding a central store (Redis, database), and that becomes a bottleneck. JWT (JSON Web Token) doesn't have that problem—it's stateless by design. The token carries everything the server needs to verify it: your user ID, roles, tenant association, all cryptographically signed.

Passport is the de facto standard passport library for Node.js. It abstracts away the strategy pattern so you can swap authentication methods—JWT today, OAuth2 tomorrow—without rewriting your guards and controller logic. NestJS wraps Passport beautifully with decorators and dependency injection, so the integration feels native.

JwtStrategy — Validate the Token and Return the User Payload

The JwtStrategy is where token validation lives. When a request comes in with a Bearer token in the Authorization header, Passport extracts it, verifies the signature using your secret, and then calls your validate() method with the decoded payload.

typescript
import { Injectable } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { Strategy, ExtractJwt } from 'passport-jwt';
import { ConfigService } from '@nestjs/config';

export interface JwtPayload {
  sub: string; // userId
  roles: string[];
  tenantId?: string;
}

@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
  constructor(private cfg: ConfigService) {
    super({
      jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
      secretOrKey: this.cfg.get('JWT_SECRET'),
      algorithms: ['HS256'],
    });
  }

  validate(payload: JwtPayload) {
    return payload;
  }
}

Notice I'm keeping the payload minimal: sub (the user's ID), roles, and optionally tenantId. That's it. Don't dump the user's full profile into the token. Every byte counts because the token is sent with every request, and bloated tokens slow down your system.

The validate() method returns whatever you want to attach to request.user inside your controllers. In most cases, you're just returning the payload as-is. But this is where you could inject the database and load the full user if you needed custom logic—though I advise against it for performance reasons. More on that later.

I always specify algorithms: ['HS256'] explicitly. It's a security best practice; otherwise Passport will accept any algorithm the token claims to use, which opens you to algorithm-substitution attacks.

Global Guard and Public Opt-Out

Most routes need authentication. You want it applied everywhere by default, not sprinkled onto individual routes. NestJS lets you register a global guard in your app module.

typescript
import { Module } from '@nestjs/common';
import { APP_GUARD } from '@nestjs/core';
import { JwtAuthGuard } from './auth/guards/jwt-auth.guard';

@Module({
  providers: [
    {
      provide: APP_GUARD,
      useClass: JwtAuthGuard,
    },
  ],
})
export class AppModule {}

Now here's the guard itself:

typescript
import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { Reflector } from '@nestjs/core';

export const Public = () =>
  Reflector.createDecorator();

@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {
  constructor(private reflector: Reflector) {
    super();
  }

  canActivate(context) {
    const isPublic = this.reflector.getAllAndOverride(Public, [
      context.getHandler(),
      context.getClass(),
    ]);

    if (isPublic) {
      return true;
    }

    return super.canActivate(context);
  }
}

With this setup, every route is protected by default. To opt out, just add @Public() to the controller or handler:

typescript
@Post('login')
@Public()
async login(@Body() dto: LoginDto) {
  // No token required
  return this.authService.login(dto);
}

This inversion—secure by default, open by exception—is safer than the alternative. You won't accidentally leave routes unprotected.

Short-Lived Access Tokens with Refresh Token Strategy

I see a lot of teams put a 24-hour or 7-day expiry on their access tokens. That's a red flag. If an access token is compromised, you've given an attacker a week of access.

The pattern that works in production is this: 15-minute access tokens paired with a separate refresh token that lives for days or weeks. The refresh token is stored securely (httpOnly cookie, or a database record) and only used to issue new access tokens. If an access token leaks, the damage window is 15 minutes.

Here's how you generate both:

typescript
import { Injectable } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config';

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

  async login(userId: string, roles: string[]) {
    const payload = { sub: userId, roles };

    const accessToken = this.jwtService.sign(payload, {
      secret: this.cfg.get('JWT_SECRET'),
      expiresIn: '15m',
    });

    const refreshToken = this.jwtService.sign(payload, {
      secret: this.cfg.get('JWT_REFRESH_SECRET'),
      expiresIn: '7d',
    });

    return { accessToken, refreshToken };
  }

  async refresh(refreshToken: string) {
    try {
      const payload = this.jwtService.verify(refreshToken, {
        secret: this.cfg.get('JWT_REFRESH_SECRET'),
      });

      const newAccessToken = this.jwtService.sign(
        { sub: payload.sub, roles: payload.roles },
        {
          secret: this.cfg.get('JWT_SECRET'),
          expiresIn: '15m',
        },
      );

      return { accessToken: newAccessToken };
    } catch (err) {
      throw new UnauthorizedException('Invalid refresh token');
    }
  }
}

Notice I'm using a separate JWT_REFRESH_SECRET. This isn't strictly necessary, but it's cleaner conceptually—refresh tokens and access tokens are different animals and shouldn't use the same key. If one secret leaks, you can rotate it without invalidating all tokens created with the other.

Keep Payloads Lean

Here's a subtle mistake I see repeatedly: developers put too much into the JWT payload, thinking it saves database queries.

What you actually want in the token:

  • sub: User ID
  • roles: Array of role strings
  • tenantId: If you're multi-tenant

What you should load on-demand from the database:

  • Email, profile name, avatar
  • Permissions (if you use fine-grained permission checks)
  • Feature flags
  • Any data that might change between token issue and expiry

Why? Because once a token is issued, it's immutable. If you encode the user's email and they change it, the token won't reflect that for 15 minutes. If you encode a role and it gets revoked, they're still acting under that role until the token expires. This is a security and consistency nightmare.

The 15-minute window makes this tractable. A brief cache at the application level (using something like Redis with a 5-minute TTL) can cover most cases, and you get fast lookups without the staleness problem.

Common Mistakes

Mistake 1: Long-lived access tokens

I see tokens with 7-day or 30-day expiries. This inflates your attack surface. Stick to 15 minutes. Seriously.

Mistake 2: Not validating token expiry on refresh

If your refresh endpoint doesn't verify that the refresh token hasn't been tampered with (e.g., by checking the signature), an attacker can craft their own refresh tokens. Always call verify(), never just decode.

Mistake 3: Putting the entire user object in the payload

It bloats the token, makes updates stale, and creates a false sense of security ("I have the user data already, no need to load it again"). It's a trap.

Mistake 4: Using a weak JWT secret

Your JWT_SECRET should be a strong random string (at least 32 characters). Store it in environment variables, never in code. And rotate it periodically if you can.

Mistake 5: Forgetting to handle token expiry gracefully on the client

When the 15-minute token expires, the client should catch the 401 response, call the refresh endpoint, retry the original request, and continue. If you don't implement this, the user experience is terrible.

Wiring It All Together

Here's what your auth module looks like when it's done right:

typescript
import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { PassportModule } from '@nestjs/passport';
import { AuthService } from './auth.service';
import { AuthController } from './auth.controller';
import { JwtStrategy } from './strategies/jwt.strategy';

@Module({
  imports: [
    PassportModule,
    JwtModule.register({
      secret: process.env.JWT_SECRET,
      signOptions: { expiresIn: '15m' },
    }),
  ],
  providers: [AuthService, JwtStrategy],
  controllers: [AuthController],
})
export class AuthModule {}

Then in your controller, you're just calling the service:

typescript
@Post('login')
@Public()
async login(@Body() dto: LoginDto) {
  return this.authService.login(dto.userId, dto.roles);
}

@Post('refresh')
@Public()
async refresh(@Body() dto: { refreshToken: string }) {
  return this.authService.refresh(dto.refreshToken);
}

Any other endpoint gets the global JwtAuthGuard automatically. Inside your handlers, request.user contains your JwtPayload.

Key Takeaways

  • Use JwtStrategy to validate tokens and extract the payload into request.user.
  • Register JwtAuthGuard globally; use @Public() to opt out. Secure by default is safer.
  • Keep access tokens short-lived (15 minutes) and back them with a refresh token for longer sessions.
  • Store only sub, roles, and tenantId in the token. Load everything else on-demand.
  • Always verify() tokens, never just decode. Check the signature.
  • Use a separate secret for refresh tokens if your architecture allows it.
  • Implement client-side token refresh so users don't get logged out every 15 minutes.
---
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles