Skip to main content

Welcome to a Grasp lesson

Create a free account to keep studying and create your own study path.

Lesson 70

User Authentication with JWTs

Recommended resources

Hello! In our last lesson, you mastered the critical first step of secure user registration: hashing and salting passwords with bcrypt. We established that passwords should never be stored in plaintext and that bcrypt provides a robust, one-way function to protect them.

Today, we'll build upon that foundation to create the complete user authentication flow. The goal for this lesson is to implement user registration and login endpoints that issue a JSON Web Token (JWT) upon success. This is where the theory of JWTs and the practice of password security come together to form the backbone of your application's authentication system. We'll build the two most important routes for any user-based application: /register and /login.

Let's start with a high-level view of the process we're about to build.

JWT Authentication Flow Diagram
Caption: This diagram shows the complete client-server interaction for registration, login, and accessing protected resources. Today, we are focusing on implementing the server-side logic for the "User Registration" and "User Login" flows.

The Complete Registration Endpoint

In the previous lesson, we focused on the hashing part of registration. Now, let's build the full endpoint. The logic is as follows:

  1. Receive Data: Get the user's name, email, and password from the request body.
  2. Validate Input: Check if all required fields are provided.
  3. Check for Duplicates: Query the database to ensure no user already exists with the given email. This prevents duplicate accounts.
  4. Hash Password: If the user is new, use bcrypt.hash() to securely hash the provided password.
  5. Create User: INSERT the new user's details (including the hashed password) into your users table.
  6. Respond: Send a success response. It's common practice to send back some of the created user's data (like ID and email) but never the password hash.

For this lesson, we'll implement these steps and also generate and send the token immediately upon registration, which is a common pattern for a smooth user experience.


The Login Endpoint and Password Verification

The login endpoint is where we verify a user's identity. This process introduces a new, crucial bcrypt function: bcrypt.compare().

The logic for the /login route is:

  1. Receive Credentials: Get the user's email and password from the request body.
  2. Find User: Query the database to find a user matching the provided email. If no user is found, the login fails.
  3. Compare Passwords: If a user is found, use bcrypt.compare(plainTextPassword, hashedPasswordFromDB). This function securely compares the password submitted during login with the hash stored in your database. It automatically handles the salt extraction from the hash.
  4. Issue Token: If bcrypt.compare() resolves to true, the password is correct. You will then generate a JWT using the jsonwebtoken library.
  5. Send Token: Send the JWT back to the client. A highly secure method for this is to use an HttpOnly cookie.

Implementation Guide

The following video provides an excellent, concise walkthrough of building both the registration and login endpoints using Node.js, Express, and PostgreSQL, which perfectly matches our stack. It demonstrates every step we've just discussed.

PERN Stack Authentication Tutorial | PostgreSQL, Express.js, React, Node.js

The video 'PERN Stack Authentication Tutorial' by Web Dev Bey demonstrates exactly how to build the registration and login logic we've just outlined. It covers password hashing, database queries, JWT generation, and sending the token in a secure cookie.

Please watch the following two segments: Registration Endpoint (15:14 - 24:06): This part covers the entire /register logic. Pay close attention to how it checks for existing users, hashes the password, inserts the new user, and then generates and sends the JWT. Login Endpoint (24:06 - 26:31): This segment implements the /login route. Focus on how it finds the user and uses bcrypt.compare to verify the password before issuing the token.

Code Summary: Key Components

To help you connect the video to the concepts, here are the essential code snippets for the core logic. You'll need to install the jsonwebtoken and cookie-parser packages:

npm install jsonwebtoken cookie-parser

Registration (/register)

// In your routes/controller file
import bcrypt from 'bcryptjs'; // Using bcryptjs as in the video
import jwt from 'jsonwebtoken';
import pool from '../config/db.js'; // Your DB connection pool

// ... inside your router.post('/register', async (req, res) => { ... })

// 1. Check if user exists
const userExists = await pool.query("SELECT * FROM users WHERE email = $1", [email]);
if (userExists.rows.length > 0) {
  return res.status(400).json({ message: "User already exists." });
}

// 2. Hash password
const salt = await bcrypt.genSalt(10);
const hashedPassword = await bcrypt.hash(password, salt);

// 3. Insert new user
const newUser = await pool.query(
  "INSERT INTO users (name, email, password) VALUES ($1, $2, $3) RETURNING id, name, email",
  [name, email, hashedPassword]
);

// 4. Generate JWT
const token = jwt.sign({ id: newUser.rows[0].id }, process.env.JWT_SECRET, {
  expiresIn: '30d',
});

// 5. Send token in cookie and user data in response
res.cookie('token', token, {
  httpOnly: true, // Prevents client-side JS from accessing the cookie
  secure: process.env.NODE_ENV === 'production', // Only send over HTTPS in production
  maxAge: 30 * 24 * 60 * 60 * 1000 // 30 days
});

res.status(201).json(newUser.rows[0]);

Login (/login)

// ... inside your router.post('/login', async (req, res) => { ... })

// 1. Find user by email
const user = await pool.query("SELECT * FROM users WHERE email = $1", [email]);
if (user.rows.length === 0) {
  return res.status(401).json({ message: "Invalid credentials" }); // Generic message
}

// 2. Compare passwords
const storedUser = user.rows[0];
const isMatch = await bcrypt.compare(password, storedUser.password);

if (!isMatch) {
  return res.status(401).json({ message: "Invalid credentials" });
}

// 3. Generate and send JWT (same as in registration)
const token = jwt.sign({ id: storedUser.id }, process.env.JWT_SECRET, {
  expiresIn: '30d',
});

res.cookie('token', token, {
  httpOnly: true,
  secure: process.env.NODE_ENV === 'production',
  maxAge: 30 * 24 * 60 * 60 * 1000
});

res.status(200).json({
  id: storedUser.id,
  name: storedUser.name,
  email: storedUser.email,
});
Test your understanding!

A developer on your team writes the following code for the login route:

// ... after finding the user in the database
const salt = await bcrypt.genSalt(10);
const hashedAttempt = await bcrypt.hash(password, salt);

if (hashedAttempt === user.password) {
  // Passwords match! Issue JWT.
}

This code will almost never work, even with the correct password. Why?

Show answer

The problem is that bcrypt.hash() (or bcrypt.genSalt()) generates a new, random salt every time it's called. Hashing the same password twice with different salts will produce two completely different hashes. The hashedAttempt will never match the user.password stored in the database.

The correct way is to use bcrypt.compare(plainTextPassword, storedHash). This function is designed for this exact purpose: it extracts the original salt from the storedHash and uses it to hash the plainTextPassword, then compares the results.


Testing Your Endpoints

Building APIs is only half the battle; you need to test them to ensure they work. API clients like Postman or Thunder Client (a VS Code extension) are essential tools for this.

The same video resource has a clear demonstration of how to test the /register and /login endpoints you've just built.

PERN Stack Authentication Tutorial | PostgreSQL, Express.js, React, Node.js

This final segment from the 'PERN Stack Authentication Tutorial' shows you how to use Postman to send POST requests to your new endpoints and verify they work correctly.

Watch from 33:51 to 37:33. Observe how to set up a POST request, add a JSON body with the user's credentials, and check the response from the server.

This step is crucial for debugging and confirming that user creation, password comparison, and token generation are all functioning as expected.


Conclusion

Excellent work! You have now implemented the core of a secure, token-based authentication system. By combining robust password hashing with JWT issuance, you've built the foundation for protecting user accounts and controlling access to your application.

Key Takeaways:

  • Registration Flow: The registration endpoint must validate input, check for existing users, hash the password with bcrypt.hash(), and store the new user record.
  • Login Flow: The login endpoint must find the user, securely verify their password using bcrypt.compare(), and only then proceed.
  • bcrypt.compare(): This is the correct and only way to verify a password against a bcrypt hash.
  • JWT Issuance: Upon successful authentication (or registration), use jsonwebtoken.sign() to create a token containing a non-sensitive identifier like the user's ID.
  • Secure Token Delivery: Sending the JWT in an HttpOnly cookie is a secure method that helps protect the token from being stolen by client-side scripts (XSS attacks).

Preview of the Next Lesson:

We can now register a user and give them a token when they log in. But what can they do with that token? Right now, nothing! The token is being sent, but our server isn't using it to protect anything.

In the next lesson, we will create protected route middleware in Express that verifies JWTs from incoming requests. This middleware will act as a gatekeeper, checking for a valid token on specific routes and allowing access only to authenticated users.