Build a JWT Authentication API

Build a secure REST API with JWT auth, password hashing, and refresh tokens.

Introduction

30 Second Summary

Every time you log into an app, something behind the scenes proves you are who you say you are. That "something" is often a token, a tiny piece of cryptographic proof that travels with every request you make.

In this project, you will build a complete authentication API that issues JSON Web Tokens to users, protects routes from unauthorized access, and handles token refresh so users stay logged in securely.

What You'll Build

A REST API built with Node.js and Express that handles user registration, login, route protection, and token refresh using JWTs.

By the end of this project, you'll have:

  • A working Express server with signup, login, refresh, and logout endpoints that handle the full authentication lifecycle.
  • An auth middleware that verifies JWTs and blocks unauthorized requests from reaching protected routes.
  • A complete access token + refresh token pattern with short-lived access tokens and longer-lived refresh tokens, the same pattern used in production OAuth 2.0 systems.
  • Secret Mission: Add refresh token rotation with reuse detection to prevent stolen token attacks.

Are there any prerequisites?

You should be comfortable with basic JavaScript concepts like variables, functions, objects, arrays, and if/else statements. No prior experience with Express or JWTs is needed.

Set Up Your Development Environment

Before you can build an authentication API, you need the right tools in place. Node.js runs JavaScript outside the browser, and Express is the framework you'll use to handle HTTP requests.

In this step, you'll install Node.js, set up VS Code as your editor, create a project folder, install dependencies, and get your server running with basic configuration.

In this step, get ready to:
  • Install Node.js, VS Code, and the Thunder Client extension.
  • Initialize the project folder and install dependencies.
  • Configure environment variables and start the Express server.
Install Node.js and VS Code

Node.js lets you run JavaScript on your computer (outside a browser). You'll use it to run your Express server.

  • Download and install Node.js v24 LTS from the official download page.
  • The installer detects your operating system automatically. Follow the prompts and accept the defaults.

Once the install finishes, verify it worked by checking the version.

  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type Terminal (macOS) or Command Prompt (Windows) and press Enter to open it.
  • Check your Node.js version by running this command:
node --version

✔️ I see version 24 or higher

You're all set with Node.js. Move on to the VS Code installation below.

ⓧ I see an older version

You have Node.js installed, but the version is too old for this project. Express 5.2.1 requires Node.js 18 or higher, and this project uses features from v24.

  • Go to the Node.js download page and download the v24 LTS installer.
  • Run the installer. It will replace your older version.
  • Close and reopen your terminal, then run node --version again to confirm it shows v24 or higher.

ⓧ Command not found

Node.js is not installed yet.

  • Go to the Node.js download page and download the v24 LTS installer for your operating system.
  • Run the installer and accept all defaults.
  • Close and reopen your terminal, then run node --version again to confirm it shows v24 or higher.
  • Also check that npm (the Node package manager) was installed alongside Node.js by running:
npm --version

You should see a version number printed. If you see a number, npm is ready to go.

Next, install VS Code if you don't already have it.

  • Download VS Code from https://code.visualstudio.com and run the installer.
  • Once installed, press Cmd+Space (macOS) or the Windows key (Windows) to open search.
  • Type Visual Studio Code and press Enter to open it.

Finally, install the Thunder Client extension so you can test API endpoints directly inside VS Code.

  • In VS Code, press Cmd+Shift+X (macOS) or Ctrl+Shift+X (Windows) to open the Extensions panel.
  • Type Thunder Client in the search box.
  • Click Install on the Thunder Client extension by Ranga Vadhineni.

What is Thunder Client?

Thunder Client is a lightweight REST API testing tool built into VS Code. It lets you send HTTP requests (GET, POST, etc.) to your server without leaving your editor.

You'll use it throughout this project to test your signup, login, and protected route endpoints.

Initialize the project and install dependencies

Now you'll create a project folder, initialize it with npm, and install the four packages your API needs.

  • In VS Code, open a terminal by pressing Ctrl+` (backtick key, same on both macOS and Windows).
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder and move into it by running these commands:
mkdir jwt-auth-api
cd jwt-auth-api
  • Initialize a new Node.js project by running:
npm init -y

What does this command do?

npm init -y creates a package.json file with default settings. This file tracks your project's name, version, and installed dependencies.

  • Install the four project dependencies by running:
npm install express@5.2.1 jsonwebtoken@9.0.3 bcryptjs@3.0.3 dotenv@16.5.0

What are these packages?

  • express is the web framework that handles HTTP requests and routing.
  • jsonwebtoken signs and verifies JSON Web Tokens (the core of this project).
  • bcryptjs hashes passwords so they're never stored in plain text.
  • dotenv loads environment variables from a .env file into your app.

You should see output showing the packages being downloaded. Once it finishes, a node_modules folder and package-lock.json file appear in your project.

Install command failing?

  • Make sure you're inside the jwt-auth-api folder. Run pwd (macOS) or cd (Windows) to check your current location.
  • If you see permission errors, try closing and reopening VS Code, then re-run the command.

Help me fix this npm install error

Now create the file structure your project needs.

  • Create the routes and middleware directories, plus the four project files, by running these commands:
mkdir routes
mkdir middleware
touch server.js routes/auth.js middleware/auth.js .env

On Windows?

If touch doesn't work, use this instead:

mkdir routes
mkdir middleware
New-Item server.js, routes/auth.js, middleware/auth.js, .env
  • Confirm the files were created by running:
ls

You should see server.js, .env, node_modules, package.json, package-lock.json, routes, and middleware listed.

Configure environment variables and server boilerplate

Your server needs secret keys to sign tokens and a port number to listen on. You'll store these in a .env file so they stay separate from your code.

  • Open your project in VS Code by clicking File in the top menu bar, then Open Folder.
  • Select the jwt-auth-api folder on your Desktop, then click Open.
  • In the Explorer sidebar on the left, click .env to open it.
  • Add the following environment variables to .env:
PORT=3000
ACCESS_TOKEN_SECRET=my-super-secret-access-key-change-in-production
REFRESH_TOKEN_SECRET=my-super-secret-refresh-key-change-in-production

What are these secrets?

ACCESS_TOKEN_SECRET is the key used to sign short-lived access tokens. REFRESH_TOKEN_SECRET is a separate key for signing refresh tokens.

Using different secrets for each token type means a stolen access token can't be used to forge a refresh token. In production, you'd replace these with long, random strings.

  • Save .env by pressing Cmd+S (macOS) or Ctrl+S (Windows).

Next, add placeholder content to the two module files so the server can import them without errors.

  • Open routes/auth.js in the Explorer sidebar and add this code:
// Placeholder - auth routes to be implemented in Steps 2-5
const express = require('express');
const router = express.Router();

module.exports = router;
  • Save routes/auth.js.
  • Open middleware/auth.js and add this code:
// Placeholder - auth middleware to be implemented in Step 4
function authenticateToken(req, res, next) {
  next();
}

module.exports = { authenticateToken };
  • Save middleware/auth.js.

Why do these files need exports?

Your server.js imports from both files using require(). Express expects app.use() to receive a valid middleware function or Router. These minimal exports prevent crashes while you build out the real logic in later steps.

Now for the main event. Your server.js file is the entry point that starts Express and wires everything together.

  • Open server.js in the Explorer sidebar and add the following code:
require('dotenv').config();
const express = require('express');
const authRoutes = require('./routes/auth');
const { authenticateToken } = require('./middleware/auth');

const app = express();
const PORT = process.env.PORT || 3000;

// Parse incoming JSON request bodies
app.use(express.json());

// Mount auth routes at /auth
app.use('/auth', authRoutes);

app.listen(PORT, () => {
  console.log(`Server running on http://localhost:${PORT}`);
});

What does this code do?

  • require('dotenv').config() loads your .env variables into process.env so your app can read them.
  • express.json() is middleware that parses JSON request bodies (needed for signup and login requests).
  • app.use('/auth', authRoutes) mounts all auth-related routes under the /auth path prefix.
  • app.listen() starts the server on port 3000 and prints a confirmation message.
  • Save server.js.
  • Start the server by running this command in your terminal:
node server.js

You should see this message printed in your terminal:

Server running on http://localhost:3000

Server not starting?

  • If you see Cannot find module, make sure routes/auth.js and middleware/auth.js exist and have the export code from above.
  • If you see SyntaxError, double check that your server.js uses backticks (`) around the template string in console.log, not regular quotes.
  • If port 3000 is already in use, stop any other servers running on that port or change PORT in your .env file.

Help me fix my server startup error

✔️ Awesome, I've got everything!

Great. Make sure all four files are saved before moving on.

ⓧ I'd like to double check the full code

Here's what each file should look like at this point.

PORT=3000
ACCESS_TOKEN_SECRET=my-super-secret-access-key-change-in-production
REFRESH_TOKEN_SECRET=my-super-secret-refresh-key-change-in-production
require('dotenv').config();
const express = require('express');
const authRoutes = require('./routes/auth');
const { authenticateToken } = require('./middleware/auth');

const app = express();
const PORT = process.env.PORT || 3000;

app.use(express.json());

app.use('/auth', authRoutes);

app.listen(PORT, () => {
  console.log(`Server running on http://localhost:${PORT}`);
});
// Placeholder - auth routes to be implemented in Steps 2-5
const express = require('express');
const router = express.Router();

module.exports = router;
// Placeholder - auth middleware to be implemented in Step 4
function authenticateToken(req, res, next) {
  next();
}

module.exports = { authenticateToken };

Your development environment is ready and your server is alive. Next up, you'll build the user registration endpoint with secure password hashing.

Build User Registration with Password Hashing

Your server is running, but it does not do anything useful yet. The first real feature any authentication system needs is user registration.

In this step, you will build a signup endpoint that accepts an email and password, hashes the password so it is never stored in plaintext, and saves the user to an in-memory store. You will also add input validation to reject bad requests before they reach your business logic.

In this step, get ready to:
  • Create the user store and signup endpoint with password hashing.
  • Add input validation and duplicate email checking.
  • Test the full signup flow with Thunder Client.
Create the user store and signup endpoint

Every authentication system needs somewhere to store user credentials. In a production app this would be a database, but for learning the JWT concepts you will use a simple in-memory array. The pattern (find user, compare hash, sign token) is identical regardless of storage backend.

  • Open routes/auth.js in VS Code.
  • Select all the placeholder text and replace it with this module setup code:
const express = require('express');
const jwt = require('jsonwebtoken');
const bcrypt = require('bcryptjs');

const router = express.Router();

// In-memory store for users (simulates a database)
const users = [];
// Store for refresh tokens (used in later steps)
let refreshTokens = [];

What does this code do?

The three require() calls import Express for routing, jsonwebtoken for signing tokens (used in later steps), and bcryptjs for hashing passwords.

The users array acts as your database. The refreshTokens array will store refresh tokens in Step 5.

Now you need the signup route handler. This is the core logic that receives a request, validates the input, hashes the password, and stores the user.

  • Add the signup route and module export below the refreshTokens line by pasting this code:
router.post('/signup', async (req, res) => {
  const { email, password } = req.body;

  // Reject requests missing required fields
  if (!email || !password) {
    return res.status(400).json({ error: 'Email and password are required' });
  }

  // Check if a user with this email already exists
  const existingUser = users.find(u => u.email === email);
  if (existingUser) {
    return res.status(409).json({ error: 'User already exists' });
  }

  // Hash the password before storing (never store plaintext)
  const hashedPassword = await bcrypt.hash(password, 10);
  const user = { email, password: hashedPassword };
  users.push(user);

  res.status(201).json({ message: 'User created', email: user.email });
});

module.exports = router;

What does this code do?

  • const { email, password } = req.body extracts the email and password from the JSON request body.
  • The first if block returns a 400 status if either field is missing. This is input validation.
  • users.find() checks for duplicate emails and returns 409 (Conflict) if found.
  • bcrypt.hash(password, 10) hashes the password with 10 salt rounds. The 10 controls how computationally expensive the hash is to produce.
  • The hashed user object is pushed to the users array and a 201 (Created) response is returned.
  • Save routes/auth.js.
  • In your terminal, stop the server with Ctrl+C and restart it by running:
node server.js
  • You should see Server running on http://localhost:3000 in the terminal. This confirms the updated route file loaded without errors.

Server won't start?

  • Check for a syntax error message in the terminal. A missing comma, bracket, or parenthesis in routes/auth.js is the most common cause.
  • Make sure you deleted the original placeholder comment before pasting the new code.
  • Confirm the file ends with module.exports = router; so that server.js can import the router.

Why won't my server start after updating routes/auth.js?

Test the signup endpoint with Thunder Client

Your signup code handles three scenarios: a successful registration, a request with missing fields, and a duplicate email. You will test all three to confirm the validation and hashing work correctly.

  • Click the Thunder Client icon (lightning bolt) in the VS Code Activity Bar on the left side.
  • Click New Request.
  • Change the HTTP method dropdown from GET to POST.
  • Enter http://localhost:3000/auth/signup in the URL field.
  • Click the Body tab below the URL field.
  • Select JSON as the body type.
  • Paste the following JSON into the body area:
{
  "email": "test@example.com",
  "password": "mypassword123"
}
  • Click Send.

You should see a 201 Created response with a JSON body containing "message": "User created" and the email you sent.

Now test the validation. Try sending the same request again without changing anything.

  • Click Send again with the same email.

This time you should see a 409 Conflict response with "error": "User already exists". Your duplicate detection is working.

  • For the final test, clear the JSON body and replace it with an empty object:
{}
  • Click Send.

You should see a 400 Bad Request response with "error": "Email and password are required". All three scenarios work correctly.

Getting a 404 or connection error?

  • Make sure your server is still running in the terminal. If you see no output or a prompt, restart it with node server.js.
  • Check the URL is exactly http://localhost:3000/auth/signup. A missing /auth prefix will return a 404.
  • Make sure the method is set to POST, not GET. A GET request to this route will also return a 404.

Why is Thunder Client returning an error when I send my signup request?

Understand why we hash passwords

Look at the successful signup response. Notice that the response only shows the email, not the password. But what does the stored user object actually look like?

  • Add a temporary console.log to see the stored user. In routes/auth.js, find this line inside the signup route:
  res.status(201).json({ message: 'User created', email: user.email });
  • Add this line directly above it:
  console.log('Stored user:', user);
  • Save the file, restart the server with Ctrl+C then node server.js.
  • In Thunder Client, send the signup request again with a new email (e.g. change test@example.com to test2@example.com since the server restarted and cleared the in-memory store).
  • Check your terminal output.

You should see something like: Stored user: { email: 'test2@example.com', password: '$2a$10$...' }. The password field is a long, scrambled string instead of the plaintext password you sent.

Why hash passwords with bcrypt?

Bcrypt is a one-way hashing algorithm. You can turn a password into a hash, but you cannot reverse the hash back to the original password. Even if your database leaks, attackers cannot recover user passwords.

The number 10 (salt rounds) controls how computationally expensive the hash is. Higher values are more secure but slower. 10 is the standard balance for most applications.

  • Remove the console.log('Stored user:', user); line you added. It was just for inspection.
  • Save routes/auth.js.

✔️ Awesome, I've got everything!

Great. Make sure you have saved routes/auth.js and removed the temporary console.log line.

ⓧ I'd like to double check the full code

Here is the complete routes/auth.js file at this point:

const express = require('express');
const jwt = require('jsonwebtoken');
const bcrypt = require('bcryptjs');

const router = express.Router();

const users = [];
let refreshTokens = [];

router.post('/signup', async (req, res) => {
  const { email, password } = req.body;

  if (!email || !password) {
    return res.status(400).json({ error: 'Email and password are required' });
  }

  const existingUser = users.find(u => u.email === email);
  if (existingUser) {
    return res.status(409).json({ error: 'User already exists' });
  }

  const hashedPassword = await bcrypt.hash(password, 10);
  const user = { email, password: hashedPassword };
  users.push(user);

  res.status(201).json({ message: 'User created', email: user.email });
});

module.exports = router;

Your signup endpoint is working. Users can register, passwords are hashed securely, and bad requests are rejected with clear error messages. Next up, you will build the login endpoint that verifies credentials and issues JWTs.

Implement Login and JWT Signing

Your signup endpoint lets users create accounts, but they have no way to prove their identity on future requests. The login endpoint solves this by verifying credentials and issuing a token the user can present instead of their password.

In this step, you'll build a login route that checks the user's password against the stored hash, then signs two JSON Web Tokens. One is a short-lived access token for immediate use. The other is a longer-lived refresh token for getting new access tokens without re-entering credentials.

In this step, get ready to:
  • Build a login endpoint that validates credentials and signs an access token.
  • Issue a refresh token alongside the access token using a separate secret.
  • Test the full login flow with Thunder Client and decode the token at jwt.io.
Build the login endpoint

The login endpoint needs to do three things: find the user by email, verify their password against the stored hash, and sign a JWT if everything checks out.

  • In routes/auth.js, find the closing line module.exports = router; at the bottom of the file.
  • Add the following login route above that line:
router.post('/login', async (req, res) => {
  const { email, password } = req.body;

  // Reject requests missing required fields
  if (!email || !password) {
    return res.status(400).json({ error: 'Email and password are required' });
  }

  // Look up the user by email
  const user = users.find(u => u.email === email);
  if (!user) {
    return res.status(401).json({ error: 'Invalid credentials' });
  }

  // Compare the plaintext password against the stored hash
  const validPassword = await bcrypt.compare(password, user.password);
  if (!validPassword) {
    return res.status(401).json({ error: 'Invalid credentials' });
  }

  // Sign a short-lived access token (15 minutes)
  const accessToken = jwt.sign(
    { sub: user.email },
    process.env.ACCESS_TOKEN_SECRET,
    { algorithm: 'HS256', expiresIn: '15m' }
  );

  // Sign a longer-lived refresh token (7 days)
  const refreshToken = jwt.sign(
    { sub: user.email },
    process.env.REFRESH_TOKEN_SECRET,
    { algorithm: 'HS256', expiresIn: '7d' }
  );

  // Store the refresh token so we can validate it later
  refreshTokens.push(refreshToken);

  res.json({ accessToken, refreshToken });
});

What does this code do?

  • Input validation rejects requests missing email or password with a 400 status.
  • bcrypt.compare() hashes the incoming password and checks it against the stored hash. Both the "user not found" and "wrong password" cases return the same generic "Invalid credentials" message so attackers can't tell which emails exist.
  • jwt.sign() creates a signed token containing the user's email as the sub (subject) claim. The server can later verify this signature without storing session data.
  • The access token expires in 15 minutes ('15m'). The refresh token uses a different secret and lasts 7 days ('7d').
  • Save routes/auth.js.
  • Restart your server by pressing Ctrl+C in the terminal to stop it, then running:
node server.js
  • You should see Server running on http://localhost:3000 in the terminal output.

Seeing a syntax error on startup?

  • Make sure the new router.post('/login', ...) block is placed ABOVE module.exports = router; and BELOW the closing }); of the signup route.
  • Check that every opening brace { has a matching closing brace } and every opening parenthesis has a closing one.

Still stuck? Ask for help.

Understand the refresh token pattern

You may have noticed the login handler signs two separate tokens instead of one. This is the access token + refresh token pattern that production authentication systems use.

Why two tokens instead of one?

Short-lived access tokens limit damage from stolen tokens. If an attacker intercepts your access token, it only works for 15 minutes. Without a short expiry, a stolen token could grant permanent access.

Refresh tokens let users stay logged in without re-entering credentials every 15 minutes. When the access token expires, the client sends the refresh token to get a new access token. This keeps the user experience smooth while maintaining security.

Why use a different secret for each token?

The access token is signed with ACCESS_TOKEN_SECRET and the refresh token with REFRESH_TOKEN_SECRET. Separate secrets mean a token signed for one purpose cannot be accepted for the other. An access token can never be used as a refresh token, even if an attacker tries.

Test the login flow

Time to verify the login endpoint works end-to-end. You'll first create a user, then log in with those credentials and inspect the returned tokens.

  • Open Thunder Client by clicking the lightning bolt icon in the VS Code sidebar.
  • Click **New Request**.
  • Set the method to POST and enter this URL:
http://localhost:3000/auth/signup
  • Click the **Body** tab, then select **JSON**.
  • Paste this JSON body:
{
  "email": "test@example.com",
  "password": "mypassword123"
}
  • Click **Send**. You should see a 201 response with "message": "User created".

Now test the login endpoint with the same credentials.

  • Create a new request in Thunder Client by clicking **New Request** again.
  • Set the method to POST and enter this URL:
http://localhost:3000/auth/login
  • Click the **Body** tab, select **JSON**, and paste the same JSON body:
{
  "email": "test@example.com",
  "password": "mypassword123"
}
  • Click **Send**.
  • You should see a 200 response containing both an accessToken and a refreshToken. Both are long encoded strings.

Getting 401 Invalid credentials?

  • Make sure you signed up first. The in-memory store resets every time you restart the server, so if you restarted between signup and login, sign up again.
  • Check that the email and password in your login request match exactly what you used for signup (case-sensitive).

Help me debug my login response.

Now decode the access token to see what's inside it.

  • Copy the accessToken value from the response (the long string between the quotes).
  • Open your browser and go to https://jwt.io.
  • Paste the token into the **Encoded** field on the left side of the page.
  • Look at the **Decoded** section on the right. You should see three parts: the header showing "alg": "HS256", the payload showing your email in the sub field, and an exp timestamp representing the 15-minute expiration.

What am I seeing at jwt.io?

A JWT has three parts separated by dots: header, payload, and signature. The header declares the algorithm (HS256). The payload carries claims like sub (who the token belongs to) and exp (when it expires).

The payload is base64-encoded, not encrypted. Anyone can read it. The signature is what proves the token was issued by your server and hasn't been tampered with.

✔️ Awesome, I've got everything!

Great. Double check you've saved routes/auth.js and your server is still running.

ⓧ I'd like to double check the full code

Here's the complete routes/auth.js file at this point:

const express = require('express');
const jwt = require('jsonwebtoken');
const bcrypt = require('bcryptjs');

const router = express.Router();

const users = [];
let refreshTokens = [];

router.post('/signup', async (req, res) => {
  const { email, password } = req.body;

  if (!email || !password) {
    return res.status(400).json({ error: 'Email and password are required' });
  }

  const existingUser = users.find(u => u.email === email);
  if (existingUser) {
    return res.status(409).json({ error: 'User already exists' });
  }

  const hashedPassword = await bcrypt.hash(password, 10);
  const user = { email, password: hashedPassword };
  users.push(user);

  res.status(201).json({ message: 'User created', email: user.email });
});

router.post('/login', async (req, res) => {
  const { email, password } = req.body;

  if (!email || !password) {
    return res.status(400).json({ error: 'Email and password are required' });
  }

  const user = users.find(u => u.email === email);
  if (!user) {
    return res.status(401).json({ error: 'Invalid credentials' });
  }

  const validPassword = await bcrypt.compare(password, user.password);
  if (!validPassword) {
    return res.status(401).json({ error: 'Invalid credentials' });
  }

  const accessToken = jwt.sign(
    { sub: user.email },
    process.env.ACCESS_TOKEN_SECRET,
    { algorithm: 'HS256', expiresIn: '15m' }
  );

  const refreshToken = jwt.sign(
    { sub: user.email },
    process.env.REFRESH_TOKEN_SECRET,
    { algorithm: 'HS256', expiresIn: '7d' }
  );

  refreshTokens.push(refreshToken);

  res.json({ accessToken, refreshToken });
});

module.exports = router;

Your API now authenticates users and hands out tokens. Next up, you'll build middleware that actually checks those tokens and protects routes from unauthorized access.

Protect Routes with Auth Middleware

Your API can register users and issue tokens. But without a gatekeeper checking those tokens on incoming requests, anyone can access your endpoints.

In this step, you will build authentication middleware that verifies JWTs before allowing access to protected routes. This is the core pattern behind stateless auth in production APIs.

In this step, get ready to:
  • Write middleware that reads and verifies JWTs from the Authorization header.
  • Create a protected route that only authenticated users can access.
  • Test with valid, invalid, and tampered tokens to confirm the middleware rejects bad requests.
Write the authentication middleware

Middleware in Express is a function that runs before your route handler. It can inspect the request, reject it, or pass it along to the next function in the chain.

Your auth middleware will extract a JWT from the request's Authorization header, verify its signature, and either allow or block the request.

  • In VS Code, open middleware/auth.js.
  • Delete the placeholder comment and replace the entire file with the following code:
const jwt = require('jsonwebtoken');

// Middleware that checks for a valid JWT before allowing access
function authenticateToken(req, res, next) {
  // Read the Authorization header (format: "Bearer <token>")
  const authHeader = req.headers['authorization'];
  const token = authHeader && authHeader.split(' ')[1];

  // If no token was provided, block the request
  if (!token) {
    return res.status(401).json({ error: 'Access token required' });
  }

  try {
    // Verify the token signature and decode the payload
    // algorithms: ['HS256'] pins the allowed algorithm (prevents alg:none attacks)
    const decoded = jwt.verify(token, process.env.ACCESS_TOKEN_SECRET, {
      algorithms: ['HS256']
    });
    // Attach decoded payload to the request so route handlers can use it
    req.user = decoded;
    next();
  } catch (err) {
    return res.status(401).json({ error: 'Invalid or expired token' });
  }
}

module.exports = { authenticateToken };

What does this code do?

This middleware sits in front of any route you want to protect. Every request to that route runs through this function first.

  • authHeader.split(' ')[1] extracts the token from the Authorization: Bearer <token> header format.
  • jwt.verify() checks the token's cryptographic signature against your secret. If anyone tampered with the payload, the signature won't match and verification fails.
  • algorithms: ['HS256'] is called algorithm pinning. It tells the library to ONLY accept HS256 tokens. This prevents the alg:none attack where an attacker changes the algorithm in the token header to bypass verification.
  • req.user = decoded attaches the decoded payload (containing the user's email in the sub claim) to the request object so your route handler can identify who made the request.
  • Save middleware/auth.js.

Seeing a syntax error when you save?

Make sure you deleted the entire placeholder comment before pasting the new code. Leftover text above the const jwt line will cause a parse error.

Also confirm the module.exports line uses curly braces around authenticateToken. The import in server.js expects a named export.

help me fix a syntax error in my auth middleware

Why algorithm pinning matters

Without the algorithms option, an attacker could craft a token with "alg": "none" in its header and strip the signature entirely. If the library trusts the token's self-declared algorithm, it would skip signature verification and accept the forged token.

Pinning to HS256 forces the verifier to ignore whatever algorithm the token claims and only accept tokens signed with your chosen algorithm.

✔️ Awesome, I've got everything!

Great. Double check you have saved middleware/auth.js before moving on.

ⓧ I'd like to double check the full code

const jwt = require('jsonwebtoken');

function authenticateToken(req, res, next) {
  const authHeader = req.headers['authorization'];
  const token = authHeader && authHeader.split(' ')[1];

  if (!token) {
    return res.status(401).json({ error: 'Access token required' });
  }

  try {
    const decoded = jwt.verify(token, process.env.ACCESS_TOKEN_SECRET, {
      algorithms: ['HS256']
    });
    req.user = decoded;
    next();
  } catch (err) {
    return res.status(401).json({ error: 'Invalid or expired token' });
  }
}

module.exports = { authenticateToken };
Create a protected route

The middleware is ready, but it's not guarding anything yet. You need a route that uses it so you can test the full flow.

  • Open server.js.
  • Find this line:
app.use('/auth', authRoutes);
  • Add the following protected route below it (before the app.listen block):
// This route requires a valid JWT - authenticateToken runs first
app.get('/protected', authenticateToken, (req, res) => {
  res.json({
    message: `Hello, ${req.user.sub}! You have access to this protected route.`,
    user: req.user
  });
});

What does this code do?

The second argument to app.get() is authenticateToken. Express runs this middleware function before the route handler. If the middleware calls next(), the route handler runs. If it sends a response (like a 401), the route handler never executes.

The route handler reads req.user.sub to get the authenticated user's email. This value was attached by the middleware after it decoded the token.

  • Save server.js.
  • In your terminal, stop the running server with Ctrl+C and restart it by running:
node server.js
  • You should see Server running on http://localhost:3000 in the terminal.

Server crashes on startup?

If you see a TypeError mentioning authenticateToken is not a function, confirm that your middleware/auth.js file has the correct module.exports = { authenticateToken } line at the bottom.

help me fix a TypeError about authenticateToken not being a function

Now test the protected route with Thunder Client to confirm the middleware works.

  • In VS Code, click the Thunder Client icon in the left sidebar.
  • Click New Request.
  • Set the method to GET and enter this URL:
http://localhost:3000/protected
  • Click Send without adding any headers.
  • You should see a 401 response with {"error":"Access token required"}.

The middleware correctly blocks requests without a token. Now test with a valid token.

  • Create a new request. Set the method to POST and enter:
http://localhost:3000/auth/signup
  • Click the Body tab, select JSON, and enter:
{
  "email": "test@example.com",
  "password": "mypassword123"
}
  • Click Send to create the user.
  • Create another new request. Set the method to POST and enter:
http://localhost:3000/auth/login
  • In the Body tab, use the same JSON body as signup.
  • Click Send. Copy the accessToken value from the response.
  • Go back to your GET /protected request.
  • Click the Headers tab.
  • Add a header with the name Authorization and the value Bearer followed by your copied token (with a space between Bearer and the token).
  • Click Send.
  • You should see a 200 response with a JSON body containing your email in the message.

✔️ Awesome, I've got everything!

Great. Double check you have saved server.js before moving on.

ⓧ I'd like to double check the full code

require('dotenv').config();
const express = require('express');
const authRoutes = require('./routes/auth');
const { authenticateToken } = require('./middleware/auth');

const app = express();
const PORT = process.env.PORT || 3000;

app.use(express.json());

app.use('/auth', authRoutes);

app.get('/protected', authenticateToken, (req, res) => {
  res.json({
    message: `Hello, ${req.user.sub}! You have access to this protected route.`,
    user: req.user
  });
});

app.listen(PORT, () => {
  console.log(`Server running on http://localhost:${PORT}`);
});
Test token expiration and invalid tokens

Your middleware accepts valid tokens. Now confirm it properly rejects tokens that have been tampered with or are no longer valid.

  • In your GET /protected request in Thunder Client, go to the Headers tab.
  • In the Authorization header value, change one character in the middle of your token string (for example, change a lowercase letter to uppercase).
  • Click Send.
  • You should see a 401 response with {"error":"Invalid or expired token"}.

Why does changing one character break the token?

A JWT has three parts separated by dots: header, payload, and signature. The signature is a cryptographic hash of the header and payload combined with your secret key.

When you change even one character, the signature no longer matches the content. The jwt.verify() function detects this mismatch and throws an error. This is what makes JWTs tamper-proof.

What makes this stateless?

Notice that the middleware never queries a database or looks up a session. It validates the token purely by checking the cryptographic signature against the secret key stored in your environment variable.

This is what makes JWT authentication stateless and fast. Any server with the same secret can verify the token independently.

Still getting 200 after tampering?

Make sure you changed a character in the token itself, not in the word Bearer. The format must remain Bearer <token> with a space between them. Change a character in the long encoded string after the space.

help me understand why my tampered token is still being accepted

Your API now has a working gatekeeper. Any route you protect with authenticateToken will reject unauthorized requests automatically. Next up, you will add token refresh and logout so users can stay authenticated without re-entering their credentials.

Add Token Refresh and Logout

Your protected route is working. Users who present a valid access token get through, and everyone else gets rejected.

But access tokens expire after 15 minutes. Without a way to get a fresh one, your users would need to log in again every quarter hour. That is terrible user experience.

In this step, you will build the refresh token endpoint that issues new access tokens without re-authentication. You will also build a logout endpoint that revokes refresh tokens so no new access tokens can be minted.

In this step, get ready to:
  • Build a refresh endpoint that verifies a refresh token and issues a new access token.
  • Build a logout endpoint that revokes a refresh token.
  • Test the complete auth lifecycle from signup through logout.
Build the refresh endpoint

The refresh endpoint accepts a refresh token, verifies it is legitimate, and returns a brand new access token. This lets users continue using the API without logging in again.

  • In routes/auth.js, find the line that says module.exports = router; at the bottom of the file.
  • Add the following refresh route above that line:
// Issue a new access token when given a valid refresh token
router.post('/refresh', (req, res) => {
  const { refreshToken } = req.body;

  // Reject if no token was provided
  if (!refreshToken) {
    return res.status(400).json({ error: 'Refresh token is required' });
  }

  // Check the token exists in our stored tokens
  if (!refreshTokens.includes(refreshToken)) {
    return res.status(403).json({ error: 'Refresh token not recognized' });
  }

  try {
    // Verify signature and expiration with algorithm pinning
    const decoded = jwt.verify(refreshToken, process.env.REFRESH_TOKEN_SECRET, {
      algorithms: ['HS256']
    });

    // Sign a fresh access token using the email from the refresh token
    const accessToken = jwt.sign(
      { sub: decoded.sub },
      process.env.ACCESS_TOKEN_SECRET,
      { algorithm: 'HS256', expiresIn: '15m' }
    );

    res.json({ accessToken });
  } catch (err) {
    return res.status(403).json({ error: 'Invalid refresh token' });
  }
});

What does this code do?

This endpoint performs three checks before issuing a new access token:

  • Is a refresh token present in the request body? If not, return 400.
  • Does this token exist in the refreshTokens array? If someone sends a random string or a revoked token, return 403.
  • Is the token's signature valid and not expired? jwt.verify() handles both checks. If either fails, return 403.

Only after all three pass does the endpoint sign a fresh 15-minute access token using the user's email from the decoded refresh token payload.

  • Save routes/auth.js.

Seeing a syntax error on save?

Make sure the new router.post('/refresh', ...) block sits between the closing }); of the /login route and the module.exports line. A misplaced curly brace will break the file.

Help me find the issue

Build the logout endpoint

JWTs are stateless. Once a token is signed, you cannot "un-sign" it. The only way to prevent future access is to revoke the refresh token so no new access tokens can be minted.

  • In routes/auth.js, add the following logout route directly below the refresh route you just added (still above module.exports = router;):
// Revoke a refresh token so no new access tokens can be issued
router.post('/logout', (req, res) => {
  const { refreshToken } = req.body;

  // Remove the token from the stored array
  refreshTokens = refreshTokens.filter(t => t !== refreshToken);

  res.status(204).send();
});

Why does logout return 204 with no body?

HTTP status 204 means "success, but nothing to send back." The client already knows it logged out. There is no new data to display.

The existing access token remains valid until it naturally expires (up to 15 minutes). This is the tradeoff of stateless auth. The server does not track issued access tokens, so it cannot revoke them mid-flight. Short expiration times limit the damage window.

  • Save routes/auth.js.
  • Restart your server by pressing Ctrl+C in the terminal, then running:
node server.js

Server won't start?

If you see a syntax error mentioning an unexpected token, double-check that both the /refresh and /logout route blocks have matching opening and closing braces. Each router.post( call needs its own closing });.

Help me debug the syntax error

✔️ Awesome, I've got everything!

Great. Double check you have saved routes/auth.js and that the server is running without errors.

ⓧ I'd like to double check the full code

const express = require('express');
const jwt = require('jsonwebtoken');
const bcrypt = require('bcryptjs');

const router = express.Router();

const users = [];
let refreshTokens = [];

router.post('/signup', async (req, res) => {
  const { email, password } = req.body;

  if (!email || !password) {
    return res.status(400).json({ error: 'Email and password are required' });
  }

  const existingUser = users.find(u => u.email === email);
  if (existingUser) {
    return res.status(409).json({ error: 'User already exists' });
  }

  const hashedPassword = await bcrypt.hash(password, 10);
  const user = { email, password: hashedPassword };
  users.push(user);

  res.status(201).json({ message: 'User created', email: user.email });
});

router.post('/login', async (req, res) => {
  const { email, password } = req.body;

  if (!email || !password) {
    return res.status(400).json({ error: 'Email and password are required' });
  }

  const user = users.find(u => u.email === email);
  if (!user) {
    return res.status(401).json({ error: 'Invalid credentials' });
  }

  const validPassword = await bcrypt.compare(password, user.password);
  if (!validPassword) {
    return res.status(401).json({ error: 'Invalid credentials' });
  }

  const accessToken = jwt.sign(
    { sub: user.email },
    process.env.ACCESS_TOKEN_SECRET,
    { algorithm: 'HS256', expiresIn: '15m' }
  );

  const refreshToken = jwt.sign(
    { sub: user.email },
    process.env.REFRESH_TOKEN_SECRET,
    { algorithm: 'HS256', expiresIn: '7d' }
  );

  refreshTokens.push(refreshToken);

  res.json({ accessToken, refreshToken });
});

router.post('/refresh', (req, res) => {
  const { refreshToken } = req.body;

  if (!refreshToken) {
    return res.status(400).json({ error: 'Refresh token is required' });
  }

  if (!refreshTokens.includes(refreshToken)) {
    return res.status(403).json({ error: 'Refresh token not recognized' });
  }

  try {
    const decoded = jwt.verify(refreshToken, process.env.REFRESH_TOKEN_SECRET, {
      algorithms: ['HS256']
    });

    const accessToken = jwt.sign(
      { sub: decoded.sub },
      process.env.ACCESS_TOKEN_SECRET,
      { algorithm: 'HS256', expiresIn: '15m' }
    );

    res.json({ accessToken });
  } catch (err) {
    return res.status(403).json({ error: 'Invalid refresh token' });
  }
});

router.post('/logout', (req, res) => {
  const { refreshToken } = req.body;

  refreshTokens = refreshTokens.filter(t => t !== refreshToken);

  res.status(204).send();
});

module.exports = router;
Test the complete auth lifecycle

Now you will run through the entire authentication flow from signup to logout using Thunder Client. This confirms every endpoint works together as a system.

  • Open Thunder Client in VS Code by clicking the lightning bolt icon in the left sidebar.
  • Create a new request. Set the method to POST and the URL to http://localhost:3000/auth/signup.
  • Click the Body tab, select JSON, and enter:
{
  "email": "test@example.com",
  "password": "mypassword123"
}
  • Click Send. You should see a 201 response with the user's email.
  • Change the URL to http://localhost:3000/auth/login and click Send again with the same body.
  • Copy the accessToken value from the response.
  • Also copy the refreshToken value and save it somewhere. You will need it shortly.
  • Create a new request. Set the method to GET and the URL to http://localhost:3000/protected.
  • Click the Headers tab and add a header with the name Authorization and the value Bearer followed by your access token.
  • Click Send. You should see a 200 response with your email in the message.

Now test the refresh flow.

  • Create a new request. Set the method to POST and the URL to http://localhost:3000/auth/refresh.
  • In the Body tab (JSON), enter:
{
  "refreshToken": "paste-your-refresh-token-here"
}
  • Replace paste-your-refresh-token-here with the refresh token you copied from the login response.
  • Click Send. You should see a 200 response containing a new accessToken.
  • Copy the new access token and use it to access the /protected route again. Confirm you get a 200 response.

Finally, test logout and confirm the refresh token is revoked.

  • Create a new request. Set the method to POST and the URL to http://localhost:3000/auth/logout.
  • In the Body tab (JSON), send the same refresh token:
{
  "refreshToken": "paste-your-refresh-token-here"
}
  • Click Send. You should see a 204 response with no body.
  • Now go back to the /auth/refresh request and click Send again with the same refresh token.
  • You should see a 403 response with the message Refresh token not recognized. The token has been revoked.

Why does refresh fail after logout?

The logout endpoint removed the refresh token from the refreshTokens array. When the refresh endpoint checks refreshTokens.includes(refreshToken), the token is no longer there. This is the only way to "revoke" access in a stateless JWT system.

The access token you already have still works until it expires in 15 minutes. Short expiration limits the damage window if a token is stolen.

Getting unexpected responses?

If you restarted the server between tests, the in-memory users and refreshTokens arrays are wiped clean. You need to sign up and log in again before testing refresh or logout.

Make sure the Body tab is set to JSON (not Text or Form) and that the key is exactly refreshToken (camelCase, no spaces).

Help me debug my request setup

Secret mission

Add Refresh Token Rotation with Reuse Detection

Your current refresh flow has a weakness. If an attacker steals a refresh token, they can use it indefinitely alongside the legitimate user. Production identity providers like Auth0 and Okta solve this with refresh token rotation and reuse detection. Can you implement the same pattern?

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely locally, so there are no ongoing costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

Resources you used:

  • The jwt-auth-api project folder on your Desktop.
  • The running Node.js server process in your terminal.
  • Thunder Client VS Code extension.

Keep everything running

No action needed. Choose this if you're still actively building or want to keep experimenting with your JWT authentication API.

  • Your server continues running in the terminal. You can send requests to it anytime.
  • Your project files stay on your Desktop, ready for further edits.
  • Thunder Client stays available for testing API endpoints.

Pause - I'll come back to this later

Shut down running processes to free up your terminal. Your project files stay on disk so you can pick up where you left off.

  • Switch to the terminal where your server is running.
  • Press Ctrl+C to stop the server.
  • Your project files remain in the jwt-auth-api folder on your Desktop. To resume later, open the folder in VS Code and run node server.js again.

Delete - I don't want to use this again

Remove all project resources and start fresh. Follow these steps to clean up everything created during this project.

  • Switch to the terminal where your server is running and press Ctrl+C to stop it.
  • Delete the project folder by running this command (macOS/Linux):
rm -rf ~/Desktop/jwt-auth-api
  • On Windows, run this instead:
rmdir /s /q "$HOME\Desktop\jwt-auth-api"
  • To uninstall Thunder Client, open the Extensions sidebar in VS Code, find Thunder Client, click the gear icon, and select Uninstall.

That's a Wrap!

That's a Wrap!

Nice work! You just built a production-shaped JWT authentication API from scratch with Express and Node.js.

You've learned how to:

  • Built a complete REST API with Express that handles user registration, login with bcrypt-hashed passwords, token issuance, refresh, and logout.
  • Created auth middleware that protects routes using stateless JWT verification with algorithm pinning (HS256) and no database lookup.
  • Implemented the access token + refresh token pattern used in production OAuth 2.0 systems, with short-lived access tokens and revocable refresh tokens.
  • Secret Mission: Added refresh token rotation with reuse detection to prevent stolen-token attacks by revoking the entire token family.

Ready to quiz yourself?