Build the Authentication System
Build a secure REST API with hashed passwords and JWT authentication.
Introduction
30 Second Summary
Every time you log in to a website, a system behind the scenes confirms you are who you say you are. If that system has a flaw, passwords leak and accounts get stolen.
In this project, you will build a complete authentication API for CodeArena, a competitive coding platform. You'll experience the evolution from storing plain text passwords to production-grade security, understanding why each layer of protection exists.
What You'll Build
A REST API that handles user registration, login, token-based authentication, and session management using Express, bcryptjs, and jsonwebtoken.
By the end of this project, you'll have:
- A user registration endpoint that hashes passwords with bcryptjs before storing them.
- A JWT-based login system that issues short-lived access tokens, validates them via custom middleware, and supports token refresh with logout.
- A protected route (/me) that returns the authenticated user's profile only when a valid token is provided.
- Secret Mission: Add role-based access control so only admins can create coding problems.
Are there any prerequisites?
This project assumes a basic understanding of JavaScript, REST APIs, and how HTTP requests work (GET, POST, status codes). Familiarity with npm and running terminal commands is also helpful.
Set Up the CodeArena Backend
Every feature in CodeArena depends on knowing who the user is. Before you can build registration, login, or protected routes, you need a running server and the right packages in place.
In this step, you'll create the project folder, install your dependencies, and set up an Express server with a clean folder structure for routes, middleware, and data.
In this step, get ready to:
- Initialize the Node.js project and install dependencies.
- Set up the Express server with routes and middleware.
- Create the in-memory user store.
Initialize the project and install dependencies
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type VS Code and press Enter to open it.
- Open the integrated terminal by pressing Ctrl+` (backtick, on both macOS and Windows).
- Move to your Desktop by running this command:
cd ~/Desktop
- Create the project folder and move into it by running these commands:
mkdir codearena-auth
cd codearena-auth
- Confirm the folder was created by running:
ls
The output should be empty for now since the folder is brand new.
- 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 dependencies and scripts.
You should see a JSON object printed to the terminal, confirming your package.json was created.
- Install the five packages your project needs by running:
npm install express@5.2.1 jsonwebtoken@9.0.3 bcryptjs@2.4.3 dotenv@16.5.0 cors@2.8.5
What are these packages?
- express handles HTTP routing and request/response logic.
- jsonwebtoken signs and verifies JSON Web Tokens for authentication.
- bcryptjs hashes passwords so they are never stored in plain text.
- dotenv loads environment variables from a .env file.
- cors enables cross-origin requests so frontend apps can talk to your API.
You should see added 5 packages (or similar) in the terminal output, along with a node_modules folder and package-lock.json file.
Seeing an error during install?
- Make sure you are inside the codearena-auth folder. Run pwd to check your current directory.
- Check your internet connection. npm needs to download packages from the npm registry.
Help me debug my npm install error.
Now open the codearena-auth folder as a workspace in VS Code so the Explorer sidebar shows your project files.
- Click File in the top menu bar.
- Click Open Folder.
- Navigate to your Desktop, select the codearena-auth folder, and click Open.
- If VS Code asks whether you trust the authors, click Yes, I trust the authors.
VS Code may open a new window. If the terminal is not visible, press Ctrl+` to reopen it.
Your API needs a few configuration values like the server port and secret keys for signing tokens. You'll store these in an environment file that dotenv loads at startup.
- Create a new file by clicking the New File icon (a page with a plus sign) at the top of the Explorer sidebar.
- Name the file .env and press Enter.
- Add the following configuration to .env:
PORT=3000
JWT_ACCESS_SECRET=codearena-access-secret-change-in-production
JWT_REFRESH_SECRET=codearena-refresh-secret-change-in-production
JWT_ACCESS_TTL=15m
JWT_REFRESH_TTL=7d
What do these values mean?
- PORT sets which port your server listens on.
- JWT_ACCESS_SECRET and JWT_REFRESH_SECRET are the keys used to sign and verify tokens. In production, these would be long random strings.
- JWT_ACCESS_TTL and JWT_REFRESH_TTL control how long each token type stays valid.
- Save .env by pressing Cmd+S (macOS) or Ctrl+S (Windows).
Create the folder structure and start the Express server
Your project needs three subdirectories to keep code organized: routes for API endpoints, middleware for request processing, and data for storage.
- Create the three subdirectories by running:
mkdir routes middleware data
- Confirm they exist by running:
ls
You should see data, middleware, node_modules, and routes in the output.
Before writing the main server file, you need to create two placeholder files that server.js will import. Without them, the server would crash on startup.
- Click the routes folder in the Explorer sidebar, then click the New File icon.
- Name the file auth.js and press Enter.
- Add the following placeholder router to routes/auth.js:
const express = require('express');
const router = express.Router();
module.exports = router;
- Save routes/auth.js.
- Click the middleware folder in the Explorer sidebar, then click the New File icon.
- Name the file auth.js and press Enter.
- Add this placeholder middleware to middleware/auth.js:
const jwt = require('jsonwebtoken');
function authMiddleware(req, res, next) {
// placeholder
next();
}
module.exports = { authMiddleware };
Why are these files placeholders?
server.js imports routes/auth.js when it starts up. If the file does not exist, the server will crash with a module-not-found error.
You will build the real route and middleware logic in later steps. For now, these minimal files let the server start cleanly.
- Save middleware/auth.js.
Now for the main file. server.js sets up Express with JSON body parsing, CORS, and mounts the auth router.
- Click the New File icon at the top of the Explorer sidebar (not inside any folder).
- Name the file server.js and press Enter.
- Add the following code to server.js:
require('dotenv').config();
const express = require('express');
const cors = require('cors');
const authRouter = require('./routes/auth');
const app = express();
// Enable CORS for cross-origin requests
app.use(cors());
// Parse incoming JSON request bodies
app.use(express.json());
// Health check route
app.get('/', (req, res) => {
res.json({ message: 'CodeArena API is running' });
});
// Mount the auth router at /api/auth
app.use('/api/auth', authRouter);
// Start the server on the port from .env (or default to 3000)
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
console.log(`CodeArena server running on http://localhost:${PORT}`);
});
What does this code do?
- require('dotenv').config() loads your .env values into process.env so your code can read them.
- app.use(express.json()) tells Express to automatically parse JSON request bodies into JavaScript objects.
- app.use('/api/auth', authRouter) mounts every route defined in routes/auth.js under the /api/auth path.
- Save server.js.
- Start the server by running this command in the terminal:
node server.js
You should see CodeArena server running on http://localhost:3000 in the terminal.
- Open your browser and navigate to http://localhost:3000.
You should see a JSON response: {"message":"CodeArena API is running"}.
Server not starting?
- If you see a "Cannot find module" error, make sure routes/auth.js exists and is saved. The server imports this file on startup.
- If port 3000 is already in use, either close the other process or change PORT in your .env file to another value like 3001.
- Check that your terminal is in the codearena-auth folder. Run pwd to confirm.
Help me debug why my Express server won't start.
Create the in-memory user store
Your authentication system needs a place to store user data. For this project, you'll use a simple JavaScript array instead of a database.
This keeps the focus on authentication concepts. The data module is designed to be swappable, so you could replace it with MongoDB in a future project without changing the rest of your code.
- Click the data folder in the Explorer sidebar, then click the New File icon.
- Name the file users.js and press Enter.
- Add the following code to data/users.js:
const users = [];
let nextId = 1;
// Find a user by their email address
function findByEmail(email) {
return users.find(user => user.email === email);
}
// Find a user by their unique ID
function findById(id) {
return users.find(user => user.id === id);
}
// Create a new user and add them to the store
function createUser({ username, email, passwordHash }) {
const user = {
id: String(nextId++),
username,
email,
passwordHash,
role: 'user',
refreshTokens: [],
createdAt: new Date().toISOString()
};
users.push(user);
return user;
}
// Return only safe-to-share fields (no password hash)
function getPublicProfile(user) {
return {
id: user.id,
username: user.username,
email: user.email,
role: user.role,
createdAt: user.createdAt
};
}
module.exports = { users, findByEmail, findById, createUser, getPublicProfile };
What does this code do?
- users is an in-memory array that acts as your database. Every user object gets pushed here.
- findByEmail() and findById() are lookup helpers. Login uses email lookup, and token verification uses ID lookup.
- createUser() assigns a unique ID, sets the default role to user, and initializes an empty refreshTokens array for storing active sessions.
- getPublicProfile() strips out the password hash so you never accidentally send it in an API response.
- Save data/users.js.
You won't see a change in the browser yet. The /register endpoint you'll build in the next step will use these functions to store and look up users.
✔️ Awesome, I've got everything!
Great. Double check you have all five files saved: .env, server.js, routes/auth.js, middleware/auth.js, and data/users.js.
ⓧ I'd like to double check the full code
Here is the complete code for every file you should have at this point.
PORT=3000
JWT_ACCESS_SECRET=codearena-access-secret-change-in-production
JWT_REFRESH_SECRET=codearena-refresh-secret-change-in-production
JWT_ACCESS_TTL=15m
JWT_REFRESH_TTL=7d
require('dotenv').config();
const express = require('express');
const cors = require('cors');
const authRouter = require('./routes/auth');
const app = express();
app.use(cors());
app.use(express.json());
app.get('/', (req, res) => {
res.json({ message: 'CodeArena API is running' });
});
app.use('/api/auth', authRouter);
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
console.log(`CodeArena server running on http://localhost:${PORT}`);
});
const express = require('express');
const router = express.Router();
module.exports = router;
const jwt = require('jsonwebtoken');
function authMiddleware(req, res, next) {
// placeholder
next();
}
module.exports = { authMiddleware };
const users = [];
let nextId = 1;
function findByEmail(email) {
return users.find(user => user.email === email);
}
function findById(id) {
return users.find(user => user.id === id);
}
function createUser({ username, email, passwordHash }) {
const user = {
id: String(nextId++),
username,
email,
passwordHash,
role: 'user',
refreshTokens: [],
createdAt: new Date().toISOString()
};
users.push(user);
return user;
}
function getPublicProfile(user) {
return {
id: user.id,
username: user.username,
email: user.email,
role: user.role,
createdAt: user.createdAt
};
}
module.exports = { users, findByEmail, findById, createUser, getPublicProfile };
Your backend foundation is in place. Next up, you'll experience firsthand why storing plain text passwords is dangerous, and then fix it with password hashing.
Solve the Password Problem
Your CodeArena backend is running and ready for requests. Now it's time to tackle the most critical piece of any authentication system: how you handle passwords.
In this step, you'll experience firsthand why storing plain text passwords is dangerous. Then you'll fix the problem with bcryptjs, a password hashing library designed to make stolen passwords worthless to attackers.
In this step, get ready to:
- Build a registration endpoint and see why plain text passwords are dangerous.
- Secure password storage with bcryptjs hashing and salting.
- Build a login endpoint that verifies passwords against stored hashes.
Build a naive register endpoint
Before fixing a problem, it helps to see it firsthand. You'll build a register endpoint that stores passwords in plain text, then observe exactly what an attacker would see if the data leaked.
- In routes/auth.js, replace all the existing code with the following:
const express = require('express');
const { createUser, getPublicProfile } = require('../data/users');
const router = express.Router();
// WARNING: This stores the password in plain text!
router.post('/register', (req, res) => {
const { username, email, password } = req.body;
// Passing the raw password as the hash - this is dangerous!
const user = createUser({ username, email, passwordHash: password });
// Log the stored data to see what's saved
console.log('Stored user:', JSON.stringify(user, null, 2));
res.status(201).json({
message: 'User registered successfully',
user: getPublicProfile(user)
});
});
module.exports = router;
What does this code do?
- The route accepts a POST request with username, email, and password in the request body.
- It stores the raw password directly in the passwordHash field. The field name says "hash" but the value is still plain text.
- console.log prints the stored user data to your terminal so you can see exactly what's saved.
- Save routes/auth.js.
- Switch back to the terminal where your server is running. Stop it with Ctrl+C.
- Restart the server by running:
node server.js
- Open a second terminal in VS Code by clicking the + icon in the terminal panel.
- Register a test user by running this command:
macOS
curl -X POST http://localhost:3000/api/auth/register -H "Content-Type: application/json" -d '{"username":"testuser","email":"test@codearena.dev","password":"securepassword123"}'
Windows
curl.exe -X POST http://localhost:3000/api/auth/register -H "Content-Type: application/json" -d '{"username":"testuser","email":"test@codearena.dev","password":"securepassword123"}'
Use curl.exe on Windows
In PowerShell, curl is an alias for a different command. Using curl.exe calls the real curl utility.
Switch back to the first terminal where your server is running. You should see output like this:
Stored user: {
"id": "1",
"username": "testuser",
"email": "test@codearena.dev",
"passwordHash": "securepassword123",
"role": "user",
"refreshTokens": [],
"createdAt": "..."
}
Look at the passwordHash field. The password is sitting right there in plain text.
Why is this so dangerous?
If this database leaks (and databases DO leak), every user's password is exposed instantly. Attackers don't need to crack anything.
It gets worse. Most people reuse passwords across sites. One breach at CodeArena could compromise a user's email, banking, and social media accounts too.
Not seeing the console output?
- Make sure you're looking at the first terminal (where node server.js is running), not the second terminal where you ran the curl command.
- If you don't see the route responding, make sure you restarted the server after saving the file.
Help me debug my register endpoint.
Hash passwords with bcryptjs
You've seen the problem. Now let's understand the solution.
What is password hashing?
Password hashing is a one-way transformation. It converts a password into a fixed-length scrambled string. Unlike encryption, hashing cannot be reversed. Even the server cannot recover the original password from the hash.
Before bcryptjs, developers either stored passwords in plain text (what you just saw) or used fast hashes like MD5 or SHA-256. Fast hashes are vulnerable to rainbow tables (precomputed lookup tables of common passwords and their hashes) and brute force attacks because they are designed to be fast.
bcryptjs solves this with three features. It is intentionally slow (controlled by a cost factor). It automatically generates a unique salt per password, which defeats rainbow tables. The cost factor can be increased over time as hardware gets faster.
Time to refactor your register endpoint. You'll replace the entire file with a secure version that validates input and hashes passwords before storing them.
- In routes/auth.js, replace all the code with the following imports and setup:
const express = require('express');
const bcrypt = require('bcryptjs');
const { findByEmail, createUser, getPublicProfile } = require('../data/users');
const { authMiddleware } = require('../middleware/auth');
const router = express.Router();
What changed in the imports?
- bcryptjs gives you the hashing and comparison functions for passwords.
- findByEmail lets you check if an email is already registered before creating a duplicate.
- authMiddleware is imported for routes you'll add in the next step.
- Below the router declaration, add the secure register route:
router.post('/register', async (req, res) => {
const { username, email, password } = req.body;
// Validate that all required fields are present
if (!username || !email || !password) {
return res.status(400).json({ error: 'Username, email, and password are required' });
}
// Enforce minimum password length
if (password.length < 8) {
return res.status(400).json({ error: 'Password must be at least 8 characters' });
}
// Prevent duplicate registrations
const existing = findByEmail(email);
if (existing) {
return res.status(409).json({ error: 'Email already registered' });
}
// Hash the password with a cost factor of 12
const passwordHash = await bcrypt.hash(password, 12);
const user = createUser({ username, email, passwordHash });
res.status(201).json({
message: 'User registered successfully',
user: getPublicProfile(user)
});
});
module.exports = router;
What does the secure register endpoint do?
- Three validation checks run before anything is stored: all fields present, password length, and email uniqueness.
- bcrypt.hash(password, 12) hashes the password with a cost factor of 12. This means bcrypt performs 2^12 (4,096) rounds of processing, making each hash take roughly 250-300ms. That's fast enough for a single registration but painfully slow for an attacker trying millions of passwords.
- The function is async because hashing takes time. The await keyword pauses until the hash is ready.
- getPublicProfile(user) returns only safe fields (id, username, email, role). The password hash never leaves the server.
- Save routes/auth.js.
- In the first terminal, stop the server with Ctrl+C and restart it by running:
node server.js
- In the second terminal, register a user by running:
macOS
curl -X POST http://localhost:3000/api/auth/register -H "Content-Type: application/json" -d '{"username":"testuser","email":"test@codearena.dev","password":"securepassword123"}'
Windows
curl.exe -X POST http://localhost:3000/api/auth/register -H "Content-Type: application/json" -d '{"username":"testuser","email":"test@codearena.dev","password":"securepassword123"}'
You should see a JSON response with the user profile. Notice that the response only contains safe fields. The password hash stays on the server.
What is the salt in the hash?
If you look at the stored hash, it starts with something like $2a$12$. The $2a$ means bcrypt algorithm. The $12$ is the cost factor you chose. The next 22 characters are the randomly generated salt.
Even if two users pick the same password, their hashes will be completely different because each gets a unique salt. This is what defeats rainbow tables.
Seeing an error when registering?
- If your import line says require('bcrypt') instead of require('bcryptjs'), the module won't load. Make sure the name includes the js suffix.
- If you get a 400 error, make sure your JSON body includes all three fields: username, email, and a password that is at least 8 characters long.
- If you get a 409 error, the server still has the user from your naive test. Restart the server to clear the in-memory store.
Help me debug my secure register endpoint.
Build the login endpoint
Users can register securely, but they can't log in yet. The login endpoint needs to verify a submitted password against the stored hash without ever seeing the original password.
- In routes/auth.js, find the line that says module.exports = router;.
- Add the login route directly above it:
router.post('/login', async (req, res) => {
const { email, password } = req.body;
// Validate required fields
if (!email || !password) {
return res.status(400).json({ error: 'Email and password are required' });
}
// Look up the user by email
const user = findByEmail(email);
if (!user) {
return res.status(401).json({ error: 'Invalid credentials' });
}
// Compare the submitted password with the stored hash
const isMatch = await bcrypt.compare(password, user.passwordHash);
if (!isMatch) {
return res.status(401).json({ error: 'Invalid credentials' });
}
res.json({ message: 'Login successful' });
});
What does this code do?
- bcrypt.compare(password, user.passwordHash) hashes the submitted password with the same salt embedded in the stored hash, then checks if the results match. It never decrypts the stored hash.
- Both the "no user found" and "wrong password" cases return the same generic Invalid credentials error. This prevents attackers from learning whether an email exists in the system.
- For now, a successful login returns a simple JSON message. In the next step, you'll replace this with a JWT access token.
Why use the same error message for both cases?
If you returned "Email not found" for a missing user and "Wrong password" for a bad password, an attacker could use the register and login endpoints together to build a list of valid email addresses. Generic error messages close this information leak.
- Save routes/auth.js.
- Restart the server: press Ctrl+C in the first terminal, then run node server.js.
- Since the in-memory store was cleared by the restart, register a user again:
macOS
curl -X POST http://localhost:3000/api/auth/register -H "Content-Type: application/json" -d '{"username":"testuser","email":"test@codearena.dev","password":"securepassword123"}'
Windows
curl.exe -X POST http://localhost:3000/api/auth/register -H "Content-Type: application/json" -d '{"username":"testuser","email":"test@codearena.dev","password":"securepassword123"}'
- Now test logging in with the correct password:
macOS
curl -X POST http://localhost:3000/api/auth/login -H "Content-Type: application/json" -d '{"email":"test@codearena.dev","password":"securepassword123"}'
Windows
curl.exe -X POST http://localhost:3000/api/auth/login -H "Content-Type: application/json" -d '{"email":"test@codearena.dev","password":"securepassword123"}'
You should see {"message":"Login successful"} in the response.
- Now test with a wrong password:
macOS
curl -X POST http://localhost:3000/api/auth/login -H "Content-Type: application/json" -d '{"email":"test@codearena.dev","password":"wrongpassword"}'
Windows
curl.exe -X POST http://localhost:3000/api/auth/login -H "Content-Type: application/json" -d '{"email":"test@codearena.dev","password":"wrongpassword"}'
You should see a 401 response with {"error":"Invalid credentials"}. The same generic message appears whether the email doesn't exist or the password is wrong.
Login returning 401 even with the correct password?
- Restarting the server clears all stored users. Make sure you registered a user again after the last restart before trying to log in.
- Check that the email in your login request matches the email you registered with exactly. Email matching is case-sensitive in this implementation.
Help me debug my login endpoint.
✔️ Awesome, I've got everything!
Great. Double check you've saved routes/auth.js and your server is running.
ⓧ I'd like to double check the full code
Here's the full routes/auth.js file at this point:
const express = require('express');
const bcrypt = require('bcryptjs');
const { findByEmail, createUser, getPublicProfile } = require('../data/users');
const { authMiddleware } = require('../middleware/auth');
const router = express.Router();
router.post('/register', async (req, res) => {
const { username, email, password } = req.body;
if (!username || !email || !password) {
return res.status(400).json({ error: 'Username, email, and password are required' });
}
if (password.length < 8) {
return res.status(400).json({ error: 'Password must be at least 8 characters' });
}
const existing = findByEmail(email);
if (existing) {
return res.status(409).json({ error: 'Email already registered' });
}
const passwordHash = await bcrypt.hash(password, 12);
const user = createUser({ username, email, passwordHash });
res.status(201).json({
message: 'User registered successfully',
user: getPublicProfile(user)
});
});
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 = findByEmail(email);
if (!user) {
return res.status(401).json({ error: 'Invalid credentials' });
}
const isMatch = await bcrypt.compare(password, user.passwordHash);
if (!isMatch) {
return res.status(401).json({ error: 'Invalid credentials' });
}
res.json({ message: 'Login successful' });
});
module.exports = router;
Your passwords are hashed and your login is verified. But right now, a successful login just returns a message. The user has no way to prove they logged in on future requests. Next up, you'll issue JWT tokens that let users carry their identity with every request.
Issue JWT Access and Refresh Tokens
Your login endpoint verifies passwords correctly, but the response is a dead end. It says "Login successful" and then forgets who the user is.
In this step, you will replace that simple response with JSON Web Tokens. After logging in, the server will issue a signed token that the client sends back on future requests to prove who they are.
In this step, get ready to:
- Build token-signing functions for access and refresh tokens.
- Update the login endpoint to issue both tokens.
- Decode a JWT at jwt.io to see exactly what's inside it.
Understand the problem JWTs solve and implement signAccessToken
Right now, after a user logs in, the server has no way to recognize them on the next request. Without tokens, users would need to send their username and password with every single request.
Why JWTs instead of sessions?
One solution is sessions, where the server stores login state in memory and gives the client a session ID cookie. This works for a single server, but becomes difficult in distributed systems where requests might hit different servers.
JWTs take a different approach. The server signs a payload (user ID, email, role) with a secret key, and the client sends this token back on future requests. The server verifies the signature without needing a database lookup. The token itself carries all the identity information.
The jsonwebtoken library is already installed in your project. You need to import it and tell routes/auth.js about one more helper function from your user store.
- In routes/auth.js, find the import line for bcryptjs:
const bcrypt = require('bcryptjs');
- Add this new line directly below it:
const jwt = require('jsonwebtoken');
- Now find the line that imports from data/users.js:
const { findByEmail, createUser, getPublicProfile } = require('../data/users');
- Add findById to the destructured imports, so the line now looks like this:
const { findByEmail, findById, createUser, getPublicProfile } = require('../data/users');
With the imports ready, you can build the function that creates access tokens. An access token is a short-lived JWT that the client sends on every request to prove its identity.
- Add the signAccessToken function below the const router = express.Router(); line by pasting this code:
function signAccessToken(payload) {
return jwt.sign(payload, process.env.JWT_ACCESS_SECRET, {
algorithm: 'HS256',
expiresIn: process.env.JWT_ACCESS_TTL || '15m'
});
}
What does this code do?
- jwt.sign() takes a payload object, a secret string, and an options object. It returns a signed JWT string.
- algorithm: 'HS256' pins the signing algorithm. Without this, an attacker could exploit the alg: none downgrade attack, one of the most famous JWT vulnerabilities.
- expiresIn sets the token's lifetime. The value comes from your .env file (15m), with a fallback if the variable is missing.
- Save routes/auth.js.
You won't see a change yet. The next chunk connects this function to the login endpoint.
Seeing a syntax error on save?
- Make sure the function is placed between const router = express.Router(); and the first router.post call. Placing it inside an existing route handler will cause errors.
- Check for mismatched curly braces. The function has an outer {} for the function body and an inner {} for the options object.
- help me debug a syntax error in my signAccessToken function
Add signRefreshToken and update the login endpoint
Access tokens expire quickly (15 minutes). Without a second token, users would need to log in again every 15 minutes. A refresh token solves this. It has a longer lifespan (7 days) and is used solely to request a new access token.
- Add the signRefreshToken function directly below signAccessToken by pasting this code:
function signRefreshToken(payload) {
return jwt.sign(payload, process.env.JWT_REFRESH_SECRET, {
algorithm: 'HS256',
expiresIn: process.env.JWT_REFRESH_TTL || '7d'
});
}
Why a separate secret for refresh tokens?
The refresh token uses JWT_REFRESH_SECRET instead of JWT_ACCESS_SECRET. If the access secret were compromised, an attacker could forge access tokens. But they still could not forge refresh tokens because those use a different secret.
- Save routes/auth.js.
You won't see a change yet. The next chunk connects both functions to the login endpoint.
Now you need to update the login endpoint to call both signing functions and return the tokens. The current login just returns a plain success message.
- Inside the /login route handler in routes/auth.js, find this line near the bottom of the handler:
res.json({ message: 'Login successful' });
- Replace that entire line with the following code:
// Build the JWT payload with user identity and role
const tokenPayload = { sub: user.id, email: user.email, role: user.role };
const accessToken = signAccessToken(tokenPayload);
const refreshToken = signRefreshToken(tokenPayload);
// Store the refresh token so we can verify it later
user.refreshTokens.push(refreshToken);
res.json({
accessToken,
refreshToken,
user: getPublicProfile(user)
});
What does this code do?
- tokenPayload contains the claims that get baked into the JWT. sub (subject) is the standard JWT field for identifying who the token belongs to.
- user.refreshTokens.push(refreshToken) saves the refresh token server-side. This is critical for logout. When a user logs out, you remove their refresh token from this array so it can no longer be used.
- The response now returns both tokens and the user's public profile. The client stores these tokens and sends the access token with future requests.
- Save routes/auth.js.
You will test this in the next substep by restarting the server and sending a login request.
Seeing 'signAccessToken is not defined'?
- Make sure both signAccessToken and signRefreshToken are defined above the router.post('/login', ...) handler, not inside it.
- Confirm that const jwt = require('jsonwebtoken'); appears at the top of the file. Without this import, jwt.sign() will fail.
- help me fix a 'not defined' error in my login endpoint
✔️ Awesome, I've got everything!
Great. Double check you have saved routes/auth.js.
ⓧ I'd like to double check the full code
const express = require('express');
const bcrypt = require('bcryptjs');
const jwt = require('jsonwebtoken');
const { findByEmail, findById, createUser, getPublicProfile } = require('../data/users');
const { authMiddleware } = require('../middleware/auth');
const router = express.Router();
function signAccessToken(payload) {
return jwt.sign(payload, process.env.JWT_ACCESS_SECRET, {
algorithm: 'HS256',
expiresIn: process.env.JWT_ACCESS_TTL || '15m'
});
}
function signRefreshToken(payload) {
return jwt.sign(payload, process.env.JWT_REFRESH_SECRET, {
algorithm: 'HS256',
expiresIn: process.env.JWT_REFRESH_TTL || '7d'
});
}
router.post('/register', async (req, res) => {
const { username, email, password } = req.body;
if (!username || !email || !password) {
return res.status(400).json({ error: 'Username, email, and password are required' });
}
if (password.length < 8) {
return res.status(400).json({ error: 'Password must be at least 8 characters' });
}
const existing = findByEmail(email);
if (existing) {
return res.status(409).json({ error: 'Email already registered' });
}
const passwordHash = await bcrypt.hash(password, 12);
const user = createUser({ username, email, passwordHash });
res.status(201).json({
message: 'User registered successfully',
user: getPublicProfile(user)
});
});
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 = findByEmail(email);
if (!user) {
return res.status(401).json({ error: 'Invalid credentials' });
}
const isMatch = await bcrypt.compare(password, user.passwordHash);
if (!isMatch) {
return res.status(401).json({ error: 'Invalid credentials' });
}
const tokenPayload = { sub: user.id, email: user.email, role: user.role };
const accessToken = signAccessToken(tokenPayload);
const refreshToken = signRefreshToken(tokenPayload);
user.refreshTokens.push(refreshToken);
res.json({
accessToken,
refreshToken,
user: getPublicProfile(user)
});
});
module.exports = router;
Test the login endpoint and examine the JWT
Time to see your tokens in action. Since you changed server-side code, you need to restart the server. Restarting clears the in-memory user store, so you will register a fresh user first.
- Switch back to the terminal where your server is running.
- Press Ctrl+C to stop the server.
- Restart the server by running:
node server.js
You should see CodeArena server running on http://localhost:3000 in your terminal.
- Open a second terminal in VS Code by clicking **Terminal** in the top menu, then clicking **New Terminal**.
- Register a test user by running this command:
macOS
curl -X POST http://localhost:3000/api/auth/register \
-H "Content-Type: application/json" \
-d '{"username": "testuser", "email": "test@codearena.dev", "password": "securepassword123"}'
Windows
curl.exe -X POST http://localhost:3000/api/auth/register `
-H "Content-Type: application/json" `
-d '{\"username\": \"testuser\", \"email\": \"test@codearena.dev\", \"password\": \"securepassword123\"}'
You should see a JSON response with User registered successfully and the user's public profile.
- Now log in with the same user to receive your tokens by running:
macOS
curl -X POST http://localhost:3000/api/auth/login \
-H "Content-Type: application/json" \
-d '{"email": "test@codearena.dev", "password": "securepassword123"}'
Windows
curl.exe -X POST http://localhost:3000/api/auth/login `
-H "Content-Type: application/json" `
-d '{\"email\": \"test@codearena.dev\", \"password\": \"securepassword123\"}'
The response now contains three fields: accessToken, refreshToken, and user. Both tokens are long strings of characters separated by two dots.
Still seeing 'Login successful' instead of tokens?
- Make sure you saved routes/auth.js after making the changes.
- Confirm you restarted the server with Ctrl+C and then node server.js. Node does not pick up file changes automatically.
- Check that the line res.json({ message: 'Login successful' }); has been fully replaced, not just commented out with the new code below it.
- help me debug my login endpoint not returning tokens
Now you will look inside the token to see what data it carries.
- Copy the accessToken value from the login 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.
The right side of the page splits the token into three decoded sections: Header, Payload, and Signature.
What am I looking at?
- Header shows the algorithm (HS256) and token type (JWT).
- Payload shows the claims you put in: sub (user ID), email, role, plus iat (issued at) and exp (expiration) that the library added automatically.
- Signature is the cryptographic proof that the token was created by your server. If anyone changes a single character in the payload, the signature becomes invalid.
Signed does not mean encrypted
Notice that you can read the payload without a password. The payload is Base64-encoded, not encrypted. Anyone who intercepts the token can read its contents.
The signature prevents tampering, not reading. This is why you never put sensitive data (passwords, credit card numbers, personal secrets) inside a JWT.
Your server now issues tokens, but it does not verify them yet. Anyone could send a fake token and claim to be whoever they want. In the next step, you will build the auth middleware that checks every token's signature before granting access.
Protect Routes with Auth Middleware
Your server can issue JWT tokens, but it never checks them. Any request can hit any endpoint, authenticated or not. Tokens are useless unless something stands between the request and the route handler to verify them.
In this step, you will build the middleware that verifies tokens on every incoming request. Then you will create the protected routes that only authenticated users can access.
In this step, get ready to:
- Build the auth middleware that verifies JWT tokens on incoming requests.
- Create a protected /me endpoint that returns the authenticated user's profile.
- Add refresh and logout endpoints to manage the token lifecycle.
Build the auth middleware
Right now, the authMiddleware function in middleware/auth.js is a placeholder that calls next() and lets every request through. A real middleware needs to extract the token from the request header, verify it, and block the request if verification fails.
- Switch back to middleware/auth.js in VS Code.
- Replace the entire file contents with this code:
const jwt = require('jsonwebtoken');
function authMiddleware(req, res, next) {
// Read the Authorization header from the incoming request
const authHeader = req.headers.authorization;
// Reject if the header is missing or doesn't follow the Bearer format
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({ error: 'Authentication required' });
}
// Extract the token string after "Bearer "
const token = authHeader.split(' ')[1];
try {
// Verify the token signature, expiry, and pin the algorithm to HS256
const decoded = jwt.verify(token, process.env.JWT_ACCESS_SECRET, {
algorithms: ['HS256']
});
// Attach the decoded payload so route handlers can access user info
req.user = decoded;
next();
} catch (err) {
// Return a generic error for any failure (expired, tampered, malformed)
return res.status(401).json({ error: 'Invalid or expired token' });
}
}
module.exports = { authMiddleware };
What does this code do?
This middleware runs before any protected route handler. It acts as a gatekeeper for incoming requests.
- It reads the Authorization header and checks it starts with Bearer . This is the standard format for sending tokens in HTTP requests.
- jwt.verify() checks the token's signature and expiry using JWT_ACCESS_SECRET. The algorithms: ['HS256'] option pins the allowed algorithm, preventing the alg: none attack (one of the most famous JWT vulnerabilities).
- On success, the decoded payload (user ID, email, role) is attached to req.user so downstream route handlers can use it.
- On failure, it returns a generic 401 error. Generic messages prevent attackers from learning which specific part of verification failed.
- Save middleware/auth.js.
You will not see a visible change yet. The next substep creates a route that uses this middleware.
✔️ 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 authMiddleware(req, res, next) {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({ error: 'Authentication required' });
}
const token = authHeader.split(' ')[1];
try {
const decoded = jwt.verify(token, process.env.JWT_ACCESS_SECRET, {
algorithms: ['HS256']
});
req.user = decoded;
next();
} catch (err) {
return res.status(401).json({ error: 'Invalid or expired token' });
}
}
module.exports = { authMiddleware };
Create the protected /me route
The /me endpoint is a common pattern in authentication systems. It lets a logged-in user fetch their own profile by sending their access token. The middleware verifies the token, and the route handler looks up the user.
- Switch back to routes/auth.js in VS Code.
- Find the line module.exports = router; at the bottom of the file.
- Add this route above it:
router.get('/me', authMiddleware, (req, res) => {
// Look up the full user record using the ID from the JWT payload
const user = findById(req.user.sub);
if (!user) {
return res.status(404).json({ error: 'User not found' });
}
// Return only public profile fields (no password hash)
res.json({ user: getPublicProfile(user) });
});
What does this code do?
- authMiddleware is passed as the second argument to router.get(). Express runs it before the route callback. If the token is invalid, the request is blocked before reaching the callback.
- req.user.sub contains the user ID from the JWT payload (set by the middleware in the previous substep).
- getPublicProfile() strips out sensitive fields like the password hash, returning only safe data (id, username, email, role).
- Save routes/auth.js.
- Stop the running server by pressing Ctrl+C in the terminal.
- Restart the server by running:
node server.js
Now test that the middleware blocks unauthenticated requests. The in-memory user store resets every time the server restarts, so you will register and login fresh.
Windows users
If you are using PowerShell, type curl.exe instead of curl in every command below. PowerShell aliases curl to a different command that uses different syntax.
If the JSON quoting causes errors, replace single quotes around the -d value with double quotes, and escape the inner double quotes with backslashes: -d "{\"key\":\"value\"}".
- Open a second terminal. In VS Code, click the + icon in the terminal panel to open a new terminal tab.
- Test the /me endpoint without a token by running:
curl http://localhost:3000/api/auth/me
You should see {"error":"Authentication required"}. The middleware blocked the request because no token was sent.
- Register a test user by running:
curl -X POST http://localhost:3000/api/auth/register -H "Content-Type: application/json" -d '{"username":"testuser","email":"test@codearena.dev","password":"securepassword123"}'
- Login to get an access token by running:
curl -X POST http://localhost:3000/api/auth/login -H "Content-Type: application/json" -d '{"email":"test@codearena.dev","password":"securepassword123"}'
The response contains an accessToken field. Copy the token value (the long string after "accessToken":").
- Test /me with the valid token by running this command, replacing <your-access-token> with the token you copied:
curl http://localhost:3000/api/auth/me -H "Authorization: Bearer <your-access-token>"
You should see a JSON response with your user's profile (id, username, email, role). The middleware verified the token and let the request through.
Getting 401 even with a token?
- Make sure you copied the entire token string, with no extra spaces or missing characters.
- Check that the Authorization header has a space between Bearer and the token.
- If you restarted the server between login and the /me request, the in-memory user store was wiped. Register and login again to get a fresh token.
Help me debug my auth middleware 401 error
Add refresh and logout endpoints
Access tokens expire after 15 minutes by design. Without a way to get a new one, users would have to log in again every 15 minutes. The refresh endpoint solves this by issuing a new access token using the longer-lived refresh token.
The logout endpoint lets users revoke their refresh token, effectively ending their session.
- In routes/auth.js, find the /me route you just added.
- Add the refresh endpoint below it (still above module.exports = router;):
router.post('/refresh', (req, res) => {
const { refreshToken } = req.body;
if (!refreshToken) {
return res.status(400).json({ error: 'Refresh token is required' });
}
try {
// Verify the refresh token with the separate refresh secret
const decoded = jwt.verify(refreshToken, process.env.JWT_REFRESH_SECRET, {
algorithms: ['HS256']
});
// Look up the user to confirm they still exist
const user = findById(decoded.sub);
if (!user) {
return res.status(401).json({ error: 'Invalid refresh token' });
}
// Check the refresh token is still in the user's stored tokens
const tokenExists = user.refreshTokens.includes(refreshToken);
if (!tokenExists) {
return res.status(401).json({ error: 'Refresh token revoked' });
}
// Issue a brand new access token
const tokenPayload = { sub: user.id, email: user.email, role: user.role };
const newAccessToken = signAccessToken(tokenPayload);
res.json({ accessToken: newAccessToken });
} catch (err) {
return res.status(401).json({ error: 'Invalid or expired refresh token' });
}
});
What does this code do?
- It verifies the refresh token using JWT_REFRESH_SECRET (a separate secret from the access token). This means a stolen access token cannot be used to generate new tokens.
- It checks that the refresh token still exists in the user's refreshTokens array. If it was removed by the logout endpoint, the request is rejected with a "revoked" error.
- On success, it issues a fresh access token without requiring the user to re-enter their password.
You will not see a change yet. The next chunk adds the logout endpoint, then you will test both together.
- Add the logout endpoint below the /refresh route (still above module.exports = router;):
router.post('/logout', authMiddleware, (req, res) => {
const { refreshToken } = req.body;
// Find the user and remove the refresh token from their stored list
const user = findById(req.user.sub);
if (user && refreshToken) {
user.refreshTokens = user.refreshTokens.filter(t => t !== refreshToken);
}
res.json({ message: 'Logged out successfully' });
});
What does this code do?
- The logout route is protected by authMiddleware. Only authenticated users can log out (you need a valid access token to call this endpoint).
- It removes the refresh token from the user's refreshTokens array using .filter(). After this, any attempt to use that refresh token to get a new access token will fail.
- Save routes/auth.js.
- Switch to the terminal tab running the server. Press Ctrl+C to stop it, then restart by running:
node server.js
- Switch to your second terminal tab.
- Register and login again (the in-memory store was wiped on restart) by running:
curl -X POST http://localhost:3000/api/auth/register -H "Content-Type: application/json" -d '{"username":"testuser","email":"test@codearena.dev","password":"securepassword123"}'
curl -X POST http://localhost:3000/api/auth/login -H "Content-Type: application/json" -d '{"email":"test@codearena.dev","password":"securepassword123"}'
Copy both the accessToken and refreshToken values from the login response. You will need both for the next commands.
- Test the refresh endpoint by running this command, replacing <your-refresh-token> with the refresh token you copied:
curl -X POST http://localhost:3000/api/auth/refresh -H "Content-Type: application/json" -d '{"refreshToken":"<your-refresh-token>"}'
You should see a new accessToken in the response. The server issued a fresh access token without asking for the password again.
- Test the logout endpoint by running this command, replacing both token placeholders:
curl -X POST http://localhost:3000/api/auth/logout -H "Content-Type: application/json" -H "Authorization: Bearer <your-access-token>" -d '{"refreshToken":"<your-refresh-token>"}'
You should see {"message":"Logged out successfully"}. The refresh token has been revoked.
- Confirm the revocation works by trying the refresh endpoint again with the same refresh token:
curl -X POST http://localhost:3000/api/auth/refresh -H "Content-Type: application/json" -d '{"refreshToken":"<your-refresh-token>"}'
This time you should see {"error":"Refresh token revoked"}. The logout endpoint removed the token from the user's stored list, so the server rejects it.
Refresh or logout returning unexpected errors?
- If you see a 400 error on /refresh, make sure the request body uses the key refreshToken (camelCase, not refresh_token).
- If /logout returns 401, you need a valid access token in the Authorization header. Logout requires authentication.
- Make sure you did not restart the server between login and testing. Restarting wipes the in-memory store and invalidates all tokens.
Help me debug my refresh and logout endpoints
✔️ Awesome, I've got everything!
Great. Double check you have saved routes/auth.js before moving on.
ⓧ I'd like to double check the full code
const express = require('express');
const bcrypt = require('bcryptjs');
const jwt = require('jsonwebtoken');
const { findByEmail, findById, createUser, getPublicProfile } = require('../data/users');
const { authMiddleware } = require('../middleware/auth');
const router = express.Router();
function signAccessToken(payload) {
return jwt.sign(payload, process.env.JWT_ACCESS_SECRET, {
algorithm: 'HS256',
expiresIn: process.env.JWT_ACCESS_TTL || '15m'
});
}
function signRefreshToken(payload) {
return jwt.sign(payload, process.env.JWT_REFRESH_SECRET, {
algorithm: 'HS256',
expiresIn: process.env.JWT_REFRESH_TTL || '7d'
});
}
router.post('/register', async (req, res) => {
const { username, email, password } = req.body;
if (!username || !email || !password) {
return res.status(400).json({ error: 'Username, email, and password are required' });
}
if (password.length < 8) {
return res.status(400).json({ error: 'Password must be at least 8 characters' });
}
const existing = findByEmail(email);
if (existing) {
return res.status(409).json({ error: 'Email already registered' });
}
const passwordHash = await bcrypt.hash(password, 12);
const user = createUser({ username, email, passwordHash });
res.status(201).json({
message: 'User registered successfully',
user: getPublicProfile(user)
});
});
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 = findByEmail(email);
if (!user) {
return res.status(401).json({ error: 'Invalid credentials' });
}
const isMatch = await bcrypt.compare(password, user.passwordHash);
if (!isMatch) {
return res.status(401).json({ error: 'Invalid credentials' });
}
const tokenPayload = { sub: user.id, email: user.email, role: user.role };
const accessToken = signAccessToken(tokenPayload);
const refreshToken = signRefreshToken(tokenPayload);
user.refreshTokens.push(refreshToken);
res.json({
accessToken,
refreshToken,
user: getPublicProfile(user)
});
});
router.get('/me', authMiddleware, (req, res) => {
const user = findById(req.user.sub);
if (!user) {
return res.status(404).json({ error: 'User not found' });
}
res.json({ user: getPublicProfile(user) });
});
router.post('/refresh', (req, res) => {
const { refreshToken } = req.body;
if (!refreshToken) {
return res.status(400).json({ error: 'Refresh token is required' });
}
try {
const decoded = jwt.verify(refreshToken, process.env.JWT_REFRESH_SECRET, {
algorithms: ['HS256']
});
const user = findById(decoded.sub);
if (!user) {
return res.status(401).json({ error: 'Invalid refresh token' });
}
const tokenExists = user.refreshTokens.includes(refreshToken);
if (!tokenExists) {
return res.status(401).json({ error: 'Refresh token revoked' });
}
const tokenPayload = { sub: user.id, email: user.email, role: user.role };
const newAccessToken = signAccessToken(tokenPayload);
res.json({ accessToken: newAccessToken });
} catch (err) {
return res.status(401).json({ error: 'Invalid or expired refresh token' });
}
});
router.post('/logout', authMiddleware, (req, res) => {
const { refreshToken } = req.body;
const user = findById(req.user.sub);
if (user && refreshToken) {
user.refreshTokens = user.refreshTokens.filter(t => t !== refreshToken);
}
res.json({ message: 'Logged out successfully' });
});
module.exports = router;
Your API now has a complete auth lifecycle. Users can register, login, access protected routes, refresh their tokens, and log out. Next up, you will wire everything together and run through the full flow end-to-end with a proper test file.
Test the Complete Auth Flow
You now have every piece of the authentication puzzle in place. Registration hashes passwords. Login issues JWT tokens. Middleware guards protected routes. Refresh and logout manage sessions.
In this step, you will wire everything together and run the full authentication lifecycle end-to-end. You will use the REST Client extension for VS Code so you can send HTTP requests directly from a file, without leaving your editor.
In this step, get ready to:
- Install the REST Client extension and create a test request file.
- Run the complete auth flow and verify every response.
- Review a security checklist to confirm your API follows best practices.
Install the REST Client extension and create test requests
Testing APIs usually means switching to a separate tool like Postman. The REST Client extension lets you send HTTP requests directly from a .http file inside VS Code.
- Open the Extensions sidebar by pressing Cmd+Shift+X (macOS) or Ctrl+Shift+X (Windows).
- Type REST Client in the search bar.
- Find the extension with the ID humao.rest-client and click Install.
What is REST Client?
REST Client is a VS Code extension that recognizes .http files and adds a clickable Send Request link above each request. You click the link, and the response appears in a new editor pane. No app switching required.
- Create a new file called requests.http in the root of your codearena-auth folder (at the same level as server.js).
The first two requests in this file handle registration and login. These do not require any token because the user is not authenticated yet.
- Add the register and login requests to requests.http by copying this code:
### Register a new user
POST http://localhost:3000/api/auth/register
Content-Type: application/json
{
"username": "testuser",
"email": "test@codearena.dev",
"password": "securepassword123"
}
### Login
POST http://localhost:3000/api/auth/login
Content-Type: application/json
{
"email": "test@codearena.dev",
"password": "securepassword123"
}
What does this code do?
Each ### separator marks the start of a new request. REST Client reads each block as an independent HTTP request you can send on its own.
The register request sends a username, email, and password to your /register endpoint. The login request sends the same credentials to /login to get back tokens.
The remaining requests all require authentication. They use <token> and <refresh-token> as placeholders that you will replace with real values after logging in.
- Add the protected route requests below the login request in requests.http:
### Get profile (replace <token> with access token from login)
GET http://localhost:3000/api/auth/me
Authorization: Bearer <token>
### Refresh access token (replace <refresh-token> with refresh token from login)
POST http://localhost:3000/api/auth/refresh
Content-Type: application/json
{
"refreshToken": "<refresh-token>"
}
### Logout (replace both tokens)
POST http://localhost:3000/api/auth/logout
Authorization: Bearer <token>
Content-Type: application/json
{
"refreshToken": "<refresh-token>"
}
### Try accessing /me after logout (should fail after token expires)
GET http://localhost:3000/api/auth/me
Authorization: Bearer <token>
What does this code do?
- The /me request sends the access token in an Authorization: Bearer header. The auth middleware reads this header, verifies the token, and returns the user profile.
- The /refresh request sends the refresh token in the body to get a new access token without re-entering the password.
- The /logout request removes the refresh token from the server, revoking that session.
- The final /me request is a post-logout test. It uses the same access token, which still works until it expires (access tokens are stateless). The refresh token is what was revoked.
- Save requests.http.
- You should see Send Request links appearing above each ### separator in the file.
Don't see Send Request links?
Make sure you installed the REST Client extension and that the file is saved with a .http extension. Check the bottom-right corner of VS Code and confirm the language mode shows HTTP. If it shows something else, click it and select HTTP from the list.
Help me troubleshoot my REST Client setup.
✔️ Awesome, I've got everything!
Great. Make sure requests.http is saved before moving on.
ⓧ I'd like to double check the full code
Here is the complete requests.http file:
### Register a new user
POST http://localhost:3000/api/auth/register
Content-Type: application/json
{
"username": "testuser",
"email": "test@codearena.dev",
"password": "securepassword123"
}
### Login
POST http://localhost:3000/api/auth/login
Content-Type: application/json
{
"email": "test@codearena.dev",
"password": "securepassword123"
}
### Get profile (replace <token> with access token from login)
GET http://localhost:3000/api/auth/me
Authorization: Bearer <token>
### Refresh access token (replace <refresh-token> with refresh token from login)
POST http://localhost:3000/api/auth/refresh
Content-Type: application/json
{
"refreshToken": "<refresh-token>"
}
### Logout (replace both tokens)
POST http://localhost:3000/api/auth/logout
Authorization: Bearer <token>
Content-Type: application/json
{
"refreshToken": "<refresh-token>"
}
### Try accessing /me after logout (should fail after token expires)
GET http://localhost:3000/api/auth/me
Authorization: Bearer <token>
Run the complete auth flow and test failure cases
Your test file is ready. Now you will run each request in order and verify that every response matches expectations.
Why does the order matter?
The in-memory user store resets every time you restart the server. You need to register first, then login to get tokens, then use those tokens for the protected routes. If you restart the server mid-flow, start again from the register request.
- Make sure your server is still running in the terminal from earlier. If it stopped, start it again by running:
node server.js
- In requests.http, click the Send Request link above the ### Register a new user section.
- A response pane opens on the right. You should see a 201 Created status with a JSON body containing "message": "User registered successfully" and the user's public profile (id, username, email, role).
- Click Send Request above the ### Login section.
- You should see a 200 OK response containing three fields: accessToken, refreshToken, and user.
- Copy the accessToken value from the response (the long string after "accessToken":). Do not include the surrounding quotes.
- In requests.http, replace every <token> placeholder with the access token you just copied.
- Copy the refreshToken value from the response.
- Replace every <refresh-token> placeholder with the refresh token you just copied.
Use find and replace
Press Cmd+H (macOS) or Ctrl+H (Windows) to open find and replace. Search for <token> and replace all occurrences with your access token at once.
- Click Send Request above ### Get profile.
- You should see a 200 OK response with your user's profile data: id, username, email, role, and createdAt.
- Click Send Request above ### Refresh access token.
- You should see a 200 OK response with a new accessToken value.
- Click Send Request above ### Logout.
- You should see a 200 OK response with "message": "Logged out successfully".
The happy path works. Now test the failure cases to make sure your API rejects bad requests correctly.
- Click Send Request on the ### Register a new user section again (same email as before).
- You should see a 409 Conflict response with "error": "Email already registered".
- Temporarily change the password in the ### Login section to wrongpassword123 and click Send Request.
- You should see a 401 Unauthorized response with "error": "Invalid credentials". Change the password back to securepassword123 afterwards.
- Delete the access token from the Authorization header in the ### Get profile section so it just says Authorization: Bearer with nothing after it. Click Send Request.
- You should see a 401 Unauthorized response with "error": "Authentication required".
Why are all the error messages generic?
Notice that both wrong-password and wrong-email logins return the same message: "Invalid credentials". If the API said "email not found" versus "wrong password," an attacker could use that information to figure out which emails are registered. Generic messages prevent this kind of information leakage.
Review the security checklist
Your auth flow works end-to-end. Before wrapping up, take a moment to verify your API follows security best practices.
Run through this checklist and confirm each item in your own code:
- Passwords are hashed with bcryptjs at cost factor 12. No plain text passwords are ever stored.
- Access tokens are short-lived (15m as set in .env). If a token is stolen, it only works for 15 minutes.
- Refresh tokens are stored server-side in the user's refreshTokens array, so they can be revoked on logout.
- The algorithm is pinned to HS256 on every jwt.verify() call. This prevents the alg: none downgrade attack.
- Error messages are generic ("Invalid credentials") and do not leak information about which field was wrong.
- Secrets come from environment variables in .env, not hardcoded in the source code.
What is the alg: none attack?
JWTs have a header that declares the signing algorithm. If a server does not enforce a specific algorithm, an attacker can craft a token with "alg": "none" and remove the signature entirely. The server accepts the unsigned token as valid.
Pinning algorithms: ['HS256'] on jwt.verify() tells the library to reject any token that claims to use a different algorithm. This is one of the most important JWT security practices.
Secret mission
Role-Based Access Control
Your auth system can verify who someone is. But can it control what they're allowed to do? Add role-based access control so only admins can create coding problems on CodeArena.
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:
- codearena-auth project folder on your Desktop (includes all source files, node_modules, and .env).
- Node.js server process running on port 3000.
- REST Client VS Code extension (humao.rest-client).
Keep everything running
No action needed. Choose this if you plan to continue building on CodeArena (Project 2 adds a MongoDB database to replace the in-memory store).
The server only runs while your terminal is open. Closing your terminal or pressing Ctrl+C stops it automatically with no side effects.
- Your project files stay in the codearena-auth folder on your Desktop.
- The REST Client extension stays installed in VS Code for future API testing.
Pause - I'll come back to this later
Shut down the running server to free up port 3000. Your project files stay on disk so you can pick up where you left off.
- Switch back to the terminal where the server is running.
- Press Ctrl+C to stop the server.
To restart later, open a terminal in the codearena-auth folder and run:
node server.js
Delete - I don't want to use this again
Remove all project files and start fresh. This deletes everything including your source code and installed packages.
- Switch back to the terminal where the server is running.
- Press Ctrl+C to stop the server.
Delete the project folder by running this command:
macOS
cd ~/Desktop
rm -rf codearena-auth
Windows
cd %USERPROFILE%\Desktop
rmdir /s /q codearena-auth
Optionally, uninstall the REST Client extension:
- Open the Extensions panel in VS Code by pressing Cmd+Shift+X (macOS) or Ctrl+Shift+X (Windows).
- Search for REST Client in the search bar.
- Click the gear icon next to the extension and select Uninstall.
Nice Work!
Nice Work!
Nice work! You just built a production-patterned authentication API for CodeArena using Express 5, bcryptjs, and jsonwebtoken.
You've learned how to:
- Hash and verify passwords with bcryptjs, experiencing firsthand why plain text storage is dangerous and how salting and intentional slowness defeat rainbow tables and brute force attacks.
- Implement JWT-based stateless authentication with short-lived access tokens, refresh tokens, and a logout flow that revokes sessions.
- Build reusable auth middleware that protects routes by verifying JWT signatures with pinned algorithms, preventing the infamous alg: none attack.
- Secret Mission: Added role-based access control with a reusable requireRole middleware factory so only admins can create coding problems.
Ready to quiz yourself?