Protect Express Routes with Auth
Build an Express lab that protects routes with sessions and bearer tokens.
Introduction
30 Second Summary
A hidden button can make private information feel protected. However, people can still reach that information when protection only exists on the screen.
In this project, you will build Auth Lab, a local Express dashboard that compares two ways to protect private profile routes. You will secure one route with a server-side session while another uses an opaque bearer token.
What You'll Build
You'll demo the finished dashboard by switching each private profile from denied to unlocked and back to denied.
By the end of this project, you'll have:
- A visible security failure where both profile buttons expose the same private user before protection exists.
- A session flow where the browser carries authlab.sid automatically. Destroying the session makes the next profile request fail.
- A bearer token flow whose Authorization header is visible in Microsoft Edge DevTools. Revoking the token makes the next profile request fail.
- Secret Mission: Add an admin-only endpoint that denies an authenticated non-admin user with a 403 response.
Are there any prerequisites?
Basic JavaScript knowledge will help. Familiarity with Express routes makes the middleware easier to follow.
Step 1 guides you through the local project setup and verifies every dependency.
Before We Start
This is your moment to commit to what Auth Lab will prove before the hands-on work begins. You will compare session and bearer-token authentication to explain why authentication middleware must block requests before protected route handlers return private data.
Set Up the Auth Lab
Auth Lab needs a predictable foundation before its two authentication flows can be compared. Runtime differences can hide the authentication behavior you are here to study.
Node.js runs the local server. Visual Studio Code holds the workspace for pinned Express plus express-session dependencies.
In this step, get ready to:
- Open the auth-lab workspace in Visual Studio Code.
- Prepare the project file structure.
- Verify the runtime plus dependency versions.
Create the workspace
A Visual Studio Code workspace keeps the editor files in the same folder as the integrated terminal. This shared location prevents later commands from running against the wrong project.
- Press Cmd+Space on macOS or the Windows key on Windows to open your search bar.
- Type Visual Studio Code and press Enter to open it.
Visual Studio Code opens without a project folder. You are ready to give Auth Lab its own workspace.
- Select File in the top menu bar.
- Select Open Folder....
- Select Desktop in the folder picker.
- Select New Folder.
- Enter auth-lab as the folder name.
- Select Select Folder.
- Select Yes, I trust the authors if the Workspace Trust dialog appears.
Your Workspace Is Ready
Good start. You should see auth-lab at the top of the Explorer sidebar.
Visual Studio Code now treats that folder as the workspace. Every file you create in the Explorer belongs to Auth Lab.
- Select Terminal in the top menu bar.
- Select New Terminal.
You are in the right place. The terminal prompt should end with auth-lab, which confirms commands run from the workspace folder.
Create the manifest and asset directory
The package.json manifest records the exact packages Auth Lab requires. Pinning each version prevents a future package update from changing the behavior of this lab.
- Select New File... in the Explorer toolbar.
- Enter package.json as the file name.
- Define the pinned project setup by replacing the file contents with this code:
{
"name": "auth-lab",
"version": "1.0.0",
"private": true,
"description": "A local Express lab comparing protected routes with sessions and bearer tokens.",
"scripts": {
"start": "node server.js"
},
"dependencies": {
"express": "5.3.0",
"express-session": "1.19.0"
}
}
What Does This Manifest Define?
- The private setting marks Auth Lab as a local project.
- The start script points to the server file you create in the next step.
- The dependencies object pins express to 5.3.0.
- The same object pins express-session to 1.19.0.
- Save package.json by pressing Cmd+S on macOS or Ctrl+S on Windows.
You should see package.json listed directly under auth-lab in the Explorer. Its editor tab should no longer show an unsaved-change dot.
Manifest Not Saving?
Check that package.json sits directly inside auth-lab. Make sure every key uses double quotation marks.
Confirm that the final dependency entry has no trailing comma. Help me find a JSON formatting problem in my package manifest.
The browser files arrive in the next step. An empty public directory reserves their exact location now.
- Select New Folder in the Explorer toolbar.
- Enter public as the folder name.
- Press Enter to create the directory.
Your setup files are now in place. The Explorer should show package.json plus an empty public directory inside auth-lab.
Verify Node.js and install dependencies
Express 5.3.0 requires Node.js 18 or newer. The first check confirms the runtime can support the pinned server package.
- Check the installed Node.js version plus the installed npm version by running these commands:
node -v
npm -v
What Do These Commands Check?
The first line prints the installed Node.js version. The second line prints the installed npm version.
Both commands must work before the project dependencies can be installed.
✔️ I see Node.js 18 or higher
Your runtime is ready for Express 5.3.0. You should also see an npm version number from the second command.
ⓧ I see an older Node.js version
The installed runtime is below Auth Lab's minimum version. The Windows Node.js installer also includes npm.
- Open the official Node.js download page.
- Download the Windows installer for an LTS release at version 18 or newer.
- Run the downloaded installer.
- Keep the default installation options selected.
- Restart Visual Studio Code after installation.
- Return to the auth-lab workspace from earlier.
- Select Terminal in the top menu bar.
- Select New Terminal.
The fresh terminal reads the updated runtime from your system. Recheck both versions before continuing.
- Confirm the upgraded Node.js version plus npm by running:
node -v
npm -v
What Does This Recheck Prove?
The first output should now report Node.js 18 or higher. The second output confirms npm is available in the refreshed terminal.
ⓧ Command not found
The terminal cannot find the runtime on your system path. Installing Node.js for Windows also installs npm.
- Open the official Node.js download page.
- Download the Windows installer for an LTS release at version 18 or newer.
- Run the downloaded installer.
- Keep the default installation options selected.
- Restart Visual Studio Code after installation.
- Return to the auth-lab workspace from earlier.
- Select Terminal in the top menu bar.
- Select New Terminal.
The fresh terminal can now read the runtime installed on your system. Recheck both commands before continuing.
- Confirm Node.js plus npm are available by running:
node -v
npm -v
What Does This Recheck Prove?
You should now see a Node.js version of 18 or higher. You should also see an npm version number.
The runtime can now read the dependency list in package.json. npm installs those packages inside this workspace.
- Install the pinned project dependencies by running:
npm install
What Does This Command Do?
This command reads the dependency versions from package.json. It downloads them into a local node_modules directory.
Local installation keeps Auth Lab's packages tied to this workspace.
Before you run the final check, which two direct packages do you expect the dependency tree to show?
- Inspect the direct dependency tree by running:
npm ls --depth=0
What Does Depth Zero Show?
The --depth=0 flag limits the tree to packages listed directly by your project. Nested dependencies stay out of this final check.
That locks in your setup. You should see express@5.3.0 plus express-session@1.19.0 in the output.
Dependency Check Not Matching?
Confirm the terminal prompt ends with auth-lab. A different path means npm is reading another project's manifest.
Check that the dependency names plus versions in package.json match the reference below. Help me diagnose why my local dependency tree does not show the pinned packages.
✔️ Awesome, I've got everything!
Great. Your workspace contains the exact manifest, the empty asset directory, plus the installed dependency tree.
ⓧ I'd like to double check the full code
Compare your saved package.json with this complete reference.
{
"name": "auth-lab",
"version": "1.0.0",
"private": true,
"description": "A local Express lab comparing protected routes with sessions and bearer tokens.",
"scripts": {
"start": "node server.js"
},
"dependencies": {
"express": "5.3.0",
"express-session": "1.19.0"
}
}
What Should Match?
Every key plus value should match the saved file exactly. The dependency versions must remain pinned.
- Confirm the public directory is empty.
- Confirm the local node_modules tree contains express@5.3.0.
- Confirm the same tree contains express-session@1.19.0.
- Confirm the integrated terminal remains at the auth-lab workspace root.
Your pinned workspace is ready. Next, you will build the dashboard plus expose the unprotected profile routes that make the security problem visible.
Expose the Data Leak
A route can have a private-sounding name while still returning its data to anyone who asks. Hiding a button in the browser also leaves the server endpoint exposed.
You will build a local Express dashboard with two profile routes. Both routes deliberately return the demo user's private data before any authentication check happens.
In this step, get ready to:
- Build and style the Session and Bearer Token dashboard.
- Connect both profile buttons to temporary unprotected routes.
- Prove that both routes leak the demo profile before login.
Serve the dashboard shell
The browser needs a local server before each interface change can be checked. This first version serves static files and returns the same demo user from two unprotected profile routes.
- In the Visual Studio Code Explorer sidebar, create server.js inside the open auth-lab folder.
- Paste this temporary server into server.js:
const express = require('express');
const app = express();
const PORT = 3000;
const DEMO_USER = {
id: 1,
email: 'learner@example.com',
password: 'learn123',
role: 'user'
};
app.use(express.json());
app.use(express.static('public'));
app.get('/api/session/profile', (req, res) => {
res.json({
message: 'Session-protected profile unlocked.',
user: DEMO_USER
});
});
app.get('/api/token/profile', (req, res) => {
res.json({
message: 'Token-protected profile unlocked.',
user: DEMO_USER
});
});
app.listen(PORT, () => {
console.log(`Auth Lab is running at http://localhost:${PORT}`);
});
What Does This Server Do?
- The DEMO_USER object holds the private profile returned by both routes.
- The express.static('public') middleware serves the dashboard files from public.
- Each profile handler sends DEMO_USER immediately because no credential check exists.
- Save server.js.
- Start the server from the existing Visual Studio Code terminal by running:
node server.js
What Does This Command Do?
The Node.js process runs server.js and keeps the local server listening on port 3000.
You should see Auth Lab is running at http://localhost:3000 in the terminal. Keep this process running while you build the dashboard.
Server Not Starting?
Confirm the terminal is still at the root of the open auth-lab workspace. Check that server.js is beside package.json.
If another process is using port 3000, stop that process before trying again.
Help me diagnose why the Auth Lab server does not start.
The server can now deliver every file placed in public. Start with a valid page shell so every later edit has an immediate browser result.
- In the Visual Studio Code Explorer sidebar, create index.html inside the existing public folder.
- Build the page shell by pasting this into public/index.html:
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Auth Lab</title>
<link rel="stylesheet" href="styles.css">
</head>
<body>
<main class="shell">
<header class="hero">
<p class="eyebrow">Express authentication mini project</p>
<h1>Auth Lab</h1>
<p>Compare a browser-managed session cookie with an explicitly supplied bearer token.</p>
</header>
</main>
</body>
</html>
What Does This Page Shell Do?
- The stylesheet link prepares the page to load styles.css from the same folder.
- The hero introduces the comparison between a session cookie and a bearer token.
- The shell becomes the outer layout container for the complete dashboard.
- Save public/index.html.
- Press the Windows key to open Windows search.
- Type Microsoft Edge.
- Press Enter to open Microsoft Edge.
- Select the browser address bar.
- Enter http://localhost:3000.
- Press Enter to load the dashboard.
You should see the Auth Lab heading with a short comparison beneath it. The page is unstyled because styles.css does not exist yet.
Dashboard Not Loading?
Return to the Visual Studio Code terminal and confirm the server process is still running. Check that the browser URL uses port 3000.
If Microsoft Edge is unavailable, install it from the official Microsoft Edge download page before continuing.
Help me work out why the Auth Lab page does not load from localhost.
The dashboard needs one shared account so both flows make requests for the same identity. The password input masks the teaching fixture while preserving a simple local login form.
- In public/index.html, insert this credentials section directly below the closing header tag:
<section class="credentials" aria-labelledby="credentials-title">
<h2 id="credentials-title">Demo account</h2>
<label>
Email
<input id="email" type="email" value="learner@example.com">
</label>
<label>
Password
<input id="password" type="password" value="learn123">
</label>
</section>
What Does This Form Do?
- The email field starts with the demo user's address.
- The password field starts with the matching local teaching password.
- The email and password IDs give the browser script stable references to both inputs.
- Save public/index.html.
- Return to Microsoft Edge.
- Refresh the dashboard.
You should see the demo email and a masked password beneath the Auth Lab introduction.
Credentials Missing?
Confirm the credentials section sits inside main and below the closing header tag. Save the file before refreshing Microsoft Edge.
Help me find why the Auth Lab credential inputs do not appear.
Each authentication flow gets its own card and result panel. The separate panels make the status of one request easy to compare with the other.
- In public/index.html, insert this Session flow card below the credentials section:
<article class="card">
<span class="badge">Server-managed state</span>
<h2>Session flow</h2>
<p>The browser sends the session cookie automatically.</p>
<div class="actions">
<button id="session-login">Log in with session</button>
<button id="session-profile" class="secondary">Check session profile</button>
<button id="session-logout" class="danger">Destroy session</button>
</div>
<pre id="session-output" aria-live="polite">No session request yet.</pre>
</article>
How Is the Session Card Organized?
- The three buttons represent session login, profile access, and logout.
- The session-output panel displays the HTTP status and response body.
- The server-managed badge previews where the session state belongs later in the project.
- Save public/index.html.
- Refresh the dashboard in Microsoft Edge.
You should see a Session flow card with three controls and a panel that reads No session request yet..
Session Card Not Visible?
Confirm the complete article sits inside main. Check that every opening tag in the card has a matching closing tag.
Help me repair the Session flow card in public/index.html.
- In public/index.html, insert this Bearer Token flow card directly below the Session flow card:
<article class="card">
<span class="badge">Client-supplied credential</span>
<h2>Bearer token flow</h2>
<p>The page adds the token to the Authorization header.</p>
<div class="actions">
<button id="token-login">Request token</button>
<button id="token-profile" class="secondary">Check token profile</button>
<button id="token-logout" class="danger">Revoke token</button>
</div>
<pre id="token-output" aria-live="polite">No token request yet.</pre>
</article>
How Is the Token Card Organized?
- The token controls cover issuance, profile access, and revocation.
- The token-output panel keeps token responses separate from session responses.
- The client-supplied badge highlights that this credential must be added to a request explicitly.
- Wrap both card articles in a section element with the class grid.
- Save public/index.html.
- Refresh the dashboard in Microsoft Edge.
You should see both flow cards beneath the demo credentials. They appear as plain page content until the stylesheet is added.
Cards in the Wrong Place?
Confirm the grid section begins before the Session article. Confirm it closes after the Bearer Token article.
Help me correct the grid wrapper around the two Auth Lab cards.
The page structure is ready for a visual hierarchy. The stylesheet turns the form and request panels into a responsive two-column lab.
- In the Visual Studio Code Explorer sidebar, create styles.css inside public.
- Add the page palette and outer layout by pasting this into public/styles.css:
:root {
color-scheme: dark;
font-family: Inter, ui-sans-serif, system-ui, sans-serif;
background: #08111f;
color: #e8eef8;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-height: 100vh;
background:
radial-gradient(circle at top left, rgba(46, 160, 255, 0.18), transparent 35%),
#08111f;
}
button,
input {
font: inherit;
}
.shell {
width: min(1050px, 92vw);
margin: 0 auto;
padding: 56px 0;
}
What Does the Page Foundation Do?
- The root rules establish the dark palette and shared font stack.
- The body gradient creates a visible highlight behind the dashboard.
- The shell constrains the content width while keeping space around the page.
- Save public/styles.css.
- Refresh the dashboard in Microsoft Edge.
You should see a dark blue page with centered content and generous space above the Auth Lab heading.
Dark Theme Missing?
Confirm styles.css is inside public beside index.html. Check that the stylesheet link still uses styles.css.
Help me diagnose why public/styles.css is not loading in Auth Lab.
- Add the introduction styles below the existing rules in public/styles.css:
.hero {
max-width: 720px;
margin-bottom: 28px;
}
.eyebrow,
.badge {
color: #72c7ff;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.hero h1 {
margin: 6px 0;
font-size: clamp(2.6rem, 8vw, 5rem);
}
.hero p,
.card p {
color: #aebdd3;
line-height: 1.6;
}
What Changes in the Header?
- The eyebrow becomes a small uppercase label.
- The heading scales with the browser width.
- The supporting text uses a muted color to keep the title prominent.
- Save public/styles.css.
- Refresh the dashboard in Microsoft Edge.
You should see a larger Auth Lab title with a blue uppercase label above it.
Header Still Looks Plain?
Check that the HTML still uses the hero and eyebrow class names. CSS class matching is case-sensitive.
Help me find why the Auth Lab header styles do not match the HTML.
- Add the shared panel styles below the existing rules in public/styles.css:
.credentials,
.card {
border: 1px solid #22334c;
border-radius: 18px;
background: rgba(13, 26, 45, 0.88);
box-shadow: 0 18px 50px rgba(0, 0, 0, 0.24);
}
.credentials {
display: grid;
grid-template-columns: auto 1fr 1fr;
gap: 16px;
align-items: end;
padding: 20px;
margin-bottom: 20px;
}
.credentials h2 {
margin: 0 16px 10px 0;
}
How Do the Panels Take Shape?
- The credentials area and both cards share the same border and translucent background.
- The credentials grid places its heading beside the two inputs on wider screens.
- The shadow separates each panel from the dark page.
- Save public/styles.css.
- Refresh the dashboard in Microsoft Edge.
You should see the credentials area and both cards become rounded dark panels.
Panels Missing Their Borders?
Confirm the credentials section uses class="credentials". Confirm both flow articles use class="card".
Help me diagnose why the Auth Lab panels are not styled.
- Add the form and card-grid rules below the existing styles in public/styles.css:
label {
display: grid;
gap: 7px;
color: #aebdd3;
}
input {
width: 100%;
border: 1px solid #304666;
border-radius: 10px;
padding: 11px 12px;
background: #07101d;
color: #ffffff;
}
.grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 20px;
}
.card {
padding: 24px;
}
How Does the Two-Column Layout Work?
- Each label keeps its caption above its input.
- The inputs stretch to fill the available credential columns.
- The grid places the Session and Bearer Token cards side by side.
- Save public/styles.css.
- Refresh the dashboard in Microsoft Edge.
You should see styled credential inputs and two equally sized flow cards in one row.
Cards Not Side by Side?
Confirm both flow articles are children of the same grid section. A misplaced closing tag can move one card outside the grid.
Help me repair the two-column grid in the Auth Lab dashboard.
- Add the badge and action-stack rules below the existing styles in public/styles.css:
.badge {
font-size: 0.72rem;
}
.actions {
display: grid;
gap: 10px;
margin: 24px 0;
}
What Do These Card Details Add?
- The badge becomes a compact label above each flow name.
- The actions container stacks the three controls with equal spacing.
- Save public/styles.css.
- Refresh the dashboard in Microsoft Edge.
You should see a compact blue badge and a vertical control stack in each card.
Controls Still Bunched Together?
Check that each set of three buttons remains inside a container with class="actions".
Help me find why the Auth Lab action buttons are not evenly spaced.
- Add the button styles below the existing rules in public/styles.css:
button {
cursor: pointer;
border: 0;
border-radius: 10px;
padding: 12px 15px;
background: #2ea0ff;
color: #04101e;
font-weight: 800;
}
button:hover {
filter: brightness(1.08);
}
button.secondary {
background: #dceaff;
}
button.danger {
background: #ff8f8f;
}
Why Use Three Button Colors?
- Blue identifies the primary login or token request action.
- The pale secondary style identifies profile checks.
- Red identifies session destruction and token revocation.
- Save public/styles.css.
- Refresh the dashboard in Microsoft Edge.
You should see blue primary controls, pale profile controls, and red credential-removal controls.
Button Colors Missing?
Confirm the profile buttons use the secondary class. Confirm the removal buttons use the danger class.
Help me diagnose why the Auth Lab buttons do not use their expected colors.
- Finish the stylesheet with the response-panel and mobile rules:
pre {
min-height: 180px;
margin: 0;
overflow: auto;
white-space: pre-wrap;
border-radius: 12px;
padding: 16px;
background: #040a12;
color: #b8f5cb;
}
@media (max-width: 760px) {
.credentials,
.grid {
grid-template-columns: 1fr;
}
}
How Does the Dashboard Finish?
- Each response panel keeps enough height for formatted JSON.
- Long responses remain readable because whitespace wraps inside the panel.
- The media rule changes the credentials and cards to one column on narrow screens.
- Save public/styles.css.
- Refresh the dashboard in Microsoft Edge.
You should see dark response panels with green text beneath both button stacks. The complete dashboard now adapts to narrower browser widths.
Response Panels Look Unstyled?
Confirm both response elements are pre elements. Check that the media rule closes with its final brace.
Help me fix the response-panel or mobile styles in Auth Lab.
✔️ Awesome, I've got everything!
Your server is running and the complete Auth Lab dashboard is visible. Save the stylesheet before continuing.
ⓧ I'd like to double check the full code
:root {
color-scheme: dark;
font-family: Inter, ui-sans-serif, system-ui, sans-serif;
background: #08111f;
color: #e8eef8;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-height: 100vh;
background:
radial-gradient(circle at top left, rgba(46, 160, 255, 0.18), transparent 35%),
#08111f;
}
button,
input {
font: inherit;
}
.shell {
width: min(1050px, 92vw);
margin: 0 auto;
padding: 56px 0;
}
.hero {
max-width: 720px;
margin-bottom: 28px;
}
.eyebrow,
.badge {
color: #72c7ff;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.hero h1 {
margin: 6px 0;
font-size: clamp(2.6rem, 8vw, 5rem);
}
.hero p,
.card p {
color: #aebdd3;
line-height: 1.6;
}
.credentials,
.card {
border: 1px solid #22334c;
border-radius: 18px;
background: rgba(13, 26, 45, 0.88);
box-shadow: 0 18px 50px rgba(0, 0, 0, 0.24);
}
.credentials {
display: grid;
grid-template-columns: auto 1fr 1fr;
gap: 16px;
align-items: end;
padding: 20px;
margin-bottom: 20px;
}
.credentials h2 {
margin: 0 16px 10px 0;
}
label {
display: grid;
gap: 7px;
color: #aebdd3;
}
input {
width: 100%;
border: 1px solid #304666;
border-radius: 10px;
padding: 11px 12px;
background: #07101d;
color: #ffffff;
}
.grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 20px;
}
.card {
padding: 24px;
}
.badge {
font-size: 0.72rem;
}
.actions {
display: grid;
gap: 10px;
margin: 24px 0;
}
button {
cursor: pointer;
border: 0;
border-radius: 10px;
padding: 12px 15px;
background: #2ea0ff;
color: #04101e;
font-weight: 800;
}
button:hover {
filter: brightness(1.08);
}
button.secondary {
background: #dceaff;
}
button.danger {
background: #ff8f8f;
}
pre {
min-height: 180px;
margin: 0;
overflow: auto;
white-space: pre-wrap;
border-radius: 12px;
padding: 16px;
background: #040a12;
color: #b8f5cb;
}
@media (max-width: 760px) {
.credentials,
.grid {
grid-template-columns: 1fr;
}
}
Wire the browser requests
The dashboard can display controls, but those controls need browser-side request logic. The script keeps each result visible by combining the HTTP status with the server's JSON response.
- In the Visual Studio Code Explorer sidebar, create app.js inside public.
- Add the dashboard state and shared element references to public/app.js:
let bearerToken = '';
const emailInput = document.querySelector('#email');
const passwordInput = document.querySelector('#password');
const sessionOutput = document.querySelector('#session-output');
const tokenOutput = document.querySelector('#token-output');
function credentials() {
return {
email: emailInput.value,
password: passwordInput.value
};
}
function showResult(output, result) {
output.textContent = JSON.stringify(result, null, 2);
}
What Does the Dashboard State Hold?
- The bearerToken variable starts empty and remains in the page's JavaScript memory.
- The input references let credentials() read the current email and password.
- The output references let showResult() render formatted response objects in the correct card.
- Save public/app.js.
- Confirm app.js now appears beside index.html and styles.css in the Visual Studio Code Explorer sidebar.
Script File in the Wrong Folder?
Move app.js into public if it appears beside server.js. The browser loads all three front-end files from the same static directory.
Help me correct the Auth Lab browser-file structure.
A shared request helper keeps every button handler small. It returns a consistent object for successful HTTP responses and browser-level network failures.
- Add the request helper directly below showResult() in public/app.js:
async function callApi(path, options = {}) {
try {
const response = await fetch(path, options);
const data = await response.json();
return {
status: response.status,
data
};
} catch (error) {
return {
status: 'network error',
data: { message: error.message }
};
}
}
document.querySelector('#session-profile').addEventListener('click', async () => {
const result = await callApi('/api/session/profile');
showResult(sessionOutput, result);
});
How Does the First Request Work?
- The callApi() helper sends the request and parses its JSON body.
- The returned object preserves the HTTP status beside the parsed data.
- The Session profile listener calls the temporary unprotected route when its button is selected.
- In public/index.html, insert the script element directly before the closing body tag:
<script src="app.js"></script>
Why Load the Script Last?
Loading app.js after the dashboard markup ensures every input, button, and output panel already exists when the script queries the page.
- Save public/app.js.
- Save public/index.html.
- Refresh the dashboard in Microsoft Edge.
- Select Check session profile.
You should see status 200 and the demo user in the Session response panel. This first visible response confirms that the browser script reached the unprotected server route.
Session Button Does Nothing?
Confirm the script element sits before the closing body tag. Check that the button ID remains session-profile.
Help me debug why the Check session profile button does not update its output panel.
The remaining Session controls use JSON POST requests. Their routes are intentionally absent from this temporary server because this step focuses on exposing the profile leak.
- Insert the Session login listener above the existing Session profile listener in public/app.js.
- Insert the POST helper above the Session listeners by pasting this code:
function postJson(body, headers = {}) {
return {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...headers
},
body: JSON.stringify(body)
};
}
document.querySelector('#session-login').addEventListener('click', async () => {
const result = await callApi('/api/session/login', postJson(credentials()));
showResult(sessionOutput, result);
});
How Are JSON Requests Prepared?
- The postJson() helper creates a POST request with a JSON body.
- The optional headers object leaves room for an explicit bearer credential later.
- The Session login listener sends the current input values to its route.
- Add the Session logout listener directly below the Session profile listener:
document.querySelector('#session-logout').addEventListener('click', async () => {
const result = await callApi('/api/session/logout', postJson({}));
showResult(sessionOutput, result);
});
What Does the Logout Control Send?
The listener sends an empty JSON object because logout does not need credentials in its request body. It still displays the route's status and response in the Session panel.
- Save public/app.js.
- Refresh the dashboard in Microsoft Edge.
- Select Log in with session.
You should see status 404 because the temporary server has no login route. This expected shortfall confirms that the new button handler runs.
- Select Check session profile again.
The Session panel returns to status 200 with the private demo profile.
Seeing a Network Error Instead?
A network error means the browser could not reach the local server. Return to the terminal and confirm the Node.js process is still running.
Help me distinguish an expected missing route from a browser network failure.
The token login listener stores a returned credential only when the server responds successfully. Until that route exists, bearerToken stays empty in JavaScript memory.
- Add the token login listener below the Session listeners in public/app.js:
document.querySelector('#token-login').addEventListener('click', async () => {
const result = await callApi('/api/token/login', postJson(credentials()));
if (result.status === 200) {
bearerToken = result.data.token;
}
showResult(tokenOutput, result);
});
When Is the Token Stored?
- The handler sends the same demo credentials used by the Session flow.
- A successful status stores the returned token in bearerToken.
- Every response remains visible in the token panel.
- Save public/app.js.
- Refresh the dashboard in Microsoft Edge.
- Select Request token.
You should see status 404 because token issuance is outside this temporary server. The response proves that the token login listener runs without placing a credential in memory.
Token Panel Not Updating?
Confirm the token login button still uses id="token-login". Check that the listener passes tokenOutput to showResult().
Help me debug why the Request token button does not update the token panel.
The profile listener adds an Authorization header only when bearerToken contains a credential. At this stage the route ignores that distinction and returns the profile anyway.
- Add the token profile listener below the token login listener in public/app.js:
document.querySelector('#token-profile').addEventListener('click', async () => {
const headers = bearerToken
? { Authorization: `Bearer ${bearerToken}` }
: {};
const result = await callApi('/api/token/profile', { headers });
showResult(tokenOutput, result);
});
How Is the Bearer Header Chosen?
- A stored token produces an Authorization request header beginning with Bearer .
- An empty token produces an empty headers object.
- The temporary profile route responds without checking either case.
- Add the token logout listener directly below the token profile listener:
document.querySelector('#token-logout').addEventListener('click', async () => {
const headers = bearerToken
? { Authorization: `Bearer ${bearerToken}` }
: {};
const result = await callApi('/api/token/logout', postJson({}, headers));
if (result.status === 200) {
bearerToken = '';
}
showResult(tokenOutput, result);
});
When Is the Browser Token Cleared?
- The logout request presents the current token when one exists.
- A successful response clears bearerToken from the page's JavaScript memory.
- The result remains visible after the local token value changes.
- Save public/app.js.
- Refresh the dashboard in Microsoft Edge.
- Select Check token profile.
You should see status 200 with the private demo profile even though no token was requested.
Token Profile Request Failing?
Confirm the route path is /api/token/profile in both public/app.js and server.js.
Help me debug why the unprotected token profile request does not return status 200.
✔️ Awesome, I've got everything!
Both profile controls now reach the temporary server. Save public/index.html and public/app.js before the final check.
ⓧ I'd like to double check the full code
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Auth Lab</title>
<link rel="stylesheet" href="styles.css">
</head>
<body>
<main class="shell">
<header class="hero">
<p class="eyebrow">Express authentication mini project</p>
<h1>Auth Lab</h1>
<p>Compare a browser-managed session cookie with an explicitly supplied bearer token.</p>
</header>
<section class="credentials" aria-labelledby="credentials-title">
<h2 id="credentials-title">Demo account</h2>
<label>
Email
<input id="email" type="email" value="learner@example.com">
</label>
<label>
Password
<input id="password" type="password" value="learn123">
</label>
</section>
<section class="grid">
<article class="card">
<span class="badge">Server-managed state</span>
<h2>Session flow</h2>
<p>The browser sends the session cookie automatically.</p>
<div class="actions">
<button id="session-login">Log in with session</button>
<button id="session-profile" class="secondary">Check session profile</button>
<button id="session-logout" class="danger">Destroy session</button>
</div>
<pre id="session-output" aria-live="polite">No session request yet.</pre>
</article>
<article class="card">
<span class="badge">Client-supplied credential</span>
<h2>Bearer token flow</h2>
<p>The page adds the token to the Authorization header.</p>
<div class="actions">
<button id="token-login">Request token</button>
<button id="token-profile" class="secondary">Check token profile</button>
<button id="token-logout" class="danger">Revoke token</button>
</div>
<pre id="token-output" aria-live="polite">No token request yet.</pre>
</article>
</section>
</main>
<script src="app.js"></script>
</body>
</html>
let bearerToken = '';
const emailInput = document.querySelector('#email');
const passwordInput = document.querySelector('#password');
const sessionOutput = document.querySelector('#session-output');
const tokenOutput = document.querySelector('#token-output');
function credentials() {
return {
email: emailInput.value,
password: passwordInput.value
};
}
function showResult(output, result) {
output.textContent = JSON.stringify(result, null, 2);
}
async function callApi(path, options = {}) {
try {
const response = await fetch(path, options);
const data = await response.json();
return {
status: response.status,
data
};
} catch (error) {
return {
status: 'network error',
data: { message: error.message }
};
}
}
function postJson(body, headers = {}) {
return {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...headers
},
body: JSON.stringify(body)
};
}
document.querySelector('#session-login').addEventListener('click', async () => {
const result = await callApi('/api/session/login', postJson(credentials()));
showResult(sessionOutput, result);
});
document.querySelector('#session-profile').addEventListener('click', async () => {
const result = await callApi('/api/session/profile');
showResult(sessionOutput, result);
});
document.querySelector('#session-logout').addEventListener('click', async () => {
const result = await callApi('/api/session/logout', postJson({}));
showResult(sessionOutput, result);
});
document.querySelector('#token-login').addEventListener('click', async () => {
const result = await callApi('/api/token/login', postJson(credentials()));
if (result.status === 200) {
bearerToken = result.data.token;
}
showResult(tokenOutput, result);
});
document.querySelector('#token-profile').addEventListener('click', async () => {
const headers = bearerToken
? { Authorization: `Bearer ${bearerToken}` }
: {};
const result = await callApi('/api/token/profile', { headers });
showResult(tokenOutput, result);
});
document.querySelector('#token-logout').addEventListener('click', async () => {
const headers = bearerToken
? { Authorization: `Bearer ${bearerToken}` }
: {};
const result = await callApi('/api/token/logout', postJson({}, headers));
if (result.status === 200) {
bearerToken = '';
}
showResult(tokenOutput, result);
});
const express = require('express');
const app = express();
const PORT = 3000;
const DEMO_USER = {
id: 1,
email: 'learner@example.com',
password: 'learn123',
role: 'user'
};
app.use(express.json());
app.use(express.static('public'));
app.get('/api/session/profile', (req, res) => {
res.json({
message: 'Session-protected profile unlocked.',
user: DEMO_USER
});
});
app.get('/api/token/profile', (req, res) => {
res.json({
message: 'Token-protected profile unlocked.',
user: DEMO_USER
});
});
app.listen(PORT, () => {
console.log(`Auth Lab is running at http://localhost:${PORT}`);
});
Trigger the intended leak
The two profile routes are labelled as protected resources, but labels do not enforce access. This final check asks the server for both profiles before either login flow has succeeded.
Before you test both buttons, do you expect either route to reject a request that has no session or bearer token?
- Refresh http://localhost:3000 in Microsoft Edge to clear the visible response panels.
- Select Check session profile before selecting the Session login control.
You should see status 200 with the demo user's ID, email, password, and role in the Session panel.
- Select Check token profile before requesting a bearer token.
You should see status 200 with the same private demo record in the Bearer Token panel.
This Failure Is Intentional
Both requests succeeded without credentials because the route handlers returned DEMO_USER immediately. Anyone who can reach either endpoint can read the supposedly private profile.
The successful status is the security failure. The server needs to stop unauthenticated requests before either protected handler returns data.
One Panel Does Not Show Status 200?
Confirm both profile paths in public/app.js match their routes in server.js exactly. Save both files before refreshing the page.
Help me compare the Auth Lab profile listeners with the temporary server routes.
You have reproduced the leak and captured proof that browser labels cannot protect server data. Next, you will make the Session profile reject unauthenticated requests before private data reaches the handler.
Protect the Session Route
Your earlier test proved that the Express session profile route returns private data to anyone who asks. The route handler needs a gate before it can respond.
A server-side session keeps the authenticated user record on the server. Authentication middleware checks that state before the private handler runs.
In this step, get ready to:
- Configure server-managed sessions with a signed session ID cookie.
- Add login and logout routes that create or destroy session state.
- Prove the protected profile returns 401 before login and after logout.
Configure server-side sessions
The express-session middleware stores session data on the server. The browser receives a signed session cookie containing the session ID.
- Switch back to server.js in Visual Studio Code.
- Replace the existing Express import with this complete import group:
const express = require('express');
const session = require('express-session');
const { randomBytes, randomUUID } = require('node:crypto');
What do these imports provide?
- The express import keeps the existing server features available.
- The session import provides the middleware that manages server-side session state.
- The crypto import provides secure random values. This session setup uses randomBytes to generate the session secret.
- Find app.use(express.json()); in server.js.
- Add this session middleware immediately below it:
app.use(
session({
secret: randomBytes(32),
name: 'authlab.sid',
resave: false,
saveUninitialized: false,
cookie: {
httpOnly: true,
sameSite: 'lax',
secure: false
}
})
);
How is the session configured?
- The randomBytes(32) value generates a fresh secret when the local server starts.
- The name option changes the cookie name to authlab.sid.
- The saveUninitialized: false setting avoids storing an empty session before login.
- The httpOnly option keeps browser-side JavaScript from reading the cookie.
- The secure: false setting allows this learning lab to use local HTTP. Real deployments should transport authentication credentials over HTTPS.
- Save server.js.
- Switch back to the Visual Studio Code terminal.
- Stop the running server by pressing Ctrl+C.
- Restart the server with the new session configuration by running:
node server.js
What should you see?
You should see Auth Lab is running at http://localhost:3000 in the terminal. This confirms the session middleware loads before the routes.
Server not restarting?
Check that the session middleware appears after app.use(express.json()); and before app.use(express.static('public'));.
Check each closing brace and parenthesis against the code block above.
Help me debug the session middleware in server.js.
Block unauthenticated profile requests
A protected route needs a safe public user shape plus middleware that checks req.session.user. A missing user ends the request with 401 before the profile handler runs.
- Add these user helpers below app.use(express.static('public')); in server.js:
function publicUser(user) {
return {
id: user.id,
email: user.email,
role: user.role
};
}
function credentialsAreValid(email, password) {
return email === DEMO_USER.email && password === DEMO_USER.password;
}
Why use these helpers?
- The publicUser() helper returns only the user ID, email address, and role.
- The credentialsAreValid() helper compares submitted credentials with the local teaching fixture.
- The fixed password belongs only in this local lab. Production authentication requires password hashing and durable user storage.
- Save server.js.
- Switch back to the Visual Studio Code terminal.
- Stop the running server by pressing Ctrl+C.
- Confirm the helpers compile by restarting the server:
node server.js
What does this restart prove?
You should see the Auth Lab address again with no syntax error. The server can now create a public user record without exposing the password.
Seeing a syntax error?
Confirm that publicUser() closes before credentialsAreValid() begins.
Help me find the syntax error in my Auth Lab user helpers.
- Add this middleware below credentialsAreValid() in server.js:
function requireSession(req, res, next) {
if (!req.session.user) {
return res.status(401).json({ message: 'Login with the session flow first.' });
}
next();
}
What does requireSession do?
- The if check looks for the public user record stored in the current session.
- The 401 response stops unauthenticated requests before they reach private data.
- The next() call passes authenticated requests to the profile handler.
- Find the temporary GET /api/session/profile handler in server.js.
- Replace the complete temporary handler with this protected route:
app.get('/api/session/profile', requireSession, (req, res) => {
res.json({
message: 'Session-protected profile unlocked.',
user: req.session.user
});
});
Where is the protection applied?
Express runs requireSession before the route handler. Only a request with req.session.user reaches the private profile response.
- Save server.js.
- Switch back to the Visual Studio Code terminal.
- Stop the running server by pressing Ctrl+C.
- Restart the protected server by running:
node server.js
What should the terminal show?
You should see the Auth Lab address with no error. The updated middleware chain is ready for its first unauthenticated request.
Before you click the profile button, do you expect the response to remain at 200 or change to 401?
- Switch back to the dashboard in Microsoft Edge.
- Click Check session profile.
You should see status 401 with the message Login with the session flow first. The private route handler no longer runs for an unauthenticated request.
Still seeing status 200?
Confirm that requireSession appears between the route path and the handler function.
Restart the server after saving server.js. The running Node.js process does not reload this file automatically.
Help me find why my session profile still returns 200 before login.
Create and destroy the session
The route now rejects anonymous requests. The login route must create trusted session state so the same middleware can grant access.
- Add this login route above the protected session profile route in server.js:
app.post('/api/session/login', (req, res, next) => {
const { email, password } = req.body;
if (!credentialsAreValid(email, password)) {
return res.status(401).json({ message: 'Invalid demo credentials.' });
}
req.session.regenerate((error) => {
if (error) return next(error);
req.session.user = publicUser(DEMO_USER);
req.session.save((saveError) => {
if (saveError) return next(saveError);
res.json({
message: 'Session created. The browser now carries the session ID cookie.',
user: req.session.user
});
});
});
});
What does the login route do?
- The route rejects credentials that do not match the local demo account.
- The regenerate() call creates a new session ID for the authenticated session.
- The route stores only publicUser(DEMO_USER) in req.session.user.
- The save() call writes the session before the success response returns.
- Add this logout route immediately below the protected session profile route:
app.post('/api/session/logout', (req, res, next) => {
req.session.destroy((error) => {
if (error) return next(error);
res.clearCookie('authlab.sid');
res.json({ message: 'Session destroyed.' });
});
});
How does logout remove access?
The destroy() call removes the server-side session. The clearCookie() call expires the browser's authlab.sid cookie.
- Save server.js.
- Switch back to the Visual Studio Code terminal.
- Stop the running server by pressing Ctrl+C.
- Start the complete session flow by running:
node server.js
What does this restart reset?
You should see the Auth Lab address again. Restarting also clears every session from the development-only memory store.
Before you test the sequence, predict which requests can reach the private profile before login and after logout.
- Switch back to the Auth Lab dashboard in Microsoft Edge.
- Click Check session profile.
You should see status 401 because no authenticated session exists.
- Click Log in with session.
You should see status 200 with the session-created message and the public demo user.
- Click Check session profile.
You should see status 200 with Session-protected profile unlocked. The session middleware has granted access.
- Click Destroy session.
You should see status 200 with the message Session destroyed..
- Click Check session profile again.
You should see status 401 again. You have closed the session-route leak and proved that destroyed credentials no longer grant access.
Session sequence not matching?
If login returns 401, restore the demo email and password already supplied by the dashboard.
If the profile stays at 401 after login, confirm that the login route assigns req.session.user = publicUser(DEMO_USER) before calling req.session.save().
Help me debug my Auth Lab 401, 200, 401 session sequence.
✔️ Awesome, I've got everything!
Your saved server.js now protects the session profile while leaving the token profile intentionally unprotected.
ⓧ I'd like to double check the full code
Compare your complete server.js with this cumulative version:
const express = require('express');
const session = require('express-session');
const { randomBytes, randomUUID } = require('node:crypto');
const app = express();
const PORT = 3000;
const DEMO_USER = {
id: 1,
email: 'learner@example.com',
password: 'learn123',
role: 'user'
};
app.use(express.json());
app.use(
session({
secret: randomBytes(32),
name: 'authlab.sid',
resave: false,
saveUninitialized: false,
cookie: {
httpOnly: true,
sameSite: 'lax',
secure: false
}
})
);
app.use(express.static('public'));
function publicUser(user) {
return {
id: user.id,
email: user.email,
role: user.role
};
}
function credentialsAreValid(email, password) {
return email === DEMO_USER.email && password === DEMO_USER.password;
}
function requireSession(req, res, next) {
if (!req.session.user) {
return res.status(401).json({ message: 'Login with the session flow first.' });
}
next();
}
app.post('/api/session/login', (req, res, next) => {
const { email, password } = req.body;
if (!credentialsAreValid(email, password)) {
return res.status(401).json({ message: 'Invalid demo credentials.' });
}
req.session.regenerate((error) => {
if (error) return next(error);
req.session.user = publicUser(DEMO_USER);
req.session.save((saveError) => {
if (saveError) return next(saveError);
res.json({
message: 'Session created. The browser now carries the session ID cookie.',
user: req.session.user
});
});
});
});
app.get('/api/session/profile', requireSession, (req, res) => {
res.json({
message: 'Session-protected profile unlocked.',
user: req.session.user
});
});
app.post('/api/session/logout', (req, res, next) => {
req.session.destroy((error) => {
if (error) return next(error);
res.clearCookie('authlab.sid');
res.json({ message: 'Session destroyed.' });
});
});
app.get('/api/token/profile', (req, res) => {
res.json({
message: 'Token-protected profile unlocked.',
user: publicUser(DEMO_USER)
});
});
app.use((error, req, res, _next) => {
console.error(error);
res.status(500).json({ message: 'Unexpected server error.' });
});
app.listen(PORT, () => {
console.log(`Auth Lab is running at http://localhost:${PORT}`);
});
What should you compare?
Check the middleware order first. Then compare the three session routes and confirm that only the session profile uses requireSession.
Your session route now checks server-managed authentication state before returning private data. Next up, you will protect the remaining token route with an explicitly supplied bearer credential.
Protect the Token Route
Your server-side session now blocks anonymous profile requests before private data leaves the server. The token profile still leaks the same data because its route has no authentication middleware.
In this step, you will make the client present an opaque bearer token with each protected request. Your Express server will validate that credential through a reusable middleware function.
In this step, get ready to:
- Issue opaque bearer tokens from the server.
- Protect the token profile with authentication middleware.
- Compare credential transport before revoking each credential.
Issue an opaque token
An opaque token is a random credential with no useful information encoded inside it. The server stores the matching user in an in-memory map.
- Switch back to the Visual Studio Code terminal from earlier.
- Stop the running server by pressing Ctrl+C.
- In server.js, replace the opening setup section through the demo user with this updated section:
const express = require('express');
const session = require('express-session');
const { randomBytes, randomUUID } = require('node:crypto');
const app = express();
const PORT = 3000;
const DEMO_USER = {
id: 1,
email: 'learner@example.com',
password: 'learn123',
role: 'user'
};
const activeTokens = new Map();
What does this setup add?
- The randomUUID() function generates an opaque value for each successful token login.
- The activeTokens map connects each issued token to its public user record.
- The map lives in server memory. Restarting the server therefore revokes every token from the previous process.
- Save server.js.
- Restart the server by running this command:
node server.js
What does this command prove?
Node loads the updated imports before starting Auth Lab. A successful startup confirms that randomUUID and activeTokens are available to the routes you add next.
You should see Auth Lab is running at http://localhost:3000 in the terminal.
Server not starting?
Check that the crypto import contains both randomBytes and randomUUID. Confirm that activeTokens appears after the closing brace of DEMO_USER.
Help me debug the token setup in server.js.
The token login route needs to check the demo credentials before issuing anything. A successful login creates a UUID and stores only the public user record beside it.
- Switch back to the terminal running the server.
- Stop the server by pressing Ctrl+C.
- In server.js, add this route below the session logout route:
app.post('/api/token/login', (req, res) => {
const { email, password } = req.body;
if (!credentialsAreValid(email, password)) {
return res.status(401).json({ message: 'Invalid demo credentials.' });
}
const token = randomUUID();
activeTokens.set(token, publicUser(DEMO_USER));
res.json({
message: 'Opaque bearer token issued.',
token,
user: publicUser(DEMO_USER)
});
});
What does the token login do?
- The credential check returns 401 when the supplied email or password is invalid.
- The generated token becomes the key in activeTokens.
- The JSON response gives the page the token it must present on later requests.
- Save server.js.
- Restart the server by running this command:
node server.js
Why restart the server?
The running Node process does not reload route changes automatically. Restarting it loads the new token login handler.
- Return to Auth Lab in Microsoft Edge.
- Refresh the page.
- Click Request token.
You should see status 200 with an opaque token and the public demo user. Good progress. The server can now issue a credential that the page keeps only in JavaScript memory.
Token request not succeeding?
Confirm that the token login route appears after the JSON middleware. Restart the server after saving the route.
Help me debug the token login route.
Protect and revoke the token
Authentication middleware sits before a protected route handler. It rejects a missing or unknown credential before the profile handler can return private data.
- Switch back to the terminal running the server.
- Stop the server by pressing Ctrl+C.
- In server.js, add this function below requireSession.
function requireToken(req, res, next) {
const authorization = req.get('authorization');
if (!authorization || !authorization.startsWith('Bearer ')) {
return res.status(401).json({ message: 'A bearer token is required.' });
}
const token = authorization.slice('Bearer '.length);
const user = activeTokens.get(token);
if (!user) {
return res.status(401).json({ message: 'The bearer token is invalid or revoked.' });
}
req.authToken = token;
req.user = user;
next();
}
How does the middleware protect data?
- The middleware reads the Authorization request header.
- It requires the Authorization: Bearer <token> shape before looking up the token.
- It attaches the matching user to req.user for the protected handler.
- It keeps the token on req.authToken so the logout route can revoke that exact credential.
- Replace the temporary token profile route in server.js with this protected route:
app.get('/api/token/profile', requireToken, (req, res) => {
res.json({
message: 'Token-protected profile unlocked.',
user: req.user
});
});
What changed on the profile route?
The requireToken function now runs before the route handler. The handler can use req.user only after the middleware accepts the credential.
- Save server.js.
- Restart the server by running this command:
node server.js
What does this restart activate?
The new process loads requireToken before accepting requests. It also starts with an empty activeTokens map.
Before you test the protected route, do you think the profile request can still return private data without a current token?
- Return to Auth Lab in Microsoft Edge.
- Refresh the page.
- Click Check token profile.
You should see status 401 with a message that a bearer token is required. This is the exact server-side boundary that the leaked route was missing.
Profile still returning private data?
Confirm that requireToken appears between the route path and handler. Restart the server after replacing the temporary route.
Help me find why the token profile still returns status 200.
Revocation removes an issued token from the server's map. Any later request using that token fails because the server can no longer find a matching user.
- Switch back to the terminal running the server.
- Stop the server by pressing Ctrl+C.
- In server.js, add this logout route below the token profile route:
app.post('/api/token/logout', requireToken, (req, res) => {
activeTokens.delete(req.authToken);
res.json({ message: 'Bearer token revoked.' });
});
How does revocation work?
The logout route first authenticates the request through requireToken. It then deletes that credential from activeTokens.
The page clears bearerToken after a successful response. Both sides stop treating the credential as active.
- Save server.js.
- Restart the server by running this command:
node server.js
What is active now?
The complete session and token flows now share one running server. Both protected profile handlers have authentication middleware in front of them.
✔️ Awesome, I've got everything!
Your token login, profile middleware, and revocation route are saved. Keep the server running for the browser checks.
ⓧ I'd like to double check the full code
These are the cumulative files for the protected token flow.
const express = require('express');
const session = require('express-session');
const { randomBytes, randomUUID } = require('node:crypto');
const app = express();
const PORT = 3000;
const DEMO_USER = {
id: 1,
email: 'learner@example.com',
password: 'learn123',
role: 'user'
};
const activeTokens = new Map();
app.use(express.json());
app.use(
session({
secret: randomBytes(32),
name: 'authlab.sid',
resave: false,
saveUninitialized: false,
cookie: {
httpOnly: true,
sameSite: 'lax',
secure: false
}
})
);
app.use(express.static('public'));
function publicUser(user) {
return {
id: user.id,
email: user.email,
role: user.role
};
}
function credentialsAreValid(email, password) {
return email === DEMO_USER.email && password === DEMO_USER.password;
}
function requireSession(req, res, next) {
if (!req.session.user) {
return res.status(401).json({ message: 'Login with the session flow first.' });
}
next();
}
function requireToken(req, res, next) {
const authorization = req.get('authorization');
if (!authorization || !authorization.startsWith('Bearer ')) {
return res.status(401).json({ message: 'A bearer token is required.' });
}
const token = authorization.slice('Bearer '.length);
const user = activeTokens.get(token);
if (!user) {
return res.status(401).json({ message: 'The bearer token is invalid or revoked.' });
}
req.authToken = token;
req.user = user;
next();
}
app.post('/api/session/login', (req, res, next) => {
const { email, password } = req.body;
if (!credentialsAreValid(email, password)) {
return res.status(401).json({ message: 'Invalid demo credentials.' });
}
req.session.regenerate((error) => {
if (error) return next(error);
req.session.user = publicUser(DEMO_USER);
req.session.save((saveError) => {
if (saveError) return next(saveError);
res.json({
message: 'Session created. The browser now carries the session ID cookie.',
user: req.session.user
});
});
});
});
app.get('/api/session/profile', requireSession, (req, res) => {
res.json({
message: 'Session-protected profile unlocked.',
user: req.session.user
});
});
app.post('/api/session/logout', (req, res, next) => {
req.session.destroy((error) => {
if (error) return next(error);
res.clearCookie('authlab.sid');
res.json({ message: 'Session destroyed.' });
});
});
app.post('/api/token/login', (req, res) => {
const { email, password } = req.body;
if (!credentialsAreValid(email, password)) {
return res.status(401).json({ message: 'Invalid demo credentials.' });
}
const token = randomUUID();
activeTokens.set(token, publicUser(DEMO_USER));
res.json({
message: 'Opaque bearer token issued.',
token,
user: publicUser(DEMO_USER)
});
});
app.get('/api/token/profile', requireToken, (req, res) => {
res.json({
message: 'Token-protected profile unlocked.',
user: req.user
});
});
app.post('/api/token/logout', requireToken, (req, res) => {
activeTokens.delete(req.authToken);
res.json({ message: 'Bearer token revoked.' });
});
app.use((error, req, res, _next) => {
console.error(error);
res.status(500).json({ message: 'Unexpected server error.' });
});
app.listen(PORT, () => {
console.log(`Auth Lab is running at http://localhost:${PORT}`);
});
let bearerToken = '';
const emailInput = document.querySelector('#email');
const passwordInput = document.querySelector('#password');
const sessionOutput = document.querySelector('#session-output');
const tokenOutput = document.querySelector('#token-output');
function credentials() {
return {
email: emailInput.value,
password: passwordInput.value
};
}
function showResult(output, result) {
output.textContent = JSON.stringify(result, null, 2);
}
async function callApi(path, options = {}) {
try {
const response = await fetch(path, options);
const data = await response.json();
return {
status: response.status,
data
};
} catch (error) {
return {
status: 'network error',
data: { message: error.message }
};
}
}
function postJson(body, headers = {}) {
return {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...headers
},
body: JSON.stringify(body)
};
}
document.querySelector('#session-login').addEventListener('click', async () => {
const result = await callApi('/api/session/login', postJson(credentials()));
showResult(sessionOutput, result);
});
document.querySelector('#session-profile').addEventListener('click', async () => {
const result = await callApi('/api/session/profile');
showResult(sessionOutput, result);
});
document.querySelector('#session-logout').addEventListener('click', async () => {
const result = await callApi('/api/session/logout', postJson({}));
showResult(sessionOutput, result);
});
document.querySelector('#token-login').addEventListener('click', async () => {
const result = await callApi('/api/token/login', postJson(credentials()));
if (result.status === 200) {
bearerToken = result.data.token;
}
showResult(tokenOutput, result);
});
document.querySelector('#token-profile').addEventListener('click', async () => {
const headers = bearerToken
? { Authorization: `Bearer ${bearerToken}` }
: {};
const result = await callApi('/api/token/profile', { headers });
showResult(tokenOutput, result);
});
document.querySelector('#token-logout').addEventListener('click', async () => {
const headers = bearerToken
? { Authorization: `Bearer ${bearerToken}` }
: {};
const result = await callApi('/api/token/logout', postJson({}, headers));
if (result.status === 200) {
bearerToken = '';
}
showResult(tokenOutput, result);
});
Inspect and revoke both credentials
The session flow relies on a browser-managed cookie. The token flow explicitly creates an Authorization: Bearer <token> header in public/app.js.
The token displayed by this lab is a disposable local credential. Avoid capturing its complete value in any screenshot.
- Return to Auth Lab in Microsoft Edge.
- Refresh the page.
- Press F12 to open Microsoft Edge DevTools.
- Select the Network tab in DevTools.
- Click Log in with session in Auth Lab.
- Click Check session profile.
- Select the /api/session/profile request in the Network list.
- Select the Cookies tab for that request.
You should see authlab.sid among the cookies sent with the session profile request. The browser supplied that session ID automatically.
What does the session cookie prove?
The cookie carries a signed session identifier. The corresponding user record remains in the server-side session store.
- Click Request token in Auth Lab.
- Click Check token profile.
- Select the /api/token/profile request in the Network list.
- Select the Headers panel for that request.
You should see an Authorization request header beginning with Bearer . The page added this credential explicitly from bearerToken.
Keep bearer tokens on secure transport
This loopback lab uses local HTTP for learning. Real bearer-token traffic requires TLS because anyone who obtains the token can present it as the credential.
- Return to the Auth Lab dashboard.
- Click Revoke token.
The token panel should show status 200 with Bearer token revoked..
- Click Destroy session.
The session panel should show status 200 with Session destroyed..
Before you check both profiles again, do you expect either revoked credential to retain access?
- Click Check token profile.
- Click Check session profile.
Both response panels should now show status 401. That completes the comparison. Your cookie-backed session and explicit bearer token both lose access after server-side revocation.
A profile still returning status 200?
Confirm that the session profile uses requireSession. Confirm that the token profile uses requireToken.
Check that token logout calls activeTokens.delete(req.authToken) before sending its response.
Help me debug why revoked credentials still reach a protected route.
Secret mission
Add Role-Based Authorization
A valid token proves who is calling. It does not prove that the user may access an admin route. Add a separate role check that returns 403 to the learner account and 200 to the admin account.
Clean Up Your Resources
Clean Up Your Resources
Auth Lab runs entirely on your Windows machine, so it creates no ongoing cloud costs. Its local Node.js server uses memory only while the process is running.
The options below let you keep the lab running, pause its server, or remove its files.
Resources you used:
- The auth-lab project directory containing server.js, package.json, public, and node_modules.
- The local Express server process that serves the dashboard at http://localhost:3000.
Keep everything running
No action is required. Choose this option if you are still comparing the authentication flows or extending the admin route.
- Keep the Visual Studio Code terminal running while you use the dashboard.
- Keep the auth-lab directory to preserve your code.
- Return to http://localhost:3000 in Microsoft Edge whenever you want to test the session flow or token flow.
Pause - I'll come back to this later
Stop the local server to free its memory. Your project files remain ready for another session.
- Switch back to the Visual Studio Code terminal that is running the server.
- Press Ctrl+C to stop the server process.
- Refresh the Auth Lab tab in Microsoft Edge.
Your lab is now safely paused. The browser can no longer reconnect to the local server.
- Leave the auth-lab directory in its current location.
- Use the server start command from Step 2 when you return.
Delete - I don't want to use this again
Deleting the directory is permanent. You can rebuild Auth Lab later by following the project instructions again.
- Switch back to the Visual Studio Code terminal that is running the server.
- Press Ctrl+C to stop the server process.
The terminal prompt returns when the server has stopped.
- Remove the auth-lab directory by running these commands in PowerShell:
Set-Location ..
Remove-Item -Recurse -Force auth-lab
Get-ChildItem
What Does This Cleanup Do?
- The first line moves the terminal into the directory that contains your project.
- The second line permanently removes the project directory with all its contents.
- The final line lists the remaining items so you can verify the deletion.
You should see the parent directory listing without auth-lab. Your local server and project files are now removed.
Folder Still Listed?
Make sure the terminal prompt returned before retrying the cleanup. A running server can keep project files in use.
Close any Auth Lab files in other programs if Windows reports that the directory is in use.
Help me remove the remaining folder.
Nice Work!
Nice Work!
You did it! You built a working Express authentication lab that proves how protected routes respond before login, after login, and after credential revocation.
You've learned how to:
- Built a visible authentication dashboard that exposed private profile data through deliberately unprotected routes. The successful responses showed why server-side protection must run before private data leaves the route handler.
- Protected a profile route with server-side sessions and reusable middleware. The login and logout flow produced the expected 401, 200, and 401 sequence.
- Protected a second route with an opaque bearer token. You used Microsoft Edge DevTools to compare the explicit Authorization header with the browser-managed session cookie. Revocation proved that old credentials stop granting access.
- Completed the Secret Mission by separating authentication from role-based authorization. The same admin endpoint returned 403 for the authenticated learner account and 200 for the authenticated admin account.
Ready to quiz yourself?