Securing API Endpoints
Your work on the “Innovate Inc.” portal is progressing well. Users can register and log in, receiving a JWT upon success. However, there’s a major security gap: anyone, authenticated or not, can currently access any part of the API. Your next task is to implement a checkpoint — an authentication middleware—that inspects incoming requests, validates the JWT, and blocks any unauthorized access. You will also explore how to grant different levels of access, a concept known as authorization.
Protecting Routes with Authentication Middleware
In Express, middleware are functions that have access to the request object (req
), the response object (res
), and the next
function in the application’s request-response cycle. These functions can execute code, make changes to the request and response objects, and end the cycle or pass control to the next middleware.
Authentication is a perfect use case for middleware. We can write a single function that checks for a valid JWT and apply it to any route that should be protected.
Building the Middleware
Let’s create a file, auth.js
, to house our middleware. This function will be responsible for extracting the token from the Authorization
header, verifying it, and attaching the user’s data to the request object.
import jwt from 'jsonwebtoken';
const secret = process.env.JWT_SECRET;
const expiration = '2h';
export function authMiddleware({ req }) {
// Allows token to be sent via req.body, req.query, or headers
let token = req.body.token || req.query.token || req.headers.authorization;
// We split the token string into an array and return actual token
if (req.headers.authorization) {
token = token.split(' ').pop().trim();
}
if (!token) {
return req;
}
// If token can be verified, add the decoded user's data to the request so it can be accessed in the resolver
try {
const { data } = jwt.verify(token, secret, { maxAge: expiration });
req.user = data;
} catch {
console.log('Invalid token');
}
// Return the request object so it can be passed to the resolver as `context`
return req;
}
Key Steps in the Middleware:
- Extract the Token: The token can be sent in multiple ways. This logic checks for it in the request body, query parameters, and, most commonly, the
Authorization
header. - Handle the “Bearer” Scheme: The standard practice is to send the token in the
Authorization
header prefixed with the word “Bearer ” (e.g.,Authorization: Bearer <token>
). The code splits this string to get just the token itself. - Verify the Token:
jwt.verify()
is called inside atry...catch
block. If the token is expired, malformed, or has an invalid signature,jsonwebtoken
will throw an error, which is caught. - Attach User to Request: If the token is valid, its payload (which contains the user data) is decoded and attached to the
req
object asreq.user
. This makes the authenticated user’s information available to any subsequent route handlers. - Pass Control: If the user is successfully authenticated, we return the modified
req
object. If not,req.user
will not be set.
Applying the Middleware
Now, you can apply this middleware to any route you want to protect. For example, if you have a route to fetch a user’s profile, you would only want logged-in users to access it.
// In your user routes file
import { authMiddleware } from '../utils/auth';
// This route is now protected
router.get('/profile', authMiddleware, (req, res) => {
if (!req.user) {
return res.status(401).json({ message: 'You must be logged in to see this!' });
}
// Find user data and send it back
User.findById(req.user._id)
.select('-password')
.then(user => res.json(user))
.catch(err => res.status(500).json(err));
});
In this example, the authMiddleware
runs first. If the token is invalid, req.user
will be undefined, and we send back a 401 Unauthorized
error. If it’s valid, the user’s data is available, and the route proceeds to fetch the profile.
Implementing Basic Authorization
While authentication confirms who a user is, authorization determines what they are allowed to do. A common scenario is having different user roles, like "user"
and "admin"
. An admin might be able to delete any user’s post, while a regular user can only delete their own.
You can implement simple role-based authorization by including the user’s role in the JWT payload.
// When signing the token
const payload = {
_id: user._id,
username: user.username,
role: user.role, // e.g., 'admin'
};
const token = jwt.sign({ data: payload }, secret, { expiresIn: '2h' });
Then, you can create another middleware to check for a specific role.
// A middleware to check for admin role
export function adminOnly(req, res, next) {
if (req.user && req.user.role === 'admin') {
next(); // User is an admin, proceed
} else {
res.status(403).json({ message: 'Access denied. Admins only.' });
}
}
// Applying both middlewares to a route
router.delete('/posts/:id', authMiddleware, adminOnly, (req, res) => {
// Logic to delete a post...
});
Here, a request to delete a post must first pass the authMiddleware
(to prove identity) and then the adminOnly
middleware (to prove permission).
Introduction to Passport.js
While building your own authentication middleware is a great way to learn, in a real-world application, you would likely use a library like Passport.js. Passport is a powerful and flexible authentication middleware for Node.js.
Why use Passport?
- Modularity: Passport uses “strategies” to handle different authentication methods (local username/password, JWT, Google, Facebook, etc.). There are over 500 strategies available.
- Unobtrusive: It doesn’t mount its own routes or impose any structure on your application. You apply it where you need it.
- Boilerplate Reduction: It handles much of the session management, token parsing, and error handling logic that we just wrote manually.
For example, using passport-jwt
, our authentication middleware would be simplified to configuring the strategy and then applying it:
// Simplified example with passport-jwt
passport.use(new JwtStrategy(opts, (jwt_payload, done) => {
User.findById(jwt_payload.data._id, (err, user) => {
if (err) return done(err, false);
if (user) return done(null, user);
return done(null, false);
});
}));
// Applying the passport middleware
router.get(
'/profile',
passport.authenticate('jwt', { session: false }),
(req, res) => {
res.json(req.user);
}
);
While we will continue to build our auth logic manually for this module to solidify the core concepts, it’s important to know that powerful tools like Passport exist to streamline this process in production applications.
Activity 1: Protect an Endpoint
- Take the API you built in Lab 1.
- Create a new
GET /api/users/me
route that is supposed to return the currently logged-in user’s profile data (without the password). - Implement an authentication middleware function similar to the one described in this lesson.
- Apply your middleware to the new
/api/users/me
route. - Using an API client like Postman or Insomnia, test your new endpoint:
- First, try to access it without an
Authorization
header. You should receive an error. - Next, log in to get a valid token.
- Finally, access the endpoint again, this time providing the JWT in the
Authorization: Bearer <token>
header. You should receive your user profile data.
- First, try to access it without an
Knowledge Check
What is the primary role of an authentication middleware in an Express application?
- Select an answer to view feedback.
According to HTTP standards, how should a client send a JWT to a server for an authenticated request?
- Select an answer to view feedback.
In an Express middleware function, what is the purpose of the next()
function?
- Select an answer to view feedback.
Summary
In this lesson, you built a critical piece of any secure API: authentication middleware. You learned how these functions intercept requests to act as a gatekeeper, verifying a user’s JWT before allowing access to protected resources. You also saw how to attach user data to the request object, making it available to your route handlers. We briefly touched on authorization by checking for user roles and introduced Passport.js as a powerful, production-ready tool that abstracts much of this logic into configurable “strategies.”