Why Your NodeJs/NestJS JWT Authentication is Probably Broken

Olayinka AdeyemiOlayinka Adeyemi
10 min read

After years of building applications with both Laravel and NestJS, I've noticed a dangerous pattern among Node.js developers. Coming from Laravel's beautifully abstracted authentication system, many developers assume that slapping a JWT on their NestJS API makes it secure. This misconception has led to countless applications with fundamental security flaws.

Laravel handles most authentication complexity behind the scenes. You get session management, CSRF protection, and secure logout out of the box. But when you move to NestJS, you're building from scratch, and that's where the problems start.

What Many Developers Get Wrong

Let me show you what I’ve seen in many NestJS codebases:

// AuthService.ts
async login(credentials: LoginDto) {
  const user = await this.validateCredentials(credentials);
  const payload = { sub: user.id, email: user.email };

  return {
    access_token: this.jwtService.sign(payload, { expiresIn: '24h' })
  };
}

// AuthGuard.ts
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {
  canActivate(context: ExecutionContext) {
    return super.canActivate(context);
  }
}

This looks professional, follows JWT standards, and works perfectly in development. But the moment you deploy to production; Dear Bob, you've created a security nightmare.

Think about it: what happens when a user says their account is compromised? You can't revoke that token. It's valid until expiry, potentially giving attackers 24 hours of access. There's no server-side kill switch.

Even worse, if your JWT secret ever leaks (and secrets do leak, yours probably would too), every token in circulation becomes forgeable. An attacker can create tokens for any user, with any permissions, indefinitely.

This is an example JWT;

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImtleTIifQ.eyJzdWIiOiJ1c2VyLTEyMyIsInNlc3Npb25JZCI6InNlc3MtYWJjLTEyMyIsImlhdCI6MTcxMDAwMDAwMCwiZXhwIjoxNzEwMDAzNjAwfQ.9QKp7Fv6AxZ3C9cRZ2dAaLDcsXQMY7CZtOZmHZF91GE

If you’re like Bob and your JWT payload just contains the user’s ID and email, you can see how terribly simple it would be to generate a mock JWT using a leaked JWT secret that gives you unrestricted and untracked access to a user’s profile.

Your logout endpoint (if you’d dare have one with this interesting JWT approach) also probably looks like this:

@Post('logout')
async logout() {
  // What exactly happens here?
  return { message: 'Logged out successfully' };
}

This is pure security theater. The JWT remains valid until expiry. A malicious script or stolen token can continue accessing your API even after "logout."

How to Actually Secure Your Authentication

The solution isn't to abandon JWTs. They're still useful. But you need to stop treating them as complete authentication tokens and start using them as session references. Let me walk you through building a better authentication system.

First: Store Sessions Server-Side

The foundation of secure authentication is having server-side sessions. Here's a decent enough session schema you could use:

// entities/session.entity.ts
@Entity('sessions')
export class Session {
  @PrimaryGeneratedColumn('uuid')
  id: string;

  @Column()
  userId: string;

  @Column({ nullable: true })
  userAgent: string;

  @Column({ nullable: true })
  ipAddress: string;

  @Column({ type: 'timestamp' })
  expiresAt: Date;

  @Column({ default: false })
  revoked: boolean;

  @Column({ type: 'timestamp', nullable: true })
  lastActivity: Date;

  @CreateDateColumn()
  createdAt: Date;

  @UpdateDateColumn()
  updatedAt: Date;
}

Why all these fields? Well, when a user calls you panicking about a compromised account, you need to know exactly which devices they're logged in from. The IP address and user agent help you identify suspicious sessions. The revoked flag gives you that kill switch you’d then realize you desperately need.

Create Sessions That Actually Mean Something

Now your login process becomes more thoughtful:

// auth.service.ts
async login(credentials: LoginDto, request: Request) {
  const user = await this.validateCredentials(credentials);

  // Create server-side session first
  const session = await this.sessionRepository.save({
    userId: user.id,
    userAgent: request.headers['user-agent'],
    ipAddress: request.ip,
    expiresAt: new Date(Date.now() + 15 * 60 * 1000), // 15 minutes
    lastActivity: new Date()
  });

  // JWT now carries session reference, not user data
  const token = this.jwtService.sign({
    sub: user.id,
    sessionId: session.id,
    type: 'access'
  }, { expiresIn: '15m' });

  return {
    access_token: token,
    user: {
      id: user.id,
      email: user.email,
    }
  };
}

Notice I'm using 15-minute expiry instead of 24 hours. This might seem inconvenient, but it's actually brilliant. If someone steals your token, they have 15 minutes of access, not 24 hours. The shorter the window, the smaller the blast radius.
It’s best practice to add more fields to the JWT like iat and exp. iat in JWT stands for Issued At. It’s a standard claim defined by the JWT spec (RFC 7519) and represents the timestamp when the JWT was created, exp should obviously mean the expiry datetime of the JWT.
The principle is; the longer the JWT, the better.

Validate Sessions on Every Request

Here's where the magic happens. Your auth guard now checks both the JWT and the session:

// guards/session.guard.ts
@Injectable()
export class SessionGuard implements CanActivate {
  constructor(
    private readonly sessionService: SessionService,
    private readonly cacheManager: CacheManager
  ) {}

  async canActivate(context: ExecutionContext): Promise<boolean> {
    const request = context.switchToHttp().getRequest();
    const token = this.extractTokenFromHeader(request);

    if (!token) {
      throw new UnauthorizedException('No token provided');
    }

    try {
      const payload = this.jwtService.verify(token);
      const session = await this.getValidSession(payload.sessionId);

      if (!session) {
        throw new UnauthorizedException('Invalid session');
      }

      // Update last activity
      await this.updateSessionActivity(session.id);

      request.user = { ...payload, session };
      return true;
    } catch (error) {
      throw new UnauthorizedException('Invalid token');
    }
  }
}

This two-step verification is crucial. Even if someone has a valid JWT, if the session is revoked, they're locked out immediately. That's your kill switch right there.

Make Logout Actually Work

Now logout becomes meaningful:

// auth.service.ts
async logout(sessionId: string) {
  // Revoke the session
  await this.sessionRepository.update(sessionId, { 
    revoked: true 
  });

  // Clear from cache
  await this.cacheManager.del(`session:${sessionId}`);

  return { message: 'Successfully logged out' };
}

async logoutAll(userId: string) {
  // Revoke all user sessions
  await this.sessionRepository.update(
    { userId, revoked: false },
    { revoked: true }
  );

  // Clear all cached sessions for this user
  const sessions = await this.sessionRepository.find({
    where: { userId },
    select: ['id']
  });

  await Promise.all(
    sessions.map(session => 
      this.cacheManager.del(`session:${session.id}`)
    )
  );
}

When someone logs out, their session is actually revoked (you could also choose to delete the session or setup a cron job that handles that). The JWT becomes useless immediately. You can also implement "logout from all devices" which is incredibly valuable for security.

Cache for Performance

You might be thinking: "But now I'm hitting the database on every request!" That's where caching comes in:

// session.service.ts
@Injectable()
export class SessionService {
  constructor(
    @InjectRepository(Session)
    private sessionRepository: Repository<Session>,
    @Inject(CACHE_MANAGER)
    private cacheManager: Cache
  ) {}

  async validateSession(sessionId: string): Promise<Session | null> {
    const cacheKey = `session:${sessionId}`;

    // Try cache first
    let session = await this.cacheManager.get<Session>(cacheKey);

    if (!session) {
      // Database fallback
      session = await this.sessionRepository.findOne({
        where: { 
          id: sessionId, 
          revoked: false,
          expiresAt: MoreThan(new Date())
        }
      });

      if (session) {
        // Cache for 5 minutes
        await this.cacheManager.set(cacheKey, session, 300);
      }
    }

    return session;
  }
}

Most requests hit the cache, not the database. Performance stays fast, but you maintain the security benefits.

Handle Token Refresh Properly

With short-lived tokens, you need a refresh mechanism. This is probably how that would look:

// auth.service.ts
async refreshToken(refreshToken: string) {
  try {
    const payload = this.jwtService.verify(refreshToken, {
      secret: this.configService.get('JWT_REFRESH_SECRET')
    });

    const session = await this.validateSession(payload.sessionId);
    if (!session) {
      throw new UnauthorizedException('Invalid session');
    }

    // Generate new access token
    const newToken = this.jwtService.sign({
      sub: payload.sub,
      sessionId: session.id,
      type: 'access'
    }, { expiresIn: '15m' });

    return { access_token: newToken };
  } catch (error) {
    throw new UnauthorizedException('Invalid refresh token');
  }
}

The refresh token also references the session. If the session is revoked, refresh fails too.

Going Further; Key Rotation and Better Secret Management

For applications that handle sensitive data, you should implement key rotation. But first, let's talk about where NOT to store your secrets.

Stop Using Environment Variables for JWT Secrets

I see this everywhere:

# .env
JWT_SECRET=myverysecretkey123

It’s not advisory to do this, especially not on scale. Environment variables are visible to anyone with access to your server. Instead, use a proper secret management service. Here's how to integrate with AWS Parameter Store:

// services/secret-manager.service.ts
@Injectable()
export class SecretManagerService {
  private ssm: SSM;
  private secretCache = new Map<string, { value: string; expiry: number }>();

  constructor() {
    this.ssm = new SSM({
      region: process.env.AWS_REGION || 'us-east-1'
    });
  }

  async getSecret(parameterName: string): Promise<string> {
    const cached = this.secretCache.get(parameterName);
    if (cached && cached.expiry > Date.now()) {
      return cached.value;
    }

    try {
      const result = await this.ssm.getParameter({
        Name: parameterName,
        WithDecryption: true
      }).promise();

      const secret = result.Parameter?.Value;
      if (!secret) {
        throw new Error(`Secret ${parameterName} not found`);
      }

      // Cache for 5 minutes
      this.secretCache.set(parameterName, {
        value: secret,
        expiry: Date.now() + 300000
      });

      return secret;
    } catch (error) {
      throw new Error(`Failed to retrieve secret: ${error.message}`);
    }
  }
}

Implement Key Rotation

For high-security applications, optionally rotate your JWT signing keys regularly:

// services/jwt-key-manager.service.ts
@Injectable()
export class JwtKeyManagerService {
  private activeKeys = new Map<string, string>();
  private currentKeyId: string;

  constructor(
    private secretManager: SecretManagerService
  ) {
    this.loadKeys();
  }

  async loadKeys() {
    // Load current active keys from secret manager
    const keyIds = await this.secretManager.getSecret('/jwt/active-key-ids');
    const keyIdList = JSON.parse(keyIds);

    for (const keyId of keyIdList) {
      const key = await this.secretManager.getSecret(`/jwt/keys/${keyId}`);
      this.activeKeys.set(keyId, key);
    }

    this.currentKeyId = keyIdList[0]; // Most recent key
  }

  async rotateKeys() {
    const newKeyId = `key-${Date.now()}`;
    const newKey = this.generateSecureKey();

    // Store new key in secret manager
    await this.secretManager.storeSecret(`/jwt/keys/${newKeyId}`, newKey);

    // Update active keys list
    const activeKeyIds = [newKeyId, ...Array.from(this.activeKeys.keys())];
    await this.secretManager.storeSecret('/jwt/active-key-ids', 
      JSON.stringify(activeKeyIds.slice(0, 3)) // Keep last 3 keys
    );

    this.activeKeys.set(newKeyId, newKey);
    this.currentKeyId = newKeyId;

    // Schedule cleanup of old keys
    setTimeout(() => this.cleanupOldKeys(), 24 * 60 * 60 * 1000);
  }

  signToken(payload: any): string {
    const key = this.activeKeys.get(this.currentKeyId);
    return this.jwtService.sign(payload, {
      secret: key,
      header: { kid: this.currentKeyId }
    });
  }

  verifyToken(token: string): any {
    const header = this.jwtService.decode(token, { complete: true }).header;
    const keyId = header.kid;
    const key = this.activeKeys.get(keyId);

    if (!key) {
      throw new UnauthorizedException('Invalid token key');
    }

    return this.jwtService.verify(token, { secret: key });
  }

  private generateSecureKey(): string {
    return require('crypto').randomBytes(64).toString('hex');
  }
}

Key rotation protects you from long-term key compromise. Even if someone gets your current key, you can rotate it and invalidate all tokens signed with the old key.

You can add a kid (key ID) in the JWT header that’ll tell your application which key to use for verification. This allows you to maintain multiple keys during rotation periods.

What This Actually Gets You

When you implement this properly, you're not just adding complexity for the sake of it. You're solving real problems:

  • Immediate threat response: User reports compromise at 2 AM? You can revoke their session instantly, even if they have a valid JWT, not waiting for token expiry. The users can also do that on their end.

  • Breach containment: If your JWT secret gets compromised, you can rotate keys and invalidate all existing sessions. With plain JWTs, you'd have to wait for every token to expire naturally.

  • User empowerment: Users can see their active sessions, manage their devices, and logout from everywhere. They get the same control they expect from Google or GitHub.

  • Audit capabilities: You know exactly when and where users access your system. Security teams love this visibility.

  • Performance that scales: With proper caching, you're not sacrificing speed for security. Most requests hit Redis, not your database.

The Real Trade-offs

I'm not going to pretend this is as simple as stateless JWTs. You need more infrastructure: a database, Redis, secret management. Your authentication logic is more complex.

But here's what you get in return: actual security instead of security theater. The ability to respond to threats in real-time. User trust because they can actually control their sessions, modern applications need this level of security.

The good news is that once you implement this pattern, you can reuse it across every application you build. Create a proper authentication module, create a package around it or a starter repository, and you're done.

Also, Don't Forget the Basics

While it's common to store secrets in .env files during development, consider using a proper secret management service like AWS Parameter Store or HashiCorp Vault in production. These tools offer encryption at rest and make it easier to rotate sensitive keys like your JWT secret over time.

Also, implement proper rate limiting on your authentication endpoints. All the session management in the world won't help if someone can brute force your login endpoint.

The Bottom Line

JWTs are a tool, not a complete solution. They're great for what they do: carrying signed claims between services. But they were never meant to handle session management, user device tracking, or real-time security responses.

Stop treating authentication as a solved problem just because you have a JWT. Build it properly from the start, and you'll save yourself countless security headaches down the road.

Your users' data is worth more than the convenience of stateless tokens. Build systems that can actually protect it.

About the Author

I'm Olayinka Adeyemi, a full-stack engineer focused on building secure and scalable APIs using Laravel, NestJS, and modern web technologies. I’ve worked across consumer systems, microservices, and infrastructure tooling; and I write to document things I’ve battle-tested in real projects, and to share tips to help anyone learning tech.

Feel free to reach out or discuss improvements; I’m also available to collaborate on projects. Always happy to chat dev-to-dev.

Portfolio: theadeyemiolayinka.com

0
Subscribe to my newsletter

Read articles from Olayinka Adeyemi directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Olayinka Adeyemi
Olayinka Adeyemi

Gen-Z Full stack Engineer. Interested in Backend, Flutter, Physics and Web3.