DEV Community

Cover image for Building Secure JWT Auth in NestJS: Argon2, Redis Blacklisting, and Token Rotation
David Essien
David Essien

Posted on

Building Secure JWT Auth in NestJS: Argon2, Redis Blacklisting, and Token Rotation

Imagine you've just watched your first tutorial on authentication. You spin up a login flow, add some JWTs, and call it done. Then you push to production and realize those tutorials skipped the hard parts—token revocation, session management, replay attacks.

It's not enough to have a login endpoint. Production auth means thinking like an attacker and handling edge cases that basic tutorials ignore. Let's talk about what actually secure authentication looks like in NestJS and how to implement it without the security holes.

When a developer says they are "implementing auth," they are usually talking about two distinct but inseparable concepts: Authentication and Authorization. When building robust systems, these go hand in hand.

Authentication (AuthN) vs. Authorization (AuthZ)

  • Authentication is about identity. It answers "Who are you?" using credentials like email/password, passkeys, or biometrics.
  • Authorization is about permissions. It answers "What can you do?" Once we know who you are, we use strategies like Role-Based Access Control (RBAC) to ensure you only touch the resources you're allowed to.

The NestJS Toolkit: Passport and Guards

In the NestJS ecosystem, we don't reinvent the wheel. We use Passport.js, the industry standard for Node.js authentication. NestJS wraps this in @nestjs/passport and provides AuthGuards.

Guards are the "bouncers" of your application. When a request hits a protected route, the Guard intercepts it, extracts the token, and validates it before the request ever reaches your controller logic.

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

  async canActivate(context: ExecutionContext): Promise<boolean> {
    const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
      context.getHandler(),
      context.getClass(),
    ]);

    if (isPublic) return true;

    const request = context.switchToHttp().getRequest();
    if (!request.headers['authorization']) return false;

    const token = request.headers['authorization'].split(' ')[1] as string;

    // Check if the token was manually revoked (logout)
    const isBlacklisted = await this.redis.isTokenBlacklisted(token);
    if (isBlacklisted) return false;

    const result = super.canActivate(context);
    return result instanceof Promise ? result : (result as boolean);
  }
}
Enter fullscreen mode Exit fullscreen mode

Secure Onboarding: Why Argon2?

For sign-up, you need to store passwords. I chose Argon2 over the more common Bcrypt. Argon2 was the winner of the Password Hashing Competition for a reason: it's memory-intensive.

Unlike older algorithms, Argon2 allows you to configure memory usage, making it significantly harder to crack using specialized hardware like GPUs or ASICs. It essentially punishes brute-force attempts with slow, high-resource computation.

Registration (Hashing)

When a user signs up, the plain-text password is transformed into a hash before being stored in the database.

// UsersService
import { hash } from '@node-rs/argon2';

async create(data: CreateUserDto): Promise<User> {
  let userData = { ...data };
  if (data.password) {
    const passwordHash = await hash(data.password); //
    userData = { ...userData, password: passwordHash };
  }
  return await this.prisma.user.create({ data: userData });
}
Enter fullscreen mode Exit fullscreen mode

Login (Verification)

Crucially, we never "decrypt" a password. When a user logs in:

  1. We retrieve the stored hash from the DB.
  2. We take the plain-text password provided in the login form.
  3. We run the provided password through the same hashing process (including the "salt" stored within the hash).
  4. If the resulting hash matches the stored one, the user is verified.
import { verify } from '@node-rs/argon2';

// AuthService
async validateUser(email: string, password: string): Promise<UserEntity | undefined> {
  const user = await this.usersService.findOneByEmail(email);
  if (user && user.password) {
    /* The verify function automatically creates a hash of the plain
password and compares it against our stored password hash */
    const isPasswordValid = await verify(user.password, password);
    if (isPasswordValid) return new UserEntity(user);
    throw new UnauthorizedException('Password is not valid');
  }
}
Enter fullscreen mode Exit fullscreen mode

The Dual-Token Strategy

Once verified, we generate two tokens.

1. The Access Token (Short-lived & Stateless)

Signed with a secret key, this contains the user’s ID and metadata. We set this to 15 minutes. This is the industry standard because it limits the "blast radius"—if a token is stolen, it’s only useful for a tiny window.

2. The Refresh Token (Long-lived & Stateful)

Signed with a completely different secret (this is vital for security), we set this to 7 days. Unlike the access token, we store this in our database (Prisma) because it needs to be stateful. We also attach a unique JTI (JWT ID) when signing the token to allow for efficient database lookups after decoding the JWT.

By storing it, we gain a "Kill Switch." If we detect suspicious activity, we can manually revoke that specific token in the DB, preventing it from ever being used to generate a new access token. Here's how we create and store the refresh token in our database:

import { randomUUID } from 'node:crypto';

// JwtRefreshService
async create(payload: CreateRefreshTokenDto): Promise<string> {
  const tokenId = randomUUID(); // Unique ID for this token
  const refresh_token = this.jwtService.sign(
    { ...payload, jti: tokenId },
    {
      secret: this.configService.get('JWT_REFRESH_SECRET'),
      expiresIn: '7d',
    },
  );

  const hashed_refresh_token = await hash(refresh_token);

  await this.prisma.refreshToken.create({
    data: {
      id: tokenId,
      token: hashed_refresh_token,
      userId: payload.sub,
      absolute_limit_at: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000), // Hard session limit
      isRevoked: false,
    },
  });

  return refresh_token;
}
Enter fullscreen mode Exit fullscreen mode

Solving the "Stateless Revocation" Problem with Redis

The biggest flaw in JWTs is that they are stateless. If a user logs out, their Access Token is still technically valid until those 15 minutes are up.

To fix this without destroying performance, we use Redis.

Redis is an in-memory key-value store. Because it lives in RAM, it provides the sub-millisecond read/writes we need to check every single incoming request without slowing down the API. On logout, we "blacklist" the access token in Redis for the remainder of its lifespan. Our JwtAuthGuard then checks Redis on every request: if the token is in the blacklist, access is denied. When the user logs out, we calculate how long until the access token would naturally expire and blacklist it for exactly that duration:

// RedisService implementation
async blacklistToken(token: string, ttl: number): Promise<void> {
  await this.redis.set(`blacklist:${token}`, '1', { EX: ttl }); //
}

async isTokenBlacklisted(token: string): Promise<boolean> {
    const result = await this.redis.exists(`blacklist:${token}`);
    return result === 1;
 }

// JwtAuthGuard
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {
    // existing authorization logic

    // Check if the token was manually revoked (logout)
    const isBlacklisted = await this.redis.isTokenBlacklisted(token);
    if (isBlacklisted) return false;

    // return result
  }
}

// Logout logic
async logout(data: { userId: string; access_token: string; refresh_token: string }) {
  const access_token_payload = await this.jwtService.verifyAsync(data.access_token);
  const ttl = access_token_payload.exp - Math.floor(Date.now() / 1000);

  if (ttl > 0) {
    await this.redisService.blacklistToken(data.access_token, ttl); //
  }
  // ... proceed to revoke refresh token
}
Enter fullscreen mode Exit fullscreen mode

Refresh Token Rotation & Absolute Limits

What if a refresh token is stolen? An attacker could theoretically stay logged in for 7 days. We solve this with Refresh Token Rotation.

Every time a user uses a Refresh Token to get a new Access Token, we:

  1. Invalidate the old Refresh Token.
  2. Issue a brand-new Refresh Token.

The Absolute Time Limit

To prevent a session from being kept alive forever by rotation, we implement an Absolute Time Limit (e.g., 90 days). This is set at the initial login. Every time a token rotates, that original 90-day timestamp is carried forward. Once we hit that date, no more rotations are allowed—the user must log in with their password again.

// rotateRefreshToken logic
async rotateRefreshToken(payload: RotateRefreshTokenDto) {
  const { old_refresh_token_id, time_limit, sub, email, deviceInfo } = payload;

  await this.revokeRefreshToken(old_refresh_token_id); // Invalidate old

  const new_refresh_token = this.jwtService.sign(
        {
          email,
          sub,
          deviceInfo,
        },
        {
          secret: this.configService.get<string>('JWT_REFRESH_SECRET'),
          expiresIn: '7d',
        },
      );

  // Create new token record with the original time_limit
  await this.prisma.refreshToken.create({
    data: {
      token: await hash(new_refresh_token),
      userId: sub,
      absolute_limit_at: time_limit, // Carry forward absolute limit
      isRevoked: false,
    },
  });
}
Enter fullscreen mode Exit fullscreen mode
  async revokeRefreshToken(token_id: string) {
    try {
      await this.prisma.refreshToken.update({
        where: {
          id: token_id,
        },
        data: {
          isRevoked: true,
        },
      });
    } catch (error) {
      if (error instanceof Prisma.PrismaClientKnownRequestError) {
        if (error.code === 'P2025') {
          throw new NotFoundException('Token not found');
        }
      }
      throw error;
    }
  }
Enter fullscreen mode Exit fullscreen mode
  @Public()
  @UseGuards(JwtRefreshAuthGuard)
  @Post('refresh')
  async refresh(
    @Request()
    req: RequestType & {
      user: { email: string; id: string; refreshToken: string };
    },
    @Response({ passthrough: true }) res: ResponseType,
  ) {
    if (req.user) {
      const tokens = await this.authService.refreshTokens({
        userId: req.user.id ?? '',
        email: req.user.email ?? '',
        token: req.user.refreshToken ?? '',
      });

      res.cookie('titan_refresh_token', tokens?.refresh_token, {
        httpOnly: true,
        secure: true,
        sameSite: 'strict',
      });

      return { access_token: tokens?.access_token };
    }
  }
Enter fullscreen mode Exit fullscreen mode

Reuse Detection (The "Nuclear" Option)

If a token that was already revoked is used, it signals a potential theft. The system responds by revoking all tokens for that user.

// validateToken snippet
if (storedToken?.isRevoked) {
  // Nuke 'em
  await this.prisma.refreshToken.updateMany({
    where: { userId: payload.userId },
    data: { isRevoked: true }, // Revoke all active sessions for this user
  });
  throw new UnauthorizedException('Token reuse detected. Please login again');
}
Enter fullscreen mode Exit fullscreen mode

One thing to note is that while we return the access token to the user in the response body, we have to set the refresh token as a http-only cookie.

  @Post('login')
@UseGuards(LocalAuthGuard)
async login(
  @Request() req: RequestType & { user: UserEntity },
  @Response({ passthrough: true }) res: ResponseType,
) {
  const deviceInfo = req.headers['user-agent'] || 'Unknown Device';

  const { access_token, refresh_token } = await this.authService.login(
    req.user,
    deviceInfo,
  );

  // Securely set the Refresh Token as a cookie
  res.cookie('titan_refresh_token', refresh_token, {
    httpOnly: true, // Prevents JS from reading the cookie
    secure: true,   // Only sent over HTTPS
    sameSite: 'strict', // Protects against CSRF
    path: '/auth/refresh', // Optional: Only send cookie to the refresh endpoint
  });

  // Send the Access Token in the body for the frontend to store in memory
  return { access_token };
}
Enter fullscreen mode Exit fullscreen mode

The reason for this is to make the refresh token invisible to the client-side javascript. Even if an attacker successfully runs a script on your page, they cannot "read" the refresh token. We also set secure: true (ensuring it's only sent over HTTPS) and sameSite: strict to protect against CSRF attacks.

As for the access token, the frontend receives it in the JSON response and typically stores it in memory (a variable). While it's still technically vulnerable to XSS, because it isn't persisted in storage, it's much harder to exfiltrate, and its short 15-minute lifespan limits the damage if it is compromised


Conclusion

At the end of the day, writing code alone doesn't build a secure authentication system. We need to constantly balance security, speed, and user experience. By thinking like an attacker, we can turn standard JWTs into a fortified system using tools like Redis and techniques like Refresh Rotation. It takes a bit more effort to set up, but the peace of mind knowing you’ve accounted for edge cases like token theft or stale sessions is well worth the complexity, especially in sensitive applications.

Summary of our Security Workflow

  • Memory-Hard Hashing: Used Argon2 to ensure passwords are resistant to GPU-based cracking.

  • Dual-Token Architecture: Balanced security with short-lived (15 min) access tokens and long-lived (7 day) refresh tokens.

  • Real-time Revocation: Leveraged Redis for sub-millisecond access token blacklisting upon logout.

  • Session Lifecycle Management: Implemented 90-day absolute time limits to ensure no session lasts forever.

  • Automatic Breach Detection: Enabled Refresh Token Rotation with a "nuclear" reuse detection trigger that invalidates all user sessions if a stolen token is detected.

Technical Stack Overview

Tool Purpose Key Feature
NestJS Backend Framework Modular architecture and robust dependency injection.
Passport.js Auth Logic Industry-standard middleware for handling JWT strategies.
Argon2 Password and Token Hashing Memory-intensive hashing
Prisma Database ORM Type-safe interaction with our stateful Refresh Token storage.
Redis In-Memory Store Rapid read/write operations for stateless token revocation (blacklisting).
JWT Tokenization Secure, encoded payload delivery for AuthN and AuthZ.

Top comments (0)