W04 Learning Activity: Session Authentication and Middleware
Overview
- Purpose: Understand how session-based authentication and role middleware can be used with an API.
- Task: Carefully read and understand the concepts of this learning activity. One person on your team will be responsible for adding this code to the project.
Your team project is a browser application with EJS pages as well as client-side JavaScript that calls APIs. For this reason, session-based authentication is a good fit for your application. This learning activity will show how you can implement session-based authentication and role-based middleware in your project.
Understand Now. Implement Later.
Read and understand the concepts in this learning activity, but you do not need to run each snippet of code yet. One person on your team will be responsible for implementing the code in your project.
Preparation Material
JWTs compared with sessions
JWTs are a strong choice for a purely API-based application with mobile clients, separate frontends, or other services. The client stores a token and must add it to each API request. They also require careful client storage, token-expiration handling, and code to attach the token to every request.
For this project, sessions are a practical and professional choice. After sign-in, the browser automatically sends its session cookie when it requests another EJS page or calls a same-origin API from client-side JavaScript. This avoids manually managing a bearer token in browser code and fits the server-rendered pages already in the application.
Both approaches follow the same pattern:
- A user sends credentials.
- The server verifies them.
- The server gives the client a token of some kind.
- Later requests present that token so the server can identify the user and determine access.
The key difference between the two approaches is that non-browser API clients normally send a bearer token manually, while browsers send session cookies automatically.
Use sessions
A session cookie will contain a session identifier, not the user's password. The server uses that identifier to find session data. For this project, you will only need to store a small amount of information, such as the user id and role, on the session that will be used later for access checks.
In order to use sessions you will need to install the necessary packages.
npm install express-session
npm install bcrypt
Installing packages
When you run a command like npm install bcrypt, it installs the package locally and adds it to the project's dependencies in package.json. Because it is listed as a dependency, the build environment will install the necessary packages when the app is deployed.
Each member of the team will also need to install the packages on their own computers before running the project locally. If a package is in the package.json dependency list, it can be installed by running npm install.
You will also need a session secret which should be stored in your .env file. The following steps show how this can be done:
- Generate a new secret key by running the following command in the terminal:
node -e "console.log(require('crypto').randomBytes(64).toString('hex'))" - Copy and paste the secret that was generated and add it to your
.envfile as shown:SESSION_SECRET=your_generated_secret_here - You will also need to generate a session secret to store at Render. Generate another secret, then open your Render dashboard, select your web service, go to the environment variable settings, and add
SESSION_SECRETwith your secret value.
Once you have generated and stored your session secret, you can use it in your application. The following is an example of how to configure the session middleware so that you can use sessions in your application.
import session from 'express-session';
const SESSION_SECRET = process.env.SESSION_SECRET;
// Set up session management
app.use(session({
secret: SESSION_SECRET,
resave: false,
saveUninitialized: false,
rolling: true,
cookie: { maxAge: 60 * 60 * 1000 } // Session expires after 1 hour of inactivity
}));
This code sets up session management with a secret key for signing the session ID cookie. The resave and saveUninitialized options control session saving behavior. Setting saveUninitialized to false means a session is not created and stored until something is actually saved to it, such as a successful login, so anonymous visitors do not get a stored session. The rolling option resets the cookie's expiration on every response, so the cookie.maxAge value of one hour is measured from the user's most recent activity instead of from the moment they logged in.
Session order matters
Express runs middleware in order. Register the session middleware before any route or middleware functions that will use the session. If you put it after the routes, req.session will not be available when those routes run.
Create user and role collections
For your project, you will need to create two Mongoose models: one for users and one for roles.
The following is an example of a roles collection with customer and admin documents.
const roleSchema = new mongoose.Schema({
name: {
type: String,
enum: ['customer', 'admin'],
unique: true,
required: true
}
});
export const Role = mongoose.model('Role', roleSchema);
The following is an example of a users collection with a reference to the role. The password is stored as a hash, not the original password.
const userSchema = new mongoose.Schema({
name: { type: String, required: true },
email: { type: String, required: true, unique: true, lowercase: true },
passwordHash: { type: String, required: true },
role: { type: mongoose.Schema.Types.ObjectId, ref: 'Role', required: true }
});
export const User = mongoose.model('User', userSchema);
The role reference makes the relationship explicit: many users can have one role, and changing the meaning of a role does not require rewriting every user document. Use populate('role') when you need the role name during sign-in. The unique email rule prevents duplicate accounts, but your registration controller should catch duplicate-key errors and return a friendly message rather than exposing a raw MongoDB error.
After creating the schemas, you should update the import.js script that sets up data in your database so that it will populate the initial roles of "customer" and "admin". Then you can re-initialize your database by running the following command.
npm run db:import
Create register and login forms
Once you have the authentication logic in place, you can create the necessary pages for users to register, sign in, and sign out.
First, create EJS views for the register.ejs and login.ejs forms.
Then, create the corresponding GET routes in your Express application to render these forms.
Add logic for registration, login, and logout
Once you have the forms and routes in place, you can implement the logic for registration, login, and logout. The following is a summary of what each function should do:
- Register: Receives all the user information, hashes the supplied password, creates a new user.
- Login: Uses bcrypt to compare the supplied password with the stored hash. If the password is correct, the user is logged in and the username and role are stored in the session.
- Logout: The user is logged out and the session is destroyed.
You should create the following model functions to help with the authentication logic:
createUserfindUserByEmailverifyPassword
Sample Model Functions (Click to expand)
const createUser = async (name, email, password) => {
const customer = await Role.findOne({ name: 'customer' });
if (!customer) throw new Error('Default role not found');
const passwordHash = await bcrypt.hash(password, 12);
const user = await User.create({
name,
email,
passwordHash,
role: customer._id
});
return user._id.toString();
};
const findUserByEmail = async (email) => {
return User.findOne({ email }).populate('role');
};
const verifyPassword = async (password, passwordHash) => {
return bcrypt.compare(password, passwordHash);
};
Then, you will need to create the following controller functions to handle the authentication logic:
registerloginlogout
Finally, you will need to register the routes for each of the controller functions:
router.post('/register', register)router.post('/login', login)router.post('/logout', logout)
Refer to previous course materials
For a detailed explanation of the authentication flow and the code examples, refer to the previous course materials on authentication and session management: CSE 340 Week 05: Authentication and Authorization
Create authentication middleware
Once the login process is working, it will store a user object in the session. From here, you can create middleware functions that you can use to protect your routes. These middleware functions verify that a user is logged in and has the correct role before the route is executed.
Both EJS page requests and API calls will need to be protected by the authentication middleware. In general, these middleware functions will perform the same checks, but they need to respond differently based on the type of request. If an unauthenticated user tries to access a protected page, the middleware should send back an HTML page telling them they need to log in, or redirect them to the login page. On the other hand, if an unauthenticated user tries to access a protected API endpoint, the middleware should return a JSON response with an error message and a 401 status code.
To accomplish these different purposes, you will need to create separate middleware functions for page requests and API calls, such as the following:
requireApiLogin()requirePageLogin()requireApiRole(roleName)requirePageRole(roleName)
// In the successful-login branch of the login controller
req.session.user = {
id: user._id.toString(),
name: user.name,
role: user.role.name // for example, 'customer' or 'admin'
};
res.redirect('/account');
The following middleware function could be used to protect routes. The two login functions do the same checks, but respond differently if a session is missing. Similarly, the two role functions make the same role checks, but respond differently based on the type of request.
export const loadSessionUser = (req, res, next) => {
req.user = req.session.user || null;
res.locals.user = req.user;
next();
};
const isLoggedIn = (req) => { return Boolean(req.user); };
const hasRole = (req, role) => { return req.user.role === role; };
export const requireApiLogin = (req, res, next) => {
if (!isLoggedIn(req)) {
return res.status(401).json({ message: 'Authentication required' });
}
next();
};
export const requirePageLogin = (req, res, next) => {
if (!isLoggedIn(req)) {
return res.redirect('/login');
}
next();
};
export const requireApiRole = (role) => (req, res, next) => {
if (!isLoggedIn(req)) {
return res.status(401).json({ message: 'Authentication required' });
}
if (!hasRole(req, role)) {
return res.status(403).json({ message: 'Forbidden' });
}
next();
};
export const requirePageRole = (role) => (req, res, next) => {
if (!isLoggedIn(req)) {
return res.redirect('/login');
}
if (!hasRole(req, role)) {
return res.status(403).render('error', { message: 'Forbidden' });
}
next();
};
loadSessionUser is not a protection check. It copies the session user to req.user for middleware and to res.locals.user for EJS templates. The other functions are guards: call next() only when the request is allowed. In the page-role function, replace error with the name of your project’s error view if it uses a different name.
Load the user before the routes
Import and register the loader after the session middleware and before any page or API routes. Middleware runs in the order it is registered, so reversing these lines would leave req.session or req.user unavailable.
import { loadSessionUser } from './src/middleware/auth.js';
app.use(session({ /* session configuration */ }));
app.use(loadSessionUser);
// Register page and API routes after these middleware functions.
Apply the matching guard to each route
Use the Page functions on routes that render EJS. Use the Api functions on routes whose controllers send JSON. A visitor to a protected page is redirected to login; a visitor calling a protected API receives JSON with status 401. A signed-in user with the wrong role receives status 403.
// In a route file (adjust the relative path for your project)
import {
requireApiLogin,
requireApiRole,
requirePageLogin,
requirePageRole
} from '../middleware/auth.js';
// Page routes: render EJS or redirect to the login page
router.get('/account', requirePageLogin, accountPage);
router.get('/admin/users', requirePageRole('admin'), adminUsersPage);
// API routes: send JSON errors that fetch() can inspect
apiRouter.get('/orders/me', requireApiLogin, getMyOrders);
apiRouter.delete('/projects/:id', requireApiRole('admin'), deleteProject);
Server-side checks are required
Hiding an admin link in an EJS page improves the experience, but it does not secure the route. Enforce authentication, roles, and ownership on the server for every protected page and API route.
Activity Instructions
Answer the following questions:
- Why is session-based authentication a good fit for this project?
Click to expand the example answer
The project uses browser-rendered EJS pages and same-origin API requests. After sign-in, the browser automatically sends the session cookie with those requests, so client-side JavaScript does not need to manually attach a bearer token.
- What is the purpose of the
SESSION_SECRETvalue?Click to expand the example answer
It is used to protect session data. It must be long and private, stored in an environment variable, and never committed to the repository.
- Why should public registration always assign the
customerrole on the server?Click to expand the example answer
A browser request cannot be trusted to choose its own access level. Assigning the customer role on the server prevents a visitor from submitting a form that grants them the admin role.
- How should an unauthenticated request be handled differently for a protected EJS page and a protected API route?
Click to expand the example answer
A protected EJS page should redirect the browser to the login page. A protected API route should return a JSON
401 Unauthorizedresponse so client-side JavaScript can handle it. - Why is a role check not enough to protect a customer's order details?
Click to expand the example answer
Role middleware can confirm that the requester is a customer, but the controller must also verify ownership by querying only orders that belong to that authenticated user's id.
Other Links:
- Return to: Week Overview | Course Home