Build a Login & Signup System
Build a secure auth system with Express.js, bcrypt, and session cookies.
Introduction
30 Second Summary
Every time you log into a website, there's a system behind the scenes checking your identity, creating a session, and deciding what you're allowed to see. Most developers use these systems daily without understanding how they actually work.
In this project, you will build a complete login and signup system from scratch using Node.js and Express.js. You'll hash passwords, manage sessions with cookies, and protect routes from unauthorized access.
What You'll Build
A complete authentication system with a Node.js/Express backend where users can create an account, log in, view a protected dashboard, and log out.
By the end of this project, you'll have:
- A signup and login system that securely hashes passwords with bcrypt before storing them in a SQLite database.
- Server-side sessions that keep users logged in across page refreshes using signed cookies.
- Auth middleware that protects routes and redirects unauthenticated users to the login page.
- Secret Mission: Add a "Remember Me" checkbox that extends the session from 30 minutes to 7 days.
Are there any prerequisites?
You should have basic JavaScript knowledge and familiarity with how the web works (HTTP requests and responses). No prior experience with Express.js or authentication is needed.
Before We Start
Before We Start...
Set Up the Project
Before you can build an authentication system, you need a working project environment. This means creating a folder for your code, initializing a Node.js project, and pulling in the libraries that handle password hashing, sessions, and database storage.
In this step, you will scaffold the entire project from scratch so every future step has a clean foundation to build on.
In this step, get ready to:
- Initialize a new Node.js project with a package.json file.
- Install Express, express-session, bcryptjs, and better-sqlite3.
- Create the folder structure and empty files for the server, database, routes, and middleware.
Create and initialize the project folder
Every Node.js project starts with a dedicated folder and a package.json file that tracks your project's name, version, and dependencies.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Terminal (macOS) or PowerShell (Windows) and press Enter to open it.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder and move into it by running these commands:
mkdir auth-demo
cd auth-demo
- Initialize the Node.js project by running:
npm init -y
You should see a JSON output in your terminal showing the generated package.json with name set to auth-demo and version set to 1.0.0.
What does npm init -y do?
The npm init command creates a package.json file, which is the identity card for any Node.js project. It records the project name, version, and (soon) all the packages your project depends on.
The -y flag skips the interactive questionnaire and fills in sensible defaults based on your folder name.
- Now open Cursor. Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Cursor and press Enter to open it.
- In Cursor, click File in the top menu bar.
- Click Open Folder.
- Navigate to your Desktop, select the auth-demo folder, and click Open.
You should see auth-demo in Cursor's Explorer sidebar on the left, with package.json listed inside it.
Don't see the auth-demo folder on your Desktop?
Make sure you ran cd ~/Desktop before mkdir auth-demo. If the folder ended up somewhere else, you can find it by running pwd in the terminal to see your current location.
Help me find my auth-demo folder
Install the project dependencies
Your authentication system relies on four packages that handle the web server, sessions, password hashing, and database storage.
- Open Cursor's integrated terminal by pressing Ctrl+` (backtick key, both macOS and Windows).
- Install all four dependencies by running:
npm install express@5.2.1 express-session@1.19.0 bcryptjs@3.0.3 better-sqlite3@12.10.0
What are these packages?
- express@5.2.1 is the web framework that handles HTTP requests and routes.
- express-session@1.19.0 manages user sessions so the server remembers who is logged in.
- bcryptjs@3.0.3 hashes passwords so they are never stored as plain text.
- better-sqlite3@12.10.0 gives you a local SQL database stored as a single file. No database server required.
You should see terminal output listing the installed packages. A node_modules folder and a package-lock.json file now appear in the Explorer sidebar.
Seeing errors during install?
If you see errors about better-sqlite3 failing to compile or missing prebuilt binaries, check your Node.js version by running node --version in the terminal. You need Node.js v22 or higher for prebuilt binaries to be available.
Help me fix my npm install errors
- Now update package.json to set the correct description, entry point, and start script. Click on package.json in the Explorer sidebar to open it.
- Select all the content in the file (Cmd+A on macOS or Ctrl+A on Windows).
- Replace it with the following:
{
"name": "auth-demo",
"version": "1.0.0",
"description": "Login and signup system with Express.js",
"main": "server.js",
"scripts": {
"start": "node server.js"
},
"dependencies": {
"express": "5.2.1",
"express-session": "1.19.0",
"bcryptjs": "3.0.3",
"better-sqlite3": "12.10.0"
}
}
What did you change?
- description tells anyone reading the project what it does.
- main is set to server.js because that will be the entry point for your app.
- scripts.start lets you run npm start to launch the server instead of typing the full command.
- Save package.json by pressing Cmd+S (macOS) or Ctrl+S (Windows).
✔️ Awesome, I've got everything!
Great. Double check you've saved package.json and move on to creating the file structure.
ⓧ I'd like to double check the full code
Your package.json should look exactly like this:
{
"name": "auth-demo",
"version": "1.0.0",
"description": "Login and signup system with Express.js",
"main": "server.js",
"scripts": {
"start": "node server.js"
},
"dependencies": {
"express": "5.2.1",
"express-session": "1.19.0",
"bcryptjs": "3.0.3",
"better-sqlite3": "12.10.0"
}
}
Create the project file structure
Your project needs separate files for the server, database setup, authentication routes, and middleware. Creating them now means each future step can focus on writing code instead of file management.
macOS
- Create the folders and empty files by running these commands in your Cursor terminal:
mkdir routes middleware public
touch server.js database.js routes/auth.js middleware/auth.js
Windows
- Create the folders and empty files by running these commands in your Cursor terminal:
mkdir routes, middleware, public
New-Item -ItemType File -Path server.js, database.js, routes\auth.js, middleware\auth.js
- Confirm the files exist by running:
ls
You should see server.js, database.js, routes/, middleware/, and public/ in the output (alongside node_modules, package.json, and package-lock.json).
Missing files or folders?
Make sure your terminal is inside the auth-demo folder. Run pwd (macOS) or cd (Windows) to check your current location. If you are somewhere else, run cd ~/Desktop/auth-demo to get back.
Help me fix my project file structure
Your project skeleton is ready. Next up, you will create the database and build the HTML pages that users interact with.
Create the Database and HTML Pages
Your project folder is set up with all dependencies installed and empty files ready to fill. Now it's time to give your app a way to store user accounts and a front end for people to interact with.
Every authentication system needs a database to remember who signed up and what their credentials are. In this step, you'll create a SQLite database and the HTML pages users will use to create accounts and log in.
In this step, get ready to:
- Set up a SQLite database with a users table.
- Create the home page with navigation links.
- Build signup and login forms that submit data to the server.
Set up the SQLite database
Your auth system needs a place to persist user accounts between server restarts. SQLite stores everything in a single file, so you don't need to install or run a separate database server.
- In Cursor's Explorer sidebar, click on database.js to open it.
- Paste the following code into database.js:
const Database = require('better-sqlite3');
const path = require('path');
// Create (or open) the database file in the project folder
const db = new Database(path.join(__dirname, 'users.db'));
// Create the users table if it doesn't already exist
db.exec(`
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT UNIQUE NOT NULL,
password TEXT NOT NULL,
created_at TEXT DEFAULT CURRENT_TIMESTAMP
)
`);
module.exports = db;
What does this code do?
This file sets up your database and exports it so other files can use it.
- better-sqlite3 creates or opens a file called users.db in your project folder.
- The CREATE TABLE IF NOT EXISTS statement builds the users table the first time and does nothing on subsequent runs.
- Each user row stores an auto-incrementing id, a unique email, a hashed password, and a created_at timestamp.
- Save database.js.
- Verify the database gets created by running this command in your terminal:
node database.js
- Confirm the database file was created by running:
ls
You should see users.db listed alongside your other project files.
Seeing an error when running node database.js?
- If you see Cannot find module 'better-sqlite3', your dependencies may not have installed correctly. Run npm install again from your project folder.
- Make sure your terminal is in the project folder (the one containing package.json). Run ls to check.
Why is my database.js throwing an error?
✔️ Awesome, I've got everything!
Great. Make sure you've saved database.js before moving on.
ⓧ I'd like to double check the full code
const Database = require('better-sqlite3');
const path = require('path');
const db = new Database(path.join(__dirname, 'users.db'));
db.exec(`
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT UNIQUE NOT NULL,
password TEXT NOT NULL,
created_at TEXT DEFAULT CURRENT_TIMESTAMP
)
`);
module.exports = db;
Create the home page
Your app needs a landing page where users can navigate to the signup or login forms. This page will also have a link to the protected dashboard to demonstrate route protection later.
- Right-click the public folder in Cursor's Explorer sidebar.
- Select New File and name it index.html.
- Paste the following code into public/index.html:
<!DOCTYPE html>
<html>
<head>
<title>Auth Demo</title>
<style>
body { font-family: sans-serif; max-width: 600px; margin: 50px auto; padding: 20px; }
a { display: inline-block; margin: 10px 10px 10px 0; padding: 10px 20px; background: #1976d2; color: white; text-decoration: none; border-radius: 4px; }
a:hover { background: #1565c0; }
</style>
</head>
<body>
<h1>Authentication Demo</h1>
<p>A simple login and signup system built with Express.js, sessions, and bcrypt.</p>
<!-- Navigation links to the three main areas -->
<a href="/signup.html">Sign Up</a>
<a href="/login.html">Log In</a>
<a href="/dashboard">Dashboard (Protected)</a>
</body>
</html>
What does this page do?
This is your app's landing page. It provides three styled links:
- /signup.html takes users to the registration form.
- /login.html takes users to the login form.
- /dashboard points to the protected route you'll build in Step 3. Without a session, users will be redirected away from it.
- Save public/index.html.
- You should see index.html appear under the public folder in Cursor's Explorer sidebar.
You won't be able to view this page in a browser yet. The Express server (which serves these static files) gets built in the next step.
✔️ Awesome, I've got everything!
Great. Make sure index.html is saved and visible in the public folder.
ⓧ I'd like to double check the full code
<!DOCTYPE html>
<html>
<head>
<title>Auth Demo</title>
<style>
body { font-family: sans-serif; max-width: 600px; margin: 50px auto; padding: 20px; }
a { display: inline-block; margin: 10px 10px 10px 0; padding: 10px 20px; background: #1976d2; color: white; text-decoration: none; border-radius: 4px; }
a:hover { background: #1565c0; }
</style>
</head>
<body>
<h1>Authentication Demo</h1>
<p>A simple login and signup system built with Express.js, sessions, and bcrypt.</p>
<a href="/signup.html">Sign Up</a>
<a href="/login.html">Log In</a>
<a href="/dashboard">Dashboard (Protected)</a>
</body>
</html>
Create the signup and login forms
Users need HTML forms to submit their email and password. Each form uses a standard POST request to send credentials to the server.
- Right-click the public folder in the Explorer sidebar.
- Select New File and name it signup.html.
- Paste the following code into public/signup.html:
<!DOCTYPE html>
<html>
<head>
<title>Sign Up</title>
<style>
body { font-family: sans-serif; max-width: 400px; margin: 50px auto; padding: 20px; }
form { display: flex; flex-direction: column; gap: 15px; }
input { padding: 10px; border: 1px solid #ccc; border-radius: 4px; font-size: 16px; }
button { padding: 12px; background: #1976d2; color: white; border: none; border-radius: 4px; font-size: 16px; cursor: pointer; }
button:hover { background: #1565c0; }
a { color: #1976d2; }
</style>
</head>
<body>
<h1>Sign Up</h1>
<!-- Form submits email and password to the signup route -->
<form action="/auth/signup" method="POST">
<input type="email" name="email" placeholder="Email" required />
<input type="password" name="password" placeholder="Password (min 6 chars)" required minlength="6" />
<button type="submit">Create Account</button>
</form>
<p>Already have an account? <a href="/login.html">Log in</a></p>
</body>
</html>
What does this form do?
The action="/auth/signup" attribute tells the browser where to send the form data. The method="POST" attribute means the data goes in the request body (not the URL).
The required and minlength="6" attributes add basic browser-side validation before the form even submits. You'll add server-side validation too in Step 4.
- Save public/signup.html.
✔️ Awesome, I've got everything!
Great. Save and move on to the login page next.
ⓧ I'd like to double check the full code
<!DOCTYPE html>
<html>
<head>
<title>Sign Up</title>
<style>
body { font-family: sans-serif; max-width: 400px; margin: 50px auto; padding: 20px; }
form { display: flex; flex-direction: column; gap: 15px; }
input { padding: 10px; border: 1px solid #ccc; border-radius: 4px; font-size: 16px; }
button { padding: 12px; background: #1976d2; color: white; border: none; border-radius: 4px; font-size: 16px; cursor: pointer; }
button:hover { background: #1565c0; }
a { color: #1976d2; }
</style>
</head>
<body>
<h1>Sign Up</h1>
<form action="/auth/signup" method="POST">
<input type="email" name="email" placeholder="Email" required />
<input type="password" name="password" placeholder="Password (min 6 chars)" required minlength="6" />
<button type="submit">Create Account</button>
</form>
<p>Already have an account? <a href="/login.html">Log in</a></p>
</body>
</html>
- Right-click the public folder again and select New File.
- Name it login.html.
- Paste the following code into public/login.html:
<!DOCTYPE html>
<html>
<head>
<title>Log In</title>
<style>
body { font-family: sans-serif; max-width: 400px; margin: 50px auto; padding: 20px; }
form { display: flex; flex-direction: column; gap: 15px; }
input { padding: 10px; border: 1px solid #ccc; border-radius: 4px; font-size: 16px; }
button { padding: 12px; background: #1976d2; color: white; border: none; border-radius: 4px; font-size: 16px; cursor: pointer; }
button:hover { background: #1565c0; }
a { color: #1976d2; }
</style>
</head>
<body>
<h1>Log In</h1>
<!-- Form submits email and password to the login route -->
<form action="/auth/login" method="POST">
<input type="email" name="email" placeholder="Email" required />
<input type="password" name="password" placeholder="Password" required />
<button type="submit">Log In</button>
</form>
<p>Don't have an account? <a href="/signup.html">Sign up</a></p>
</body>
</html>
How does the login form differ from signup?
The login form posts to /auth/login instead of /auth/signup. On the server, the login route will look up the user by email and compare the submitted password against the stored hash.
The signup form has minlength="6" on the password field to enforce a minimum length at signup. The login form skips this since the password already exists in the database.
- Save public/login.html.
- Confirm all three HTML files exist by running this in your terminal:
ls public/
You should see index.html, login.html, and signup.html listed.
Missing a file in the listing?
- Make sure you created the files inside the public folder, not in the project root.
- Double-check the file names for typos. They must be exactly index.html, signup.html, and login.html.
Why aren't my HTML files showing up?
✔️ Awesome, I've got everything!
Great. All three HTML pages are ready and waiting for the server.
ⓧ I'd like to double check the full code
<!DOCTYPE html>
<html>
<head>
<title>Log In</title>
<style>
body { font-family: sans-serif; max-width: 400px; margin: 50px auto; padding: 20px; }
form { display: flex; flex-direction: column; gap: 15px; }
input { padding: 10px; border: 1px solid #ccc; border-radius: 4px; font-size: 16px; }
button { padding: 12px; background: #1976d2; color: white; border: none; border-radius: 4px; font-size: 16px; cursor: pointer; }
button:hover { background: #1565c0; }
a { color: #1976d2; }
</style>
</head>
<body>
<h1>Log In</h1>
<form action="/auth/login" method="POST">
<input type="email" name="email" placeholder="Email" required />
<input type="password" name="password" placeholder="Password" required />
<button type="submit">Log In</button>
</form>
<p>Don't have an account? <a href="/signup.html">Sign up</a></p>
</body>
</html>
Your database is ready to store users and your HTML pages are built. Next up, you'll create the Express server that serves these pages and handles the session cookie that keeps users logged in.
Build the Express Server with Sessions
You have a database ready to store users and HTML pages for them to interact with. Now you need the piece that ties everything together: the server.
In this step, you will build the Express server that listens for requests, serves your HTML pages, and manages user sessions with cookies.
In this step, get ready to:
- Set up Express with body parsing and static file serving.
- Configure server-side sessions with cookie options.
- Add a protected dashboard route that displays the logged-in user's email.
Set up Express with middleware
Middleware functions run on every incoming request before your route handlers see it. You need middleware to parse form data, parse JSON, and serve the HTML files from your public/ folder.
- In Cursor, open server.js (currently empty) and paste the following code:
const express = require('express');
const session = require('express-session');
const path = require('path');
const authRoutes = require('./routes/auth');
const { requireAuth } = require('./middleware/auth');
// Create the Express app and set the port
const app = express();
const PORT = 3000;
// Parse URL-encoded form data and JSON request bodies
app.use(express.urlencoded({ extended: false }));
app.use(express.json());
// Serve static files (HTML pages) from the public/ folder
app.use(express.static(path.join(__dirname, 'public')));
What does this code do?
The first five lines import the packages you installed plus two files you will create in the next steps (routes/auth.js and middleware/auth.js).
- express.urlencoded({ extended: false }) parses the form data that your signup and login forms POST to the server.
- express.json() parses any JSON request bodies.
- express.static() tells Express to serve files from public/ directly. This is how your HTML pages reach the browser.
You won't see a visible change yet. The session configuration in the next section builds on this foundation.
Configure express-session
A session lets your server remember who a user is across multiple requests. When a user logs in, the server creates a session and sends back a session cookie. The browser automatically sends that cookie on every future request, so the server knows the user is still logged in.
- Add the following code directly below the static file middleware in server.js:
// Configure session middleware
app.use(session({
secret: 'your-secret-key-here',
resave: false,
saveUninitialized: false,
cookie: {
httpOnly: true,
secure: false,
maxAge: 1000 * 60 * 30
}
}));
// Mount auth routes at /auth
app.use('/auth', authRoutes);
What does each session option do?
- secret is used to sign the session cookie so it cannot be tampered with.
- resave: false prevents the session from being saved to the store on every request if nothing changed.
- saveUninitialized: false avoids creating sessions for visitors who have not logged in yet.
- httpOnly: true prevents JavaScript in the browser from reading the cookie, which protects against cross-site scripting.
- maxAge: 1000 * 60 * 30 sets the session to expire after 30 minutes of inactivity.
The last line mounts all authentication routes (signup, login, logout) under the /auth path.
You won't see a change yet. The dashboard route in the next section completes the server.
Add a protected dashboard route
The dashboard is the page users see after logging in. It is protected because only authenticated users should be able to access it.
- Add the following code below the app.use('/auth', authRoutes) line in server.js:
// Protected route - requireAuth middleware checks for a valid session
app.get('/dashboard', requireAuth, (req, res) => {
res.send(`
<!DOCTYPE html>
<html>
<head>
<title>Dashboard</title>
<style>
body { font-family: sans-serif; max-width: 600px; margin: 50px auto; padding: 20px; }
.welcome { background: #e8f5e9; padding: 20px; border-radius: 8px; margin-bottom: 20px; }
.logout-btn { background: #e53935; color: white; padding: 10px 20px; border: none; border-radius: 4px; cursor: pointer; text-decoration: none; display: inline-block; }
code { background: #f5f5f5; padding: 2px 6px; border-radius: 3px; }
</style>
</head>
<body>
<div class="welcome">
<h1>Welcome, ${req.session.user.email}!</h1>
<p>You are logged in. This page is protected by session authentication.</p>
<p>Your session ID: <code>${req.sessionID}</code></p>
</div>
<a href="/auth/logout" class="logout-btn">Log Out</a>
<a href="/" style="margin-left: 10px;">Home</a>
</body>
</html>
`);
});
// Start the server
app.listen(PORT, () => {
console.log(`Server running at http://localhost:${PORT}`);
});
What does this code do?
The /dashboard route uses requireAuth as middleware. This function runs before the route handler and redirects anyone without a valid session to the login page.
If the user has a session, the handler sends back an HTML page displaying their email from req.session.user.email and their unique session ID from req.sessionID. The app.listen() call starts the server on port 3000.
- Save server.js.
Can I run the server yet?
Not yet. The server imports routes/auth.js and middleware/auth.js, which you will create in the next two steps. If you run npm start now, Node.js will show a Cannot find module error. That is expected.
Seeing a syntax error in your editor?
- Check that every opening backtick (`) in the template literal has a matching closing backtick after the HTML.
- Make sure the ${req.session.user.email} and ${req.sessionID} placeholders are wrapped in ${} inside the backtick string.
- Confirm that res.send( has a matching closing ); after the template literal.
Still stuck? Help me find the syntax error in my server.js dashboard route
✔️ Awesome, I've got everything!
Great. Double check you have saved server.js.
ⓧ I'd like to double check the full code
const express = require('express');
const session = require('express-session');
const path = require('path');
const authRoutes = require('./routes/auth');
const { requireAuth } = require('./middleware/auth');
const app = express();
const PORT = 3000;
app.use(express.urlencoded({ extended: false }));
app.use(express.json());
app.use(express.static(path.join(__dirname, 'public')));
app.use(session({
secret: 'your-secret-key-here',
resave: false,
saveUninitialized: false,
cookie: {
httpOnly: true,
secure: false,
maxAge: 1000 * 60 * 30
}
}));
app.use('/auth', authRoutes);
app.get('/dashboard', requireAuth, (req, res) => {
res.send(`
<!DOCTYPE html>
<html>
<head>
<title>Dashboard</title>
<style>
body { font-family: sans-serif; max-width: 600px; margin: 50px auto; padding: 20px; }
.welcome { background: #e8f5e9; padding: 20px; border-radius: 8px; margin-bottom: 20px; }
.logout-btn { background: #e53935; color: white; padding: 10px 20px; border: none; border-radius: 4px; cursor: pointer; text-decoration: none; display: inline-block; }
code { background: #f5f5f5; padding: 2px 6px; border-radius: 3px; }
</style>
</head>
<body>
<div class="welcome">
<h1>Welcome, ${req.session.user.email}!</h1>
<p>You are logged in. This page is protected by session authentication.</p>
<p>Your session ID: <code>${req.sessionID}</code></p>
</div>
<a href="/auth/logout" class="logout-btn">Log Out</a>
<a href="/" style="margin-left: 10px;">Home</a>
</body>
</html>
`);
});
app.listen(PORT, () => {
console.log(`Server running at http://localhost:${PORT}`);
});
Your server is written and ready to go. Next up, you will build the signup and login routes that handle user registration and authentication.
Build Signup and Login Routes
Your server is set up and your HTML forms are ready to submit data. The missing piece is the backend logic that actually processes those form submissions.
In this step, you will build three routes that handle the complete authentication lifecycle: signing up, logging in, and logging out. Each route handles a different stage of managing a user's identity.
In this step, get ready to:
- Create a signup route that hashes passwords and stores new users.
- Create a login route that verifies credentials and creates a session.
- Create a logout route that destroys the session and clears the cookie.
Create the signup route
When someone fills out the signup form and clicks Create Account, the browser sends a POST request to /auth/signup. Your route needs to validate the input, hash the password so it is never stored in plain text, save the user, and start a session.
- In Cursor, open the routes/auth.js file (currently empty).
- Add the imports, router setup, and signup route by pasting this code:
const express = require('express');
const bcrypt = require('bcryptjs');
const db = require('../database');
const router = express.Router();
// POST /auth/signup - Register a new user
router.post('/signup', async (req, res) => {
const { email, password } = req.body;
// Validate that both fields are provided
if (!email || !password) {
return res.status(400).send('Email and password are required.');
}
// Enforce minimum password length
if (password.length < 6) {
return res.status(400).send('Password must be at least 6 characters.');
}
// Check if a user with this email already exists
const existingUser = db.prepare('SELECT id FROM users WHERE email = ?').get(email);
if (existingUser) {
return res.status(409).send('An account with this email already exists.');
}
// Hash the password before storing it
const salt = await bcrypt.genSalt(10);
const hashedPassword = await bcrypt.hash(password, salt);
// Insert the new user into the database
const stmt = db.prepare('INSERT INTO users (email, password) VALUES (?, ?)');
const result = stmt.run(email, hashedPassword);
// Create a session for the new user and redirect to dashboard
req.session.user = { id: result.lastInsertRowid, email };
res.redirect('/dashboard');
});
What does this code do?
The imports pull in Express for routing, bcryptjs for password hashing, and your database module for querying users.
- bcrypt.genSalt(10) generates a random salt with 10 rounds of complexity. The salt makes each hashed password unique, even if two users pick the same password.
- bcrypt.hash(password, salt) combines the plain-text password with the salt to produce an irreversible hash. This is what gets stored in the database instead of the real password.
- req.session.user = { id, email } attaches the user's info to the session. This is how the server remembers who is logged in on future requests.
What is password hashing?
Hashing is a one-way transformation. You can turn a password into a hash, but you cannot turn the hash back into the password. If your database is ever compromised, attackers only see scrambled hashes instead of real passwords.
The bcrypt algorithm is specifically designed for passwords. It is intentionally slow, which makes brute-force attacks impractical.
- You will not see a change yet. The server cannot start until the middleware file is complete in the next step.
Create the login route
The login route finds an existing user by email and verifies their submitted password against the stored hash. If the credentials match, a new session is created.
- In routes/auth.js, add the login route below the closing }); of the signup handler:
// POST /auth/login - Authenticate an existing user
router.post('/login', async (req, res) => {
const { email, password } = req.body;
// Validate that both fields are provided
if (!email || !password) {
return res.status(400).send('Email and password are required.');
}
// Look up the user by email
const user = db.prepare('SELECT * FROM users WHERE email = ?').get(email);
if (!user) {
return res.status(401).send('Invalid email or password.');
}
// Compare submitted password against stored hash
const isMatch = await bcrypt.compare(password, user.password);
if (!isMatch) {
return res.status(401).send('Invalid email or password.');
}
// Regenerate session ID to prevent session fixation attacks
req.session.regenerate((err) => {
if (err) {
return res.status(500).send('Session error.');
}
req.session.user = { id: user.id, email: user.email };
res.redirect('/dashboard');
});
});
What does this code do?
- bcrypt.compare(password, user.password) hashes the submitted password with the same salt that was used during signup, then checks if the result matches the stored hash. It returns true or false without ever needing to decrypt anything.
- req.session.regenerate() creates a brand new session ID after successful login. This prevents session fixation attacks, where an attacker tricks you into using a session ID they already know.
Why the same error message for wrong email and wrong password?
Both cases return the same generic message: Invalid email or password. This is a security best practice. If the error said "user not found" versus "wrong password," an attacker could figure out which emails have accounts.
- You will not see a change yet. The next chunk adds the final route.
Create the logout route
Logging out means destroying the session on the server and clearing the session cookie in the browser. Without both steps, the user could appear to still be logged in.
- Below the login route in routes/auth.js, add the logout route and export the router:
// GET /auth/logout - End the user's session
router.get('/logout', (req, res) => {
// Destroy session data on the server
req.session.destroy((err) => {
if (err) {
return res.status(500).send('Could not log out.');
}
// Remove the session cookie from the browser
res.clearCookie('connect.sid');
res.redirect('/');
});
});
module.exports = router;
What does this code do?
- req.session.destroy() deletes all session data stored on the server for this user.
- res.clearCookie('connect.sid') tells the browser to delete the session cookie. connect.sid is the default cookie name used by express-session.
- module.exports = router makes the router available to server.js, which already mounts it at /auth.
- Save routes/auth.js.
- Verify the file has no syntax errors by running this command in your terminal:
node -e "require('./routes/auth')"
- If the command finishes with no output, your file is valid. If you see a SyntaxError, check for missing brackets or commas.
Seeing a SyntaxError?
- Check that every opening ( has a matching ), and every opening { has a matching }. The most common mistake is a missing }); to close a route handler.
- Make sure the require('../database') path uses two dots. A single dot points to the wrong folder.
- Help me find the syntax error in my auth routes.
✔️ Awesome, I've got everything!
Great. Double check you have saved routes/auth.js before moving on.
ⓧ I'd like to double check the full code
const express = require('express');
const bcrypt = require('bcryptjs');
const db = require('../database');
const router = express.Router();
router.post('/signup', async (req, res) => {
const { email, password } = req.body;
if (!email || !password) {
return res.status(400).send('Email and password are required.');
}
if (password.length < 6) {
return res.status(400).send('Password must be at least 6 characters.');
}
const existingUser = db.prepare('SELECT id FROM users WHERE email = ?').get(email);
if (existingUser) {
return res.status(409).send('An account with this email already exists.');
}
const salt = await bcrypt.genSalt(10);
const hashedPassword = await bcrypt.hash(password, salt);
const stmt = db.prepare('INSERT INTO users (email, password) VALUES (?, ?)');
const result = stmt.run(email, hashedPassword);
req.session.user = { id: result.lastInsertRowid, email };
res.redirect('/dashboard');
});
router.post('/login', async (req, res) => {
const { email, password } = req.body;
if (!email || !password) {
return res.status(400).send('Email and password are required.');
}
const user = db.prepare('SELECT * FROM users WHERE email = ?').get(email);
if (!user) {
return res.status(401).send('Invalid email or password.');
}
const isMatch = await bcrypt.compare(password, user.password);
if (!isMatch) {
return res.status(401).send('Invalid email or password.');
}
req.session.regenerate((err) => {
if (err) {
return res.status(500).send('Session error.');
}
req.session.user = { id: user.id, email: user.email };
res.redirect('/dashboard');
});
});
router.get('/logout', (req, res) => {
req.session.destroy((err) => {
if (err) {
return res.status(500).send('Could not log out.');
}
res.clearCookie('connect.sid');
res.redirect('/');
});
});
module.exports = router;
Your auth routes are locked in. In the next step, you will build the middleware that protects routes from unauthenticated users and test the complete signup-login-logout flow.
Add Auth Middleware and Test the Full Flow
Your signup, login, and logout routes are complete. Right now though, anyone could type /dashboard into the browser and see the page, whether they're logged in or not.
In this step, you'll write a piece of middleware that checks for a valid session before allowing access to protected pages. Then you'll start the server and test the entire authentication flow end to end.
In this step, get ready to:
- Write the requireAuth middleware function.
- Start the server and test signup, login, logout, and route protection.
Write the requireAuth middleware function
Middleware in Express is a function that runs before your route handler. It can inspect the request, modify it, or stop it entirely by sending a response early.
Your auth middleware will act like a bouncer. If the visitor has a valid session, they get through. If not, they get redirected to the login page.
- In Cursor, open middleware/auth.js from the Explorer sidebar.
- Add the following code to create the middleware function:
function requireAuth(req, res, next) {
// Check if the session exists and has a user attached
if (req.session && req.session.user) {
return next();
}
// No valid session found - redirect to login page
res.redirect('/login.html');
}
module.exports = { requireAuth };
What does this code do?
The requireAuth function takes three parameters: req (the request), res (the response), and next (a function that passes control to the next handler in the chain).
If req.session.user exists, the user is authenticated and next() lets them proceed. If not, the response redirects them to the login page before the route handler ever runs.
- Save middleware/auth.js.
You won't see a change yet. The next substep starts the server so you can test the middleware in action.
✔️ Awesome, I've got everything!
Great. Double check you've saved your file before moving on.
ⓧ I'd like to double check the full code
function requireAuth(req, res, next) {
if (req.session && req.session.user) {
return next();
}
res.redirect('/login.html');
}
module.exports = { requireAuth };
Understand how middleware protects your route
Your server.js file already imports and uses this middleware. The wiring was set up when you built the server in the previous step.
- In server.js, find this import near the top of the file:
const { requireAuth } = require('./middleware/auth');
- Now find the /dashboard route definition further down:
app.get('/dashboard', requireAuth, (req, res) => {
How does middleware get wired into a route?
In Express, you pass middleware as an argument between the route path and the route handler. When a request hits /dashboard, Express runs requireAuth first.
If requireAuth calls next(), the route handler runs and sends the dashboard HTML. If it redirects instead, the route handler is skipped entirely.
Start the server and test the full flow
Every piece of the authentication system is now in place. Time to start the server and walk through the entire lifecycle: route protection, signup, logout, and login.
- Start your server by running this command in the terminal:
npm start
- You should see Server running at http://localhost:3000 in your terminal.
Seeing an error when starting the server?
- If you see a module not found error for ./middleware/auth, make sure you saved middleware/auth.js with the requireAuth function and the module.exports line.
- If port 3000 is already in use, close any other running servers first.
Why won't my server start?
Now test the full authentication lifecycle in your browser.
- Open your browser and go to http://localhost:3000/dashboard.
You should be immediately redirected to the login page. This proves the middleware is blocking unauthenticated access.
- Go to http://localhost:3000 to see the home page.
- Click Sign Up.
- Enter test@example.com in the email field.
- Enter password123 in the password field.
- Click Create Account.
You should land on the dashboard page showing a welcome message with your email and a session ID.
- Click the red Log Out button.
You should be redirected back to the home page. Your session has been destroyed.
- Click Dashboard (Protected) on the home page.
You should be redirected to the login page again, because your session no longer exists.
- Enter test@example.com and password123 again.
- Click Log In.
You should land on the dashboard again with a new session ID. The session ID is different from before because req.session.regenerate() creates a fresh session on every login.
Why is the session ID different?
The login route calls req.session.regenerate() to prevent session fixation attacks. This creates a brand new session ID after successful authentication, so an attacker who captured an old session ID can't use it to hijack the new session.
Dashboard not redirecting when logged out?
- Check that middleware/auth.js exports the function with module.exports = { requireAuth }. A missing export means server.js can't load the function.
- Make sure the condition checks both req.session and req.session.user. Checking only one can cause unexpected behavior.
- Restart your server after any file changes. Press Ctrl+C in the terminal, then run npm start again.
Why isn't my middleware redirecting unauthenticated users?
Secret mission
Add "Remember Me" Functionality
Your session expires after 30 minutes, which means users have to log in again every half hour. Can you add a "Remember Me" checkbox that extends their session to 7 days?
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely locally, so there are no ongoing costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
Resources you used:
- The running Express server process at http://localhost:3000.
- The auth-demo project folder on your Desktop (contains all code, node_modules, and the users.db SQLite database).
Keep everything running
No action needed. Choose this if you're still actively building or want to experiment with the authentication system further.
- Your server stays running at http://localhost:3000.
- Your users.db database retains all registered users.
- You can restart the server anytime with npm start from the project folder.
Pause - I'll come back to this later
Stop the running server to free up the terminal, but keep all project files for later.
- Switch to the terminal where your server is running.
- Press Ctrl+C to stop the server.
- Your project folder and database remain on your Desktop. Run npm start from the auth-demo folder when you're ready to resume.
Delete - I don't want to use this again
Remove all project files and the database completely.
- Switch to the terminal where your server is running.
- Press Ctrl+C to stop the server.
- Navigate to your Desktop and delete the project folder by running these commands:
macOS
cd ~/Desktop
rm -rf auth-demo
Windows
cd ~\Desktop
rmdir /s /q auth-demo
- Confirm the folder is gone by running:
ls
You should no longer see auth-demo in the output.
Nice Work!
Nice Work!
You just built a complete authentication system from scratch using Node.js and Express. Users can create accounts, log in securely, access protected pages, and log out cleanly.
Here's what you accomplished:
- Built signup and login routes that hash passwords with bcrypt and store them securely in a SQLite database.
- Implemented server-side session management with signed cookies that persist across page refreshes until logout or expiry.
- Created auth middleware that protects routes from unauthenticated access and redirects visitors to the login page.
- Added a "Remember Me" checkbox that extends the session cookie duration from 30 minutes to 7 days for returning users.
Ready to quiz yourself?