Build a Multi-Strategy Auth Server

Implement sessions, JWTs, OAuth 2.0, and API keys in one Express server.

Introduction

30 Second Summary

Every time you log into an app, something is verifying your identity behind the scenes. Sometimes it's a session cookie. Sometimes it's a token. Sometimes it's a "Login with GitHub" button handing off trust to a third party.

In this project, you will build a single Express server that authenticates users via four different strategies: sessions, JWTs, OAuth 2.0, and API keys.

What You'll Build

A running authentication server you can demo live in an interview, switching between four auth strategies and explaining the security tradeoffs with running code as your proof.

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

  • A protected dashboard you unlock by logging in with email and password, secured by bcrypt password hashing and httpOnly session cookies.
  • A stateless JWT flow and an API key flow running on separate endpoints, showing how token-based and machine-to-machine auth differ from sessions.
  • A complete GitHub OAuth 2.0 login that redirects you through the authorization code flow and returns your real GitHub profile.
  • Secret Mission: Add refresh token rotation with reuse detection to harden JWT authentication for production use.

Are there any prerequisites?

This project assumes you're comfortable with JavaScript and Node.js basics. You should also understand core HTTP concepts like requests, responses, headers, and status codes.

Before We Start

Before we dive in, let's lock in what you're about to build and why it matters.

Understanding multiple authentication strategies is one of the most common topics in backend engineering interviews. This project gives you running code to walk through each one.

Set Up the Project

Authentication is never just one thing. Your server will need to handle browser sessions, signed tokens, third-party logins, and machine-to-machine keys. Each strategy requires its own library.

In this step, you will scaffold the Node.js project, install the six packages your auth server depends on, and wire up a minimal Express server that proves everything is connected.

In this step, get ready to:
  • Install auth-related dependencies (express, express-session, jsonwebtoken, bcrypt, cookie-parser, dotenv).
  • Create the project file structure with placeholder route files.
  • Configure and start the base Express server with a health-check endpoint.
Create the project and install dependencies
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder and move into it by running these commands:
mkdir auth-explorer
cd auth-explorer
  • Initialize a new Node.js project by running:
npm init -y

You should see a message starting with Wrote to followed by the path to your new package.json file.

  • Install all six project dependencies by running:
npm install express@5.2.1 express-session@1.19.0 jsonwebtoken@9.0.3 bcrypt@6.0.0 cookie-parser@1.4.7 dotenv@17.4.2

What are these packages?

  • express is the web framework that handles HTTP requests and routing.
  • express-session stores login state on the server, tracked by a cookie.
  • jsonwebtoken creates and verifies signed JWT tokens.
  • bcrypt hashes passwords so they are never stored in plain text.
  • cookie-parser reads cookies from incoming requests.
  • dotenv loads environment variables from a .env file.
  • Confirm Express installed correctly by running:
node -e "const express = require('express'); console.log('Express', require('express/package.json').version)"

You should see Express 5.2.1 printed in your terminal.

Seeing 'Cannot find module' errors?

  • Make sure you are inside the auth-explorer folder when you run the command. The node_modules directory must be in your current working directory.
  • Re-run the install command if the npm install output showed errors earlier.

Still stuck? Help me fix a 'Cannot find module' error after running npm install in my auth-explorer project.

Create the file structure

Your project needs a main server file, an environment config file, and a folder with route files for each auth strategy.

  • Create the routes directory and all project files by running:
mkdir routes
touch server.js .env routes/auth.js routes/jwt.js routes/github.js routes/apikey.js
  • Confirm the files were created by running:
ls routes

You should see apikey.js, auth.js, github.js, and jwt.js listed.

  • Open the auth-explorer folder in your code editor.
  • Open .env and add the following environment variables:
PORT=3000
SESSION_SECRET=auth-explorer-dev-secret-change-in-production
JWT_SECRET=jwt-signing-secret-change-in-production
GITHUB_CLIENT_ID=your-github-client-id-here
GITHUB_CLIENT_SECRET=your-github-client-secret-here

What are these values?

  • SESSION_SECRET signs the session cookie so it cannot be tampered with.
  • JWT_SECRET signs your JSON Web Tokens.
  • The GitHub values are placeholders. You will fill them in when you set up OAuth in a later step.
  • Open each file in the routes folder (auth.js, jwt.js, github.js, apikey.js) and add this single line to each:
// placeholder
  • Save all five files (.env and the four route files).
Build the base Express server

With all dependencies installed and files in place, you can now wire up the Express app with the middleware it needs to parse requests and manage sessions.

  • Open server.js and add the following code:
require('dotenv').config();
const express = require('express');
const session = require('express-session');
const cookieParser = require('cookie-parser');

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

// Parse incoming JSON request bodies
app.use(express.json());
// Parse cookies attached to incoming requests
app.use(cookieParser());
// Configure session middleware for login state
app.use(session({
  secret: process.env.SESSION_SECRET,
  resave: false,
  saveUninitialized: false,
  cookie: {
    httpOnly: true,
    sameSite: 'lax',
    secure: false,
    maxAge: 1000 * 60 * 30
  }
}));

What does this code do?

  • dotenv loads your .env variables into process.env so the app can read secrets without hardcoding them.
  • express.json() parses JSON bodies from POST requests.
  • The session config stores login state on the server, tracked by a cookie that lasts 30 minutes.
  • httpOnly: true prevents JavaScript in the browser from reading the session cookie, blocking XSS attacks.
  • Below the session middleware, add the health-check route and the server start:
// Health check endpoint to confirm the server is running
app.get('/health', (req, res) => {
  res.json({ status: 'ok', timestamp: new Date().toISOString() });
});

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

What does this code do?

  • GET /health returns a JSON response proving the server is alive. You will use this to test your setup in a moment.
  • app.listen starts the server on port 3000 and prints a confirmation message to the terminal.
  • Save server.js.

Before you run the server, what do you expect to see printed in the terminal?

  • Start the server by running:
node server.js

You should see Auth Explorer running on http://localhost:3000 printed in your terminal.

  • Open a second terminal tab (keep the server running in the first one).
  • Test the health endpoint by running:
curl http://localhost:3000/health

You should see a JSON response like {"status":"ok","timestamp":"..."} with the current time.

Not seeing a response from curl?

  • Check that node server.js is still running in your first terminal tab. If it crashed, read the error message for a clue.
  • Make sure your .env file is saved and contains SESSION_SECRET. A missing secret causes the session middleware to throw on startup.
  • Confirm you are curling port 3000, not a different number.

Still stuck? Help me debug why my Express server is not responding on localhost:3000.

  • Stop the server with Ctrl+C in the first terminal tab when you are done testing.

✔️ Awesome, I've got everything!

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

ⓧ I'd like to double check the full code

Here is the complete server.js at this point:

require('dotenv').config();
const express = require('express');
const session = require('express-session');
const cookieParser = require('cookie-parser');

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

app.use(express.json());
app.use(cookieParser());
app.use(session({
  secret: process.env.SESSION_SECRET,
  resave: false,
  saveUninitialized: false,
  cookie: {
    httpOnly: true,
    sameSite: 'lax',
    secure: false,
    maxAge: 1000 * 60 * 30
  }
}));

app.get('/health', (req, res) => {
  res.json({ status: 'ok', timestamp: new Date().toISOString() });
});

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

And here is your .env file:

PORT=3000
SESSION_SECRET=auth-explorer-dev-secret-change-in-production
JWT_SECRET=jwt-signing-secret-change-in-production
GITHUB_CLIENT_ID=your-github-client-id-here
GITHUB_CLIENT_SECRET=your-github-client-secret-here

Your project scaffold is ready and the server responds to requests. Next up, you will build a login system with plain-text passwords to see exactly why that approach is dangerous.

Build the Naive Login (The Problem)

Your Express server is running and healthy. Now it needs to do something useful: authenticate users.

In this step, you will build a registration and login system that stores passwords as plain text. This is intentionally insecure. You will see the vulnerability with your own eyes before fixing it in the next step.

In this step, get ready to:
  • Build registration and login routes that store passwords as plain strings.
  • Protect a dashboard route with session-based authentication.
  • Expose the vulnerability by inspecting stored user data.
Create the registration and login routes

Every authentication system starts with two operations: letting users register, and letting them log back in. You will build both inside routes/auth.js.

  • Open routes/auth.js in your editor and replace the placeholder comment with this code:
const express = require('express');
const router = express.Router();

// Store users in memory (no database needed for learning)
const users = [];

// Register a new user with email and password
router.post('/register', (req, res) => {
  const { email, password } = req.body;
  if (!email || !password) {
    return res.status(400).json({ error: 'Email and password required' });
  }
  const existing = users.find(u => u.email === email);
  if (existing) {
    return res.status(409).json({ error: 'User already exists' });
  }
  // Store the password exactly as the user typed it
  const user = { id: users.length + 1, email, password };
  users.push(user);
  res.status(201).json({ message: 'User registered', userId: user.id });
});

module.exports = router;

What does this code do?

This sets up an Express router with an in-memory users array acting as your database.

  • POST /register extracts email and password from the request body, checks for duplicates, then stores the user object with the password as-is.
  • The password field is stored in plain text. This is the vulnerability you will expose later.

Now you need to tell your server about this new route file. The server currently has no idea routes/auth.js exists.

  • In server.js, find this line near the top:
const cookieParser = require('cookie-parser');
  • Add this line directly below it:
const authRoutes = require('./routes/auth');
  • Now find the /health route in server.js:
app.get('/health', (req, res) => {
  res.json({ status: 'ok', timestamp: new Date().toISOString() });
});
  • Add this line directly below the closing }); of the health route:
app.use('/auth', authRoutes);

What does mounting do?

The app.use('/auth', authRoutes) line mounts everything in your router under the /auth prefix. So router.post('/register') becomes POST /auth/register on the running server.

  • Save both routes/auth.js and server.js.
  • Stop any running server with Ctrl+C, then start it fresh by running:
node server.js
  • Open a second terminal and test registration by running:
curl -X POST http://localhost:3000/auth/register -H "Content-Type: application/json" -d '{"email":"test@example.com","password":"mypassword123"}'

You should see a JSON response confirming the registration:

{"message":"User registered","userId":1}

Seeing "Cannot POST /auth/register"?

  • Make sure you saved both server.js and routes/auth.js before restarting the server.
  • Check that app.use('/auth', authRoutes) appears after the session middleware setup in server.js.
  • Confirm routes/auth.js ends with module.exports = router;.

Help me debug my registration route.

Now add the login route. When a user logs in, the server compares passwords and issues a session cookie.

  • In routes/auth.js, find the line module.exports = router; and add the following code above it:
// Log in with email and password, receive a session cookie
router.post('/login', (req, res) => {
  const { email, password } = req.body;
  const user = users.find(u => u.email === email);
  // Plain-text comparison: does the string match exactly?
  if (!user || user.password !== password) {
    return res.status(401).json({ error: 'Invalid credentials' });
  }
  req.session.userId = user.id;
  res.json({ message: 'Logged in', userId: user.id });
});

What does this code do?

The login route finds the user by email, then directly compares the stored password string to the one the user typed. If they match, it sets req.session.userId which creates a session cookie the browser will send on future requests.

Notice user.password !== password is a plain string comparison. This works, but it means whoever has access to the users array can read every password.

  • Save routes/auth.js.
  • Stop the server with Ctrl+C and restart by running:
node server.js
  • In your second terminal, register a fresh user and then log in by running these commands:
curl -X POST http://localhost:3000/auth/register -H "Content-Type: application/json" -d '{"email":"alice@example.com","password":"secret123"}'
curl -X POST http://localhost:3000/auth/login -H "Content-Type: application/json" -d '{"email":"alice@example.com","password":"secret123"}' -c cookies.txt

You should see {"message":"Logged in","userId":1} and a cookies.txt file now exists in your directory holding the session cookie.

What is the -c flag?

The -c cookies.txt flag tells curl to save any cookies the server sends into a file called cookies.txt. You will use -b cookies.txt in the next request to send those cookies back, proving you are logged in.

Protect a route with session authentication

A login is only useful if it unlocks something. You will create a /dashboard route that only responds to users with a valid session.

  • In routes/auth.js, find the module.exports = router; line and add this code above it:
// Protected route: only accessible with a valid session
router.get('/dashboard', (req, res) => {
  if (!req.session.userId) {
    return res.status(401).json({ error: 'Not authenticated' });
  }
  const user = users.find(u => u.id === req.session.userId);
  res.json({ message: 'Welcome to dashboard', email: user.email });
});

What does this code do?

This route checks req.session.userId before responding. If the session does not contain a user ID (because the request has no valid cookie), it returns a 401.

This is session-based authentication in action. The server stores login state and verifies it on every protected request.

  • Save routes/auth.js, stop the server with Ctrl+C, and restart by running:
node server.js
  • In your second terminal, register, log in, and then access the dashboard by running:
curl -X POST http://localhost:3000/auth/register -H "Content-Type: application/json" -d '{"email":"bob@example.com","password":"pass456"}'
curl -X POST http://localhost:3000/auth/login -H "Content-Type: application/json" -d '{"email":"bob@example.com","password":"pass456"}' -c cookies.txt
curl http://localhost:3000/auth/dashboard -b cookies.txt

You should see {"message":"Welcome to dashboard","email":"bob@example.com"}.

  • Now try accessing the dashboard without cookies to confirm it rejects unauthenticated requests:
curl http://localhost:3000/auth/dashboard

You should see {"error":"Not authenticated"} with a 401 status. The session middleware is working.

Dashboard returning 401 even with cookies?

  • Make sure you registered a new user after restarting the server. The in-memory array resets on every restart.
  • Check that the -c cookies.txt flag is on the login command and -b cookies.txt is on the dashboard command.
  • Verify you are running all three curl commands in the same terminal (so cookies.txt is in the same directory).

Help me debug my session-protected dashboard.

Expose the vulnerability

Your login works. But there is a serious security flaw hiding in the users array. You will add a debug route that exposes it.

  • In routes/auth.js, find the module.exports = router; line and add this code above it:
// Expose all stored users (for debugging only)
router.get('/debug/users', (req, res) => {
  res.json(users);
});
  • Now update the exports at the bottom of the file. Replace module.exports = router; with these two lines:
module.exports = router;
module.exports.users = users;

Why export the users array?

Later steps will reuse the same user store for JWT authentication. Exporting users lets other route files require it without duplicating the data.

  • Save routes/auth.js, stop the server with Ctrl+C, and restart by running:
node server.js

Before you test: you registered a user with the password pass456. When you hit the debug endpoint, what do you think the password field will contain?

  • In your second terminal, register a user and then hit the debug endpoint by running:
curl -X POST http://localhost:3000/auth/register -H "Content-Type: application/json" -d '{"email":"charlie@example.com","password":"supersecret"}'
curl http://localhost:3000/auth/debug/users

You should see something like this:

[{"id":1,"email":"charlie@example.com","password":"supersecret"}]

There it is. The password supersecret is sitting in plain text, exactly as the user typed it.

This is the problem

If anyone gains access to your data store (a database leak, a backup left on a public server, a disgruntled admin), every single password is immediately readable. No cracking required.

In a real system, this means every user's account is compromised instantly. This is exactly what bcrypt solves, and you will fix it in the next step.

  • Try logging in with the wrong password to confirm the comparison works:
curl -X POST http://localhost:3000/auth/login -H "Content-Type: application/json" -d '{"email":"charlie@example.com","password":"wrongpassword"}'

You should see {"error":"Invalid credentials"} with a 401 status. The string comparison catches mismatches, but storing the correct answer in readable form is the vulnerability.

✔️ Awesome, I've got everything!

Great. Make sure both routes/auth.js and server.js are saved.

ⓧ I'd like to double check the full code

Scroll up and confirm your routes/auth.js contains each of these sections in order:

  • The require/setup block with const users = [] at the top.
  • The POST /register route that stores the user with a plain-text password.
  • The POST /login route that compares passwords and sets req.session.userId.
  • The GET /dashboard route that checks for a valid session.
  • The GET /debug/users route that dumps the array.
  • The module.exports = router and module.exports.users = users lines at the bottom.

For server.js, confirm you have:

  • const authRoutes = require('./routes/auth') near the top after the cookieParser require.
  • app.use('/auth', authRoutes) after the /health route.

You have a working login system, but it is holding passwords the way a sticky note on a monitor does. In the next step, you will replace this with bcrypt hashing so that even a full database leak reveals nothing useful.

Fix It with Bcrypt (Session Auth Done Right)

In the last step, you saw your password sitting in plain text inside the users array. If that were a real database and it got breached, every single password would be instantly exposed.

Bcrypt solves this by turning passwords into irreversible hashes. Even if an attacker steals the entire database, they cannot reverse a bcrypt hash back into the original password. This step fixes the vulnerability you just witnessed.

In this step, get ready to:
  • Replace plain-text password storage with bcrypt hashing.
  • Understand the session cookie security flags protecting your users.
  • Test the full session lifecycle from registration through logout.
Replace plain-text passwords with bcrypt hashes

The bcrypt library is already installed from Step 1. You need to require it in your auth routes and use it in two places: hashing during registration, and comparing during login.

  • In routes/auth.js, find this line at the top of the file:
const express = require('express');
  • Add the bcrypt require on the next line, so the top of your file looks like this:
const express = require('express');
const bcrypt = require('bcrypt');
  • Now find your existing /register route and replace it entirely with this updated version:
router.post('/register', async (req, res) => {
  const { email, password } = req.body;
  if (!email || !password) {
    return res.status(400).json({ error: 'Email and password required' });
  }
  const existing = users.find(u => u.email === email);
  if (existing) {
    return res.status(409).json({ error: 'User already exists' });
  }
  // Hash the password so it can never be read back
  const hashedPassword = await bcrypt.hash(password, 12);
  const user = { id: users.length + 1, email, password: hashedPassword };
  users.push(user);
  res.status(201).json({ message: 'User registered', userId: user.id });
});

What changed here?

Two key changes make this route secure.

  • async was added to the route handler because bcrypt.hash() returns a Promise. The await keyword pauses execution until hashing completes.
  • bcrypt.hash(password, 12) generates a one-way hash with 12 salt rounds. The number 12 means bcrypt runs 2^12 (4,096) iterations, making brute-force attacks computationally expensive.
  • Save routes/auth.js.
  • Switch back to your terminal and stop the running server with Ctrl+C.
  • Restart the server by running:
node server.js
  • Register a new user to test the bcrypt hashing by running:
curl -X POST http://localhost:3000/auth/register -H "Content-Type: application/json" -d '{"email":"secure@test.com","password":"mypassword123"}'
  • Now inspect the stored password by running:
curl http://localhost:3000/auth/debug/users

Instead of seeing mypassword123 in plain text, you should now see a long string starting with $2b$. That prefix confirms bcrypt hashed the password successfully.

Still seeing the password in plain text?

  • Make sure you saved routes/auth.js AND restarted the server. Node.js does not auto-reload on file changes.
  • Check that your require('bcrypt') line is at the top of the file, not inside a route handler.
  • help me debug why bcrypt isn't hashing my password in the register route

Now you need to update the login route to compare the incoming password against the stored hash instead of doing a plain-text comparison.

  • In routes/auth.js, find your existing /login route and replace it with this updated version:
router.post('/login', async (req, res) => {
  const { email, password } = req.body;
  const user = users.find(u => u.email === email);
  if (!user) {
    return res.status(401).json({ error: 'Invalid credentials' });
  }
  // Compare the plain-text password against the stored hash
  const valid = await bcrypt.compare(password, user.password);
  if (!valid) {
    return res.status(401).json({ error: 'Invalid credentials' });
  }
  req.session.userId = user.id;
  res.json({ message: 'Logged in', userId: user.id });
});

Why split the user check from the password check?

The original login combined both checks into one line: !user || user.password !== password. With bcrypt, you cannot do a simple !== comparison because the hash is different every time.

bcrypt.compare() takes the plain-text password and the stored hash, then internally re-hashes the input with the same salt to check if they match. The user-existence check must happen first so you do not call bcrypt.compare() on a null value.

  • Save routes/auth.js, stop the server with Ctrl+C, and restart it:
node server.js
  • Register a fresh user and then log in to verify bcrypt comparison works:
curl -X POST http://localhost:3000/auth/register -H "Content-Type: application/json" -d '{"email":"bcrypt@test.com","password":"secret456"}'
curl -X POST http://localhost:3000/auth/login -H "Content-Type: application/json" -d '{"email":"bcrypt@test.com","password":"secret456"}'

You should see {"message":"Logged in","userId":...} confirming that bcrypt can verify the password against the hash.

Getting 401 Invalid credentials on login?

  • Because the server stores users in memory, restarting clears all registered users. You must register again after every restart before you can log in.
  • Confirm you added async before (req, res) in the login handler. Without it, await does nothing and the comparison fails.
  • help me figure out why bcrypt.compare is failing in my login route
Understand the session cookie security flags

Your session cookie is already configured with security flags in server.js. These flags protect the cookie from common attacks. Open server.js and find the cookie object inside the session configuration:

cookie: {
  httpOnly: true,
  sameSite: 'lax',
  secure: false,
  maxAge: 1000 * 60 * 30
}

What does each flag prevent?

  • httpOnly: true prevents JavaScript from reading the cookie. This blocks XSS attacks where malicious scripts try to steal session cookies via document.cookie.
  • sameSite: 'lax' stops the browser from sending the cookie on cross-site POST requests. This prevents CSRF attacks where a malicious form submits to your server using the victim's session.
  • secure: false allows the cookie over HTTP on localhost. In production, you would set this to true so the cookie is only sent over HTTPS, preventing network sniffing.
  • maxAge: 1000 * 60 * 30 expires the session after 30 minutes of inactivity. This limits the window where a stolen cookie remains valid.
Add logout and test the full session flow

A secure session system needs a way to explicitly destroy the session. Without logout, a session cookie stays valid until it expires, even if the user is done.

  • In routes/auth.js, add this logout route after the /dashboard route:
router.post('/logout', (req, res) => {
  // Destroy the session on the server
  req.session.destroy((err) => {
    if (err) return res.status(500).json({ error: 'Logout failed' });
    // Clear the session cookie from the browser
    res.clearCookie('connect.sid');
    res.json({ message: 'Logged out' });
  });
});

What does this code do?

  • req.session.destroy() deletes the session data from the server's memory. The session ID in the cookie no longer maps to anything.
  • res.clearCookie('connect.sid') tells the browser to delete the cookie itself. connect.sid is the default cookie name used by express-session.
  • Save routes/auth.js, stop the server with Ctrl+C, and restart it:
node server.js

Time to test the complete session lifecycle. Before you run the next set of commands, think about what you expect to happen at each stage: register, login, access dashboard, logout, then try the dashboard again.

  • Register a user by running:
curl -X POST http://localhost:3000/auth/register -H "Content-Type: application/json" -d '{"email":"flow@test.com","password":"testpass"}'
  • Log in and save the session cookie to a file by running:
curl -c cookies.txt -X POST http://localhost:3000/auth/login -H "Content-Type: application/json" -d '{"email":"flow@test.com","password":"testpass"}'
  • Access the protected dashboard using the saved cookie:
curl -b cookies.txt http://localhost:3000/auth/dashboard

You should see {"message":"Welcome to dashboard","email":"flow@test.com"} confirming the session is active.

  • Now destroy the session by calling logout:
curl -b cookies.txt -X POST http://localhost:3000/auth/logout

What do you think will happen if you try to access the dashboard again with the same cookie?

  • Try hitting the dashboard one more time:
curl -b cookies.txt http://localhost:3000/auth/dashboard

You should see {"error":"Not authenticated"} with a 401 status. The session was destroyed on the server, so the old cookie is useless.

  • Confirm the password hashing one final time by running:
curl http://localhost:3000/auth/debug/users

Every password field now shows a bcrypt hash starting with $2b$. No plain text anywhere.

Logout not working or dashboard still accessible?

  • Make sure your logout route uses router.post not router.get. The curl command uses -X POST.
  • Make sure you pass -b cookies.txt on every request. Without it, curl does not send the session cookie and the server cannot identify the session to destroy.
  • help me debug why my logout route isn't destroying the session

✔️ Awesome, I've got everything!

Great. Make sure you have saved routes/auth.js with all your changes.

ⓧ I'd like to double check the full code

Scroll up and confirm your routes/auth.js has each of these sections in order:

  • Requires at the top: express and bcrypt.
  • The users array declaration.
  • POST /register with async and bcrypt.hash(password, 12).
  • POST /login with async and bcrypt.compare(password, user.password).
  • GET /dashboard with session check (unchanged from before).
  • POST /logout with req.session.destroy() and res.clearCookie('connect.sid').
  • GET /debug/users (unchanged).
  • module.exports = router and module.exports.users = users at the bottom.

Your session authentication is now production-grade. Passwords are hashed irreversibly, the cookie is locked down against common web attacks, and sessions can be explicitly destroyed. Next up, you will build a completely different auth strategy that does not need server-side storage at all.

Add JWT Authentication

Your session-based auth works, but it requires the server to store every active session in memory. In a microservices world where requests hit different servers, this creates a real problem.

JSON Web Tokens (JWTs) solve this by putting the user's identity claims inside a signed token that the client holds. The server never stores anything. It just verifies the signature on each request.

In this step, get ready to:
  • Build a token verification middleware that reads and validates JWTs.
  • Create a JWT login route that issues short-lived signed tokens.
  • Test that tampered tokens are rejected with a 401 response.
Create the token verification middleware

Every JWT-protected route needs to check for a valid token before letting the request through. Instead of repeating that logic in every route, you write it once as middleware.

  • Open routes/jwt.js in your editor.
  • Replace the placeholder comment with the following code:
const express = require('express');
const jwt = require('jsonwebtoken');
const bcrypt = require('bcrypt');
const router = express.Router();

// Import the shared users array from the session auth module
const { users } = require('./auth');

// Middleware: verify JWT from the Authorization header
function verifyToken(req, res, next) {
  const authHeader = req.headers.authorization;
  if (!authHeader || !authHeader.startsWith('Bearer ')) {
    return res.status(401).json({ error: 'No token provided' });
  }
  const token = authHeader.split(' ')[1];
  try {
    const decoded = jwt.verify(token, process.env.JWT_SECRET, {
      algorithms: ['HS256']
    });
    req.user = decoded;
    next();
  } catch (err) {
    return res.status(401).json({ error: 'Invalid or expired token' });
  }
}

What does this code do?

  • The imports bring in jsonwebtoken for signing and verifying tokens, and bcrypt for password comparison on login.
  • The { users } import reuses the same user store from your session auth. Both strategies authenticate against the same accounts.
  • The verifyToken middleware reads the Authorization header, extracts the token after Bearer , and verifies it.
  • Pinning algorithms: ['HS256'] prevents algorithm-downgrade attacks where an attacker tricks the server into accepting an unsigned token.
Add the JWT login route

The login route validates credentials the same way your session login does. The difference is what it returns: instead of setting a cookie, it issues a signed token the client stores.

  • Add the following code below the verifyToken function in routes/jwt.js:
// JWT login: validate credentials and issue a signed token
router.post('/login', async (req, res) => {
  const { email, password } = req.body;
  const user = users.find(u => u.email === email);
  if (!user) {
    return res.status(401).json({ error: 'Invalid credentials' });
  }
  const valid = await bcrypt.compare(password, user.password);
  if (!valid) {
    return res.status(401).json({ error: 'Invalid credentials' });
  }
  const accessToken = jwt.sign(
    { userId: user.id, email: user.email },
    process.env.JWT_SECRET,
    { algorithm: 'HS256', expiresIn: '15m' }
  );
  res.json({ accessToken, expiresIn: '15m' });
});

module.exports = router;

What does this code do?

  • The route validates the email and password using bcrypt.compare() against the hashed password in the shared users store.
  • On success, jwt.sign() creates a token containing the user's ID and email. The token expires in 15 minutes.
  • Pinning algorithm: 'HS256' in the sign options ensures the token is always created with the same algorithm we verify against.
  • Save routes/jwt.js.

Now you need to mount this new router in your server. The JWT routes will live under /auth/jwt.

  • In server.js, find this line:
const authRoutes = require('./routes/auth');
  • Add this line directly below it:
const jwtRoutes = require('./routes/jwt');
  • Now find this line further down:
app.use('/auth', authRoutes);
  • Add this line directly below it:
app.use('/auth/jwt', jwtRoutes);
  • Save server.js.

Time to test the JWT login. Since the server uses in-memory storage, you need to register a user first, then log in via the JWT route.

  • Stop your server if it is running (Ctrl+C), then restart it by running:
node server.js
  • In a separate terminal, register a test user by running:
curl -X POST http://localhost:3000/auth/register -H "Content-Type: application/json" -d '{"email":"test@example.com","password":"secret123"}'
  • Now log in through the JWT route by running:
curl -X POST http://localhost:3000/auth/jwt/login -H "Content-Type: application/json" -d '{"email":"test@example.com","password":"secret123"}'

You should see a JSON response with an accessToken field containing a long string of characters (the JWT) and an expiresIn field showing 15m.

Seeing "Invalid credentials" or "Cannot find module"?

  • If you see Cannot find module './routes/jwt', double-check that your routes/jwt.js file is saved and that the filename matches exactly.
  • If you get Invalid credentials, make sure you registered the user first. The in-memory store is empty each time you restart the server.
  • If you see a JWT_SECRET error, confirm your .env file has the JWT_SECRET variable set.

Need more help?

Add the protected route and test with a tampered token

The whole point of issuing a token is to protect routes with it. Now you will add a route that only responds if the request carries a valid JWT.

  • In routes/jwt.js, find this line at the bottom:
module.exports = router;
  • Add the following code directly above that line:
// Protected route: only accessible with a valid JWT
router.get('/protected', verifyToken, (req, res) => {
  res.json({
    message: 'JWT authentication successful',
    user: req.user
  });
});

What does this code do?

The verifyToken middleware runs before the route handler. If the token is invalid or missing, the middleware sends a 401 and the handler never executes. If the token is valid, the decoded claims are available on req.user.

  • Save routes/jwt.js.
  • Stop your server (Ctrl+C) and restart it by running:
node server.js

You need a fresh token to test the protected route. Register a user and get a token first.

  • Register and get a JWT by running these two commands:
curl -X POST http://localhost:3000/auth/register -H "Content-Type: application/json" -d '{"email":"test@example.com","password":"secret123"}'
curl -X POST http://localhost:3000/auth/jwt/login -H "Content-Type: application/json" -d '{"email":"test@example.com","password":"secret123"}'
  • Copy the accessToken value from the response (the long string between the quotes).

Before you run the next command, what do you think will happen when you send this token to the protected route?

  • Access the protected route by running this command, pasting your token in place of the placeholder:
curl http://localhost:3000/auth/jwt/protected -H "Authorization: Bearer [[JWT_TOKEN="your-access-token-from-the-login-response"]]"

You should see a JSON response containing JWT authentication successful along with your decoded user info (userId and email).

Now for the critical security test. What do you think will happen if you change a single character in the token?

  • Take the same token, change one character anywhere in the middle, and run the command again.

You should see a 401 response with Invalid or expired token. Even one changed character breaks the signature. This proves the server can detect tampered tokens without storing anything.

Why does one character break everything?

A JWT has three parts separated by dots: header, payload, and signature. The signature is a cryptographic hash of the first two parts combined with your secret. Changing any character in the payload means the signature no longer matches, and jwt.verify() rejects it immediately.

Getting 401 even with the correct token?

  • Make sure you copied the entire token with no trailing spaces or line breaks.
  • Check that the header format is exactly Authorization: Bearer <token> with a space between Bearer and the token.
  • If the token expired (after 15 minutes), run the login command again to get a fresh one.

Still stuck? Get help here.

✔️ Awesome, I've got everything!

Great. Double check you have saved both routes/jwt.js and server.js.

ⓧ I'd like to double check the full code

Your routes/jwt.js should contain these sections in order. Scroll up and confirm each is present:

  • The require statements for express, jsonwebtoken, and bcrypt, plus the router setup and { users } import from ./auth.
  • The verifyToken middleware function that reads the Authorization header and calls jwt.verify() with algorithms: ['HS256'].
  • The POST /login route that validates credentials and calls jwt.sign() with a 15-minute expiry.
  • The GET /protected route with verifyToken as middleware.
  • The module.exports = router; at the very bottom.

Your server.js should have these two additions:

  • The line const jwtRoutes = require('./routes/jwt'); below the authRoutes import.
  • The line app.use('/auth/jwt', jwtRoutes); below the authRoutes mount.

You now have two parallel auth strategies running on the same server: sessions with cookies and JWTs with Bearer tokens. Next up, you will add a third strategy that lets users skip passwords entirely by logging in through GitHub.

Add GitHub OAuth 2.0

Your Auth Explorer now handles sessions and JWTs. Both require users to create a password with your application. OAuth 2.0 solves a different problem: it lets users authenticate with a trusted third party (like GitHub) instead of managing yet another set of credentials.

In this step, you will implement the full authorization code flow that powers every "Login with GitHub" button on the internet. Your server will redirect users to GitHub, receive an authorization code, exchange it for an access token, and fetch the user's profile.

In this step, get ready to:
  • Create a GitHub OAuth App and store its credentials in your project.
  • Build the OAuth redirect route and wire it into your server.
  • Build the callback route and test the full OAuth handshake in your browser.
Create a GitHub OAuth App

Before your server can redirect users to GitHub, GitHub needs to know your application exists. You register an OAuth App to get a Client ID (public identifier) and Client Secret (private credential) that your server uses during the token exchange.

  • Open GitHub in your browser and click your profile picture in the upper-right corner.
  • Click Settings.
  • In the left sidebar, click Developer settings.
  • In the left sidebar, click OAuth Apps.
  • Click New OAuth App (or Register a new application if this is your first app).
  • In Application name, type Auth Explorer.
  • In Homepage URL, type http://localhost:3000.
  • In Authorization callback URL, type http://localhost:3000/auth/github/callback.
  • Click Register application.

GitHub now shows your app's settings page with a Client ID at the top.

  • Click Generate a new client secret.
  • Copy the Client ID and the newly generated Client Secret. You will need both in a moment.

Why is the callback URL important?

The callback URL is where GitHub sends the user after they approve your app. GitHub enforces that this URL matches exactly what you registered. If it does not match, the flow fails.

This prevents attackers from intercepting authorization codes by registering a different redirect URL.

Now paste your credentials into your project's environment file.

  • Switch back to your code editor and open .env.
  • Find these two lines:
GITHUB_CLIENT_ID=your-github-client-id-here
GITHUB_CLIENT_SECRET=your-github-client-secret-here
  • Replace your-github-client-id-here with the Client ID you copied from GitHub.
  • Replace your-github-client-secret-here with the Client Secret you copied from GitHub.
  • Save .env.

Lost your Client Secret?

GitHub only shows the secret once at generation time. If you navigated away, return to Settings > Developer settings > OAuth Apps, select your app, and click Generate a new client secret. The old one is invalidated.

Help me generate a new GitHub Client Secret

Build the redirect route and wire it into the server

The first leg of the OAuth flow is a redirect. When a user visits /auth/github, your server sends them to GitHub's authorization page with your app's Client ID attached.

  • Open routes/github.js in your code editor. Delete the placeholder comment and paste the following code:
const express = require('express');
const router = express.Router();

// GitHub OAuth endpoint URLs
const GITHUB_AUTH_URL = 'https://github.com/login/oauth/authorize';
const GITHUB_TOKEN_URL = 'https://github.com/login/oauth/access_token';
const GITHUB_USER_URL = 'https://api.github.com/user';

// Redirect the user to GitHub's authorization page
router.get('/', (req, res) => {
  const params = new URLSearchParams({
    client_id: process.env.GITHUB_CLIENT_ID,
    scope: 'user:email'
  });
  res.redirect(`${GITHUB_AUTH_URL}?${params.toString()}`);
});

module.exports = router;

What does this code do?

When a user visits /auth/github, the server builds a URL pointing to GitHub's authorization page. It includes your client_id so GitHub knows which app is asking, and the user:email scope so your app can read the user's email address.

The server then redirects the user's browser to that URL. GitHub handles the login UI and asks the user to approve your app.

  • Save routes/github.js.

The route file is ready, but your server does not know about it yet. You need to import and mount it in server.js.

  • Switch back to server.js.
  • Find this line near the top:
const jwtRoutes = require('./routes/jwt');
  • Add this line directly below it:
const githubRoutes = require('./routes/github');
  • Now find this line further down:
app.use('/auth/jwt', jwtRoutes);
  • Add this line directly below it:
app.use('/auth/github', githubRoutes);
  • Save server.js.

What does mounting at /auth/github do?

The router.get('/') inside routes/github.js becomes accessible at /auth/github. Later, router.get('/callback') will become /auth/github/callback. This matches the callback URL you registered on GitHub.

Before you continue, do you think visiting /auth/github will show you JSON or do something else?

  • Stop your running server with Ctrl+C in the terminal if it is still running.
  • Restart the server by running:
node server.js
  • Open your browser and navigate to http://localhost:3000/auth/github.

Your browser redirects to GitHub's authorization page. You are seeing the redirect leg of the OAuth flow in action.

Not seeing the GitHub authorization page?

Make sure GITHUB_CLIENT_ID in your .env file contains the actual Client ID from your GitHub OAuth App. If it still says your-github-client-id-here, the redirect URL is malformed and GitHub rejects it.

Help me debug why the GitHub OAuth redirect is not working

Build the callback route and test the full flow

The redirect works, but right now GitHub has nowhere to send the user after approval. You need the callback route that receives the authorization code, exchanges it for an access token, and fetches the user's profile.

  • Stop your server with Ctrl+C.
  • Open routes/github.js and find this line at the bottom:
module.exports = router;
  • Add the following callback route directly above module.exports = router;:
// Handle the callback after user approves on GitHub
router.get('/callback', async (req, res) => {
  const { code } = req.query;
  if (!code) {
    return res.status(400).json({ error: 'No authorization code received' });
  }

  console.log('Step 1: Received authorization code from GitHub');

  // Exchange the authorization code for an access token
  const tokenResponse = await fetch(GITHUB_TOKEN_URL, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'Accept': 'application/json'
    },
    body: JSON.stringify({
      client_id: process.env.GITHUB_CLIENT_ID,
      client_secret: process.env.GITHUB_CLIENT_SECRET,
      code
    })
  });

  const tokenData = await tokenResponse.json();
  console.log('Step 2: Exchanged code for access token');

  if (tokenData.error) {
    return res.status(400).json({ error: tokenData.error_description });
  }

What is happening in this token exchange?

When the user approves your app, GitHub redirects them back to your callback URL with a one-time code parameter. Your server takes that code and sends it to GitHub's token endpoint along with your Client ID and Client Secret.

GitHub verifies all three match and returns an access token. The user never sees the token. Your server handles it entirely on the back end, which is why it stays secure.

  • Add the following code directly below the error check you just pasted (still inside the same callback function):
  // Use the access token to fetch the user's GitHub profile
  const userResponse = await fetch(GITHUB_USER_URL, {
    headers: {
      'Authorization': `Bearer ${tokenData.access_token}`,
      'Accept': 'application/json',
      'User-Agent': 'Auth-Explorer'
    }
  });

  const userData = await userResponse.json();
  console.log('Step 3: Fetched user profile from GitHub API');

  res.json({
    message: 'GitHub OAuth successful',
    user: {
      login: userData.login,
      name: userData.name,
      avatar_url: userData.avatar_url
    }
  });
});

What does the profile fetch do?

With the access token in hand, your server calls GitHub's user API on behalf of the logged-in user. The User-Agent header is required by GitHub's API.

The response contains the user's public profile data including their username, display name, and avatar URL. The console logs let you watch each step of the OAuth handshake in real time.

  • Save routes/github.js.

✔️ Awesome, I've got everything!

Great. Double check you have saved your file, then continue to the test below.

ⓧ I'd like to double check the full code

Your routes/github.js should contain these sections in order. Scroll up and confirm each is present:

  • The require statements, three constant URLs, and the GET / redirect route.
  • The GET /callback route opening: code validation, token exchange POST, error check.
  • The second half of the callback: user profile fetch, JSON response, and the closing });.
  • module.exports = router; as the final line.

Seeing a syntax error when you save?

Make sure the closing }); for the callback route sits above module.exports = router;. A missing closing brace from the second chunk is the most common cause.

Help me check for missing brackets in routes/github.js

Time to test the full OAuth flow. Before you run this, think about what you expect to see when GitHub redirects back to your callback. Will the browser show HTML or something else?

  • Restart the server by running:
node server.js
  • Open your browser and navigate to http://localhost:3000/auth/github.
  • Click Authorize to approve the app when GitHub asks.

GitHub redirects you back to your server. You should see a JSON response containing your GitHub username, display name, and avatar URL.

  • Switch back to your terminal and check the console output. You should see all three log messages printed in sequence:
Step 1: Received authorization code from GitHub
Step 2: Exchanged code for access token
Step 3: Fetched user profile from GitHub API

What do these three steps prove?

Each log message corresponds to one leg of the OAuth authorization code flow. Step 1 confirms GitHub sent the code to your callback. Step 2 confirms your server exchanged that code for an access token. Step 3 confirms the token works to fetch user data from GitHub's API.

This three-step dance is identical across every OAuth provider (Google, Facebook, Slack). Only the URLs and scopes change.

Getting a 400 error on the callback?

The most common cause is a mismatch between your registered callback URL and what your server uses. Confirm that your GitHub OAuth App has the callback URL set to exactly http://localhost:3000/auth/github/callback with no trailing slash.

Also verify that GITHUB_CLIENT_ID and GITHUB_CLIENT_SECRET in your .env file match the values on your GitHub OAuth App settings page. If you regenerated the secret, the old one is invalid.

Help me debug my GitHub OAuth callback error

You now have three authentication strategies running side by side: sessions, JWTs, and OAuth. Next up, you will add API key authentication for machine-to-machine access.

Add API Key Authentication

Your Auth Explorer now handles sessions, JWTs, and OAuth. All three strategies share something in common: they authenticate humans who interact with a browser or a login form.

Automated services, cron jobs, and partner integrations are not humans. They need a simple, long-lived credential they can pass in a header without redirects or cookies. API keys solve this machine-to-machine use case. In this step, you will build the fourth and final auth strategy.

In this step, get ready to:
  • Create a key generation endpoint that only logged-in users can access.
  • Build an API key auth middleware that validates the key from a request header.
  • Test the full flow: generate a key, use it without a session, and confirm invalid keys are rejected.
Create the key generation route

Only authenticated users should be able to generate API keys. You will gate the generation route behind a session check, then use Node's built-in crypto module to produce a cryptographically random 32-byte hex string.

  • Open routes/apikey.js in your editor and replace the placeholder comment with this code:
const express = require('express');
const crypto = require('crypto');
const router = express.Router();

// In-memory store for generated API keys
const apiKeys = [];

// Middleware: only logged-in users can generate keys
function requireSession(req, res, next) {
  if (!req.session.userId) {
    return res.status(401).json({ error: 'Must be logged in to generate API keys' });
  }
  next();
}

// Generate a new API key for the authenticated user
router.post('/generate', requireSession, (req, res) => {
  const key = crypto.randomBytes(32).toString('hex');
  apiKeys.push({ key, userId: req.session.userId, createdAt: new Date() });
  res.status(201).json({ apiKey: key, message: 'Store this key securely. It cannot be retrieved again.' });
});

module.exports = router;

What does this code do?

  • requireSession checks if the request has a valid session (set during login). If not, it rejects the request immediately.
  • crypto.randomBytes(32).toString('hex') produces 64 characters of cryptographically secure randomness. This is the same approach used by real API key generators.
  • The key is stored alongside the userId so you can trace which user owns each key.

Now mount this route file in your server. The server needs to know about the new route module before it can handle requests.

  • Open server.js and find this line near the top:
const githubRoutes = require('./routes/github');
  • Add this import directly below it:
const apikeyRoutes = require('./routes/apikey');
  • Now scroll down to find this mount line:
app.use('/auth/github', githubRoutes);
  • Add this mount directly below it:
app.use('/auth/apikey', apikeyRoutes);
  • Save both routes/apikey.js and server.js.
  • Stop your running server with Ctrl+C, then restart it by running:
node server.js
  • Test key generation by running these three curl commands in a new terminal. First, register a user:
curl -X POST http://localhost:3000/auth/register -H "Content-Type: application/json" -d '{"email":"key@test.com","password":"pass123"}'
  • Log in and save the session cookie to a file:
curl -X POST http://localhost:3000/auth/login -H "Content-Type: application/json" -d '{"email":"key@test.com","password":"pass123"}' -c cookies.txt
  • Use the saved session cookie to generate an API key:
curl -X POST http://localhost:3000/auth/apikey/generate -b cookies.txt

You should see a JSON response containing a long hex string as your apiKey and the message "Store this key securely." Copy this key somewhere. You will use it in the final test.

Getting 401 on the generate request?

  • Make sure you used -c cookies.txt on the login command (saves the cookie) and -b cookies.txt on the generate command (sends it back).
  • Confirm the login responded with a success message. If the user does not exist yet, run the register command first.
  • Check that server.js mounts the route at /auth/apikey, not /apikey.

Help me debug my API key generation endpoint.

Create the API key auth middleware

You have the key generation working. Now you need a middleware that reads the x-api-key header from incoming requests and validates it against the stored keys.

  • In routes/apikey.js, find the line that says:
// Generate a new API key for the authenticated user
router.post('/generate', requireSession, (req, res) => {
  • Add this middleware function directly above that comment:
// Middleware: validate API key from x-api-key header
function apiKeyAuth(req, res, next) {
  const key = req.headers['x-api-key'];
  if (!key) {
    return res.status(401).json({ error: 'API key required in x-api-key header' });
  }
  const record = apiKeys.find(k => k.key === key);
  if (!record) {
    return res.status(401).json({ error: 'Invalid API key' });
  }
  req.user = { userId: record.userId };
  next();
}

What does this middleware do?

  • It reads the x-api-key header. This is a common convention for API key auth. Unlike cookies or Authorization headers, it is a custom header dedicated to machine credentials.
  • It looks up the key in the apiKeys array. If found, it attaches the associated userId to req.user so the route handler knows who is calling.
  • This pattern mirrors the verifyToken middleware you wrote for JWTs. Same shape, different credential source.
  • Save routes/apikey.js.
Add the protected data endpoint and test end-to-end

The middleware validates keys. Now you need a route that uses it to protect a resource.

  • In routes/apikey.js, find this line near the bottom:
module.exports = router;
  • Add this route directly above that line:
// Protected endpoint: requires valid API key
router.get('/data', apiKeyAuth, (req, res) => {
  res.json({
    message: 'API key authentication successful',
    userId: req.user.userId,
    data: { items: ['item1', 'item2', 'item3'], count: 3 }
  });
});

What does this route do?

GET /auth/apikey/data sits behind the apiKeyAuth middleware. A request without a valid x-api-key header never reaches the handler. This is your machine-to-machine endpoint. No session, no JWT, no browser required.

  • Save routes/apikey.js.
  • Stop your server with Ctrl+C and restart it:
node server.js

Since restarting cleared in-memory data, you need to register, log in, and generate a fresh key. Run these commands in your other terminal:

  • Register and log in:
curl -X POST http://localhost:3000/auth/register -H "Content-Type: application/json" -d '{"email":"key@test.com","password":"pass123"}'
curl -X POST http://localhost:3000/auth/login -H "Content-Type: application/json" -d '{"email":"key@test.com","password":"pass123"}' -c cookies.txt
  • Generate a new API key:
curl -X POST http://localhost:3000/auth/apikey/generate -b cookies.txt
  • Copy the apiKey value from the response. Now use it to access the protected data endpoint:
curl http://localhost:3000/auth/apikey/data -H "x-api-key: [[APIKEY="your-generated-api-key"]]"

You should see the protected payload with "message": "API key authentication successful" and the sample data items. Notice you did not send a session cookie or a JWT. The API key alone grants access.

Before you run the next command, predict: what will happen if you send a key that does not exist in the server's memory?

  • Test with an invalid key by running:
curl http://localhost:3000/auth/apikey/data -H "x-api-key: fake-key-that-does-not-exist"

You get a 401 response with "error": "Invalid API key". The middleware rejected the request before it reached the route handler.

Not seeing the protected data?

  • Make sure you copied the full 64-character key from the generate response. Even one missing character makes it invalid.
  • Check the header name is exactly x-api-key (lowercase, with hyphens). Custom headers are case-insensitive in HTTP, but the curl flag must match the format shown.
  • Confirm you restarted the server after adding the apiKeyAuth middleware and the /data route. If you only saved without restarting, the old code is still running.

Help me debug my API key data endpoint.

Why use API keys instead of JWTs for machines?

JWTs expire after 15 minutes and require a login flow to refresh. Automated services running 24/7 cannot handle interactive login. API keys are long-lived, revocable, and require no authentication dance.

The tradeoff is that a leaked API key gives permanent access until revoked. That is why production systems store keys hashed (like passwords) and rate-limit their use. For this learning project, storing in plain text lets you inspect the mechanism directly.

✔️ Awesome, I've got everything!

Great. Double check you have saved both files before moving on.

ⓧ I'd like to double check the full code

Scroll through your routes/apikey.js and confirm these sections appear in order:

  • Imports: express and crypto require statements, plus router and apiKeys declarations.
  • requireSession middleware function checking req.session.userId.
  • apiKeyAuth middleware function reading req.headers['x-api-key'].
  • POST /generate route using requireSession and crypto.randomBytes(32).
  • GET /data route using apiKeyAuth.
  • module.exports = router; at the bottom.

In server.js, confirm you have const apikeyRoutes = require('./routes/apikey'); in the imports and app.use('/auth/apikey', apikeyRoutes); in the mounts.

Your Auth Explorer now has all four authentication strategies running side by side. You can walk through sessions, JWTs, OAuth, and API keys with running code as your proof. Next up, a Secret Mission to harden your JWT flow with refresh token rotation.

Secret mission

Add Refresh Token Rotation

Your 15-minute access token forces users to re-login constantly. Refresh tokens solve this, but naive implementations are dangerous if a token is stolen. Can you implement refresh token rotation with reuse detection, the production standard that locks out attackers the moment they try to replay a stolen token?

Clean Up Your Resources

Clean Up Your Resources

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

Resources you used:

  • GitHub OAuth App (free, no recurring cost).
  • Local Node.js project folder with server.js, .env, and routes/ directory.
  • npm packages installed in node_modules/ (removed when you delete the project folder).

Keep everything running

No action needed. Choose this if you're still actively building or plan to extend the project with more auth strategies.

  • The GitHub OAuth App stays in your account for future projects. It costs nothing.
  • The local server only runs when you start it with node server.js. It uses no resources when stopped.
  • Your .env file contains your GitHub OAuth credentials. Keep it safe and never commit it to a public repository.

Pause - I'll come back to this later

Stop the running server to free up your terminal. Everything stays on disk for when you return.

  • If your server is still running, press Ctrl+C in the terminal to stop it.
  • Your project files and GitHub OAuth App remain in place. You can restart anytime with node server.js.

Delete - I don't want to use this again

Remove all project resources and the GitHub OAuth App entirely.

  • Delete the GitHub OAuth App:
  • Delete the local project folder. On macOS/Linux, run:
cd ~/Desktop
rm -rf auth-explorer

On Windows, run:

cd ~\Desktop
rmdir /s /q auth-explorer

Nice Work!

Nice Work!

You just built a complete authentication server with four production strategies running side-by-side. You can now walk anyone through sessions vs JWTs vs OAuth vs API keys with running code as your proof.

Here's what you learned:

  • Implemented session-based authentication with bcrypt password hashing and secure cookie configuration (httpOnly, sameSite, secure) to protect against XSS and CSRF.
  • Built JWT authentication with explicit algorithm pinning and short-lived tokens, then completed the full OAuth 2.0 authorization code flow with GitHub as the identity provider.
  • Created API key generation and validation for machine-to-machine access, protecting the generation endpoint behind session middleware so only authenticated users can issue keys.
  • Secret Mission: Added refresh token rotation with reuse detection for production-grade JWT lifecycle management.

Ready to quiz yourself?