NestJS Authentication Patterns

Passport strategies, JWT with refresh-token rotation and family detection, Redis sessions, OAuth, and the lockout patterns I learned the hard way.

It was a Tuesday morning. The login endpoint on one of my NestJS services was getting hammered, around 09:30 local, the kind of ramp you’d see when a market opens. The pattern looked off though. The same client IPs, the same email, hitting /auth/login four or five times a second, every second. Our verify-password p99 climbed from 110 ms to almost 2 s in under a minute, and bcrypt was eating CPU on every gateway pod.

I’d seen this shape before. Not on auth, on Socket.io. Years ago on a real-time trading platform I architected, the gateway took a reconnection storm about a minute after market open and I scaled pods three times before realising I was feeding the fire. Same lesson here. Auth isn’t a scale problem. It’s a backoff and idempotency problem.

This is how I lay out NestJS auth now, after a few of these.

Passport strategies, kept small

@nestjs/passport is the boring right answer. I don’t roll my own auth in NestJS. I wrap Passport, keep strategies tiny, and put the actual policy in guards and services. A strategy’s only job is “given a credential, give me back a principal”. Anything more and it leaks.

import { Injectable, UnauthorizedException } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { Strategy, ExtractJwt } from 'passport-jwt';
import { ConfigService } from '@nestjs/config';
import { UsersService } from '../users/users.service';

type JwtPayload = { sub: string; sid: string; iat: number; exp: number };

@Injectable()
export class JwtAccessStrategy extends PassportStrategy(Strategy, 'jwt-access') {
  constructor(private config: ConfigService, private users: UsersService) {
    super({
      jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
      ignoreExpiration: false,
      secretOrKey: config.getOrThrow<string>('JWT_ACCESS_PUBLIC_KEY'),
      algorithms: ['RS256'],
    });
  }

  async validate(payload: JwtPayload) {
    const user = await this.users.findActiveById(payload.sub);
    if (!user) throw new UnauthorizedException('user_inactive');
    return { id: user.id, sid: payload.sid, roles: user.roles };
  }
}

RS256, not HS256, so the verifier doesn’t need the signing secret. The sid is a session id I’ll explain in a second. The strategy doesn’t decide whether the user is allowed to do anything, it just confirms identity. Roles and permissions go in a guard.

JWT with refresh-token rotation

Plain access tokens are easy. Refresh tokens are where teams get hurt. I rotate every refresh, store its hash server-side, and run family detection. If a refresh token is presented twice, the entire family gets revoked. That’s the deal.

import { Injectable, ForbiddenException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { createHash, randomUUID } from 'node:crypto';
import { RefreshToken } from './refresh-token.entity';

@Injectable()
export class TokenService {
  constructor(
    private jwt: JwtService,
    @InjectRepository(RefreshToken) private repo: Repository<RefreshToken>,
  ) {}

  private sha(token: string) {
    return createHash('sha256').update(token).digest('hex');
  }

  async rotate(presented: string, userId: string) {
    const hash = this.sha(presented);
    const stored = await this.repo.findOne({ where: { hash } });

    if (!stored) {
      // unknown token, treat as theft and revoke everything for this user
      await this.repo.update({ userId, revokedAt: null }, { revokedAt: new Date() });
      throw new ForbiddenException('refresh_reuse_unknown');
    }

    if (stored.revokedAt) {
      // reuse of an already-rotated token, kill the family
      await this.repo.update({ familyId: stored.familyId, revokedAt: null }, { revokedAt: new Date() });
      throw new ForbiddenException('refresh_reuse_detected');
    }

    const next = randomUUID();
    await this.repo.manager.transaction(async (tx) => {
      await tx.update(RefreshToken, { id: stored.id }, { revokedAt: new Date() });
      await tx.insert(RefreshToken, {
        userId,
        familyId: stored.familyId,
        hash: this.sha(next),
        createdAt: new Date(),
      });
    });

    const access = await this.jwt.signAsync(
      { sub: userId, sid: stored.familyId },
      { expiresIn: '10m' },
    );
    return { access, refresh: next };
  }
}

The thing that made me build it this way wasn’t a paper, it was an incident at the creator-economy platform I spent the last few years at. Different domain, same shape. We had a renewal handler for Apple in-app purchase that had no idempotency check on the server-to-server notification. Apple’s API retries aggressively. A 200 OK that arrives a few seconds late gets retried, and if your handler isn’t idempotent every retry creates a new row. We ended up with thousands of customers carrying two active subscriptions, real money, a few legal threads. The fix was a unique constraint at the database level plus an async queue with an idempotency key. I write refresh-token endpoints under exactly the same rule now. Any token can be presented twice. Treat the second one as either a retry that matches state or as theft. There is no third option.

Sessions with Redis when JWT isn’t right

JWT is great for stateless service-to-service calls. It’s the wrong tool for “log this user out everywhere right now”. For dashboards and admin surfaces I run server-side sessions in Redis behind express-session and a NestJS module. Logout becomes one DEL, and forced password reset is one SCAN + DEL over a user prefix.

import { Module, MiddlewareConsumer, NestModule } from '@nestjs/common';
import { ThrottlerModule } from '@nestjs/throttler';
import session from 'express-session';
import { RedisStore } from 'connect-redis';
import { createClient } from 'redis';

const redis = createClient({ url: process.env.REDIS_URL });
redis.connect().catch((e) => { throw e });

@Module({
  imports: [
    ThrottlerModule.forRoot([
      { name: 'login', ttl: 60_000, limit: 10 },
      { name: 'refresh', ttl: 60_000, limit: 30 },
    ]),
  ],
})
export class AuthModule implements NestModule {
  configure(consumer: MiddlewareConsumer) {
    consumer
      .apply(
        session({
          store: new RedisStore({ client: redis, prefix: 'sess:' }),
          secret: process.env.SESSION_SECRET!,
          resave: false,
          saveUninitialized: false,
          cookie: {
            httpOnly: true,
            secure: true,
            sameSite: 'lax',
            maxAge: 1000 * 60 * 60 * 8,
          },
        }),
      )
      .forRoutes('*');
  }
}

I don’t mix the two registers in one product. Either every surface is JWT or every surface is session. Mixing them invites the worst kind of bug. A user “logs out” on web, their mobile app keeps working for the next 50 minutes, and nobody can explain why because the two paths run different verification logic.

OAuth, with the secret boundary in mind

OAuth in NestJS is mostly Passport again, with one rule I don’t bend. The provider’s client_secret never leaves the auth service. Not in env files copied around. Not in a shared .env.production baked into images. Pulled at boot from AWS Secrets Manager or SSM, kept in memory, never logged.

import { Injectable } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { Strategy, VerifyCallback } from 'passport-google-oauth20';
import { ConfigService } from '@nestjs/config';
import { OAuthAccountsService } from './oauth-accounts.service';

@Injectable()
export class GoogleStrategy extends PassportStrategy(Strategy, 'google') {
  constructor(private config: ConfigService, private accounts: OAuthAccountsService) {
    super({
      clientID: config.getOrThrow('GOOGLE_CLIENT_ID'),
      clientSecret: config.getOrThrow('GOOGLE_CLIENT_SECRET'),
      callbackURL: config.getOrThrow('GOOGLE_CALLBACK_URL'),
      scope: ['email', 'profile'],
    });
  }

  async validate(_at: string, _rt: string, profile: any, done: VerifyCallback) {
    try {
      const account = await this.accounts.linkOrCreate({
        provider: 'google',
        providerUserId: profile.id,
        email: profile.emails?.[0]?.value,
      });
      done(null, { id: account.userId });
    } catch (e) {
      done(e as Error, undefined);
    }
  }
}

I never store provider access tokens unless the product literally needs them later for API calls. If we only need login, the token gets dropped after validate returns. Less surface area, fewer audits.

Rate limiting and lockout

@nestjs/throttler is the floor. It catches noise. It does not stop a credential-stuffing campaign because a real attacker rotates IPs faster than your TTL. The actual defence is two-layered. A per-IP throttle to protect CPU and bcrypt, plus a per-account counter in Redis that hardens the response after a few failures. Five failures inside ten minutes, the next attempt asks for a captcha. Ten failures, the account goes into a 15-minute soft lock and the user gets an email. Twenty across two hours, the family of refresh tokens dies and the user has to reset.

That last layer is what I missed during the storm I opened with. We had throttling. We didn’t have an account-aware counter, so an attacker hitting one inbox at one request per second per IP, across hundreds of IPs, looked like normal traffic to the per-IP limiter. The fix was small. The lesson, again, was that backoff and idempotency live closer to the user than to the network.

Takeaways

  • Use @nestjs/passport, keep strategies to identification only, push policy into guards.
  • Rotate refresh tokens, hash them server-side, detect reuse, kill the family on reuse.
  • Pick one register per product, JWT or sessions. Don’t mix on the same surface.
  • Treat every auth retry as potentially duplicate. Idempotency keys aren’t optional.
  • Layer rate limiting per-IP and per-account. The per-account layer is the one that stops real campaigns.
  • Pull OAuth secrets at boot from a real secret store. Never log them, never bake them.

Thanks for reading. If you’ve got thoughts, send them my way.

© 2026 Akin Gundogdu. All Rights Reserved.