Introduction: Securing User Login with JWT

Welcome back! In the previous lesson, you built a secure registration system that hashed passwords before saving them. Now it’s time to let users log in and access protected areas of your API.

But how do we remember users once they log in? That’s where JWT (JSON Web Token) comes in.

  • When a user logs in successfully, the server issues a signed JWT.
  • The client includes this token in the Authorization header of future requests.
  • The server verifies the token to confirm the user’s identity and role.

By the end of this lesson, you’ll have:

  • A login endpoint that validates credentials.
  • A system that issues JWTs for authenticated users.
  • A way for clients to access protected routes using those tokens.
How bcrypt Verifies Passwords

At registration time, you hashed the user’s password with bcrypt. Now during login, bcrypt needs to check whether the plain-text password the user entered matches the stored hash.

Here’s the interesting part: bcrypt hashes aren’t deterministic in the way you might expect. Even if two users both use "password123", their stored hashes will look completely different. That’s because each hash contains:

  • The salt (a random string generated when hashing).
  • The cost factor (how many rounds of hashing).
  • The final hash itself.

When verifying:

  1. bcrypt extracts the salt and cost factor from the stored hash string.
  2. It applies the same algorithm to the candidate password.
  3. If the result matches the stored hash, the password is correct.

This design means you don’t need to store salts in a separate column — everything is self-contained in the hash string.

That’s why verifyPassword works reliably:

if (!user || !verifyPassword(password, user.passwordHash)) {
  throw new UnauthorizedException('Invalid credentials');
}

Here, the hash itself tells bcrypt how to verify it, making the process both secure and convenient.

Implementing Login in the AuthService

The login method in AuthService is a small but critical piece of the authentication puzzle. Let’s break down the important design choices:

async login(username: string, password: string) {
  if (!username || !password) {
    throw new UnauthorizedException('Invalid credentials');
  }

  const user = this.db.getUsers().find((u) => u.username === username);
  if (!user || !verifyPassword(password, user.passwordHash)) {
    throw new UnauthorizedException('Invalid credentials');
  }

  const access_token = this.jwtService.sign({ sub: user.id, role: user.role });
  return { access_token };
}

Key points:

  • Fail fast → If either username or password is missing, the method immediately rejects.
  • Constant response → Notice how it throws the same error message whether the username is wrong or the password is wrong. This prevents attackers from learning which part was incorrect.
  • JWT payload → The token contains just the user ID (sub) and role, not sensitive info like the full user object.
  • Token signing → Uses JwtService.sign with the secret key from AuthModule, ensuring the token can later be verified.

This design balances security, performance, and simplicity.

Sign up
Join the 1M+ learners on CodeSignal
Be a part of our community of 1M+ users who develop and demonstrate their skills on CodeSignal