How to secure APIs built with Express.js
Want to know how to secure your Express.js APIs? Dive into our latest blog post, where we guide you through the best practices for Express.js security.
Want to know how to secure your Express.js APIs? Dive into our latest blog post, where we guide you through the best practices for Express.js security. Explore how these techniques can not only enhance the security of your web applications but also bring tangible benefits to your development journey.
In this guide, Escape's security research team has gathered the most crucial tips to protect your Express.js applications from potential data breaches. Our goal is to empower you to create more resilient and efficient Express.js projects. Let's get started!
What is Express.js?
Express.js is a fast, minimalist, and highly extensible web application framework for Node.js. Serving as a robust foundation for building web and mobile applications, Express.js simplifies the process of creating server-side applications with its intuitive and flexible features. It provides a set of essential functionalities for handling routes, managing middleware, and interfacing with databases, allowing developers to build scalable and efficient web applications.
Known for its unopinionated design, Express.js allows developers the freedom to structure their applications while providing powerful tools for routing and managing application states. Widely adopted in the Node.js ecosystem, Express.js has become a go-to framework for developers seeking a lightweight yet powerful solution to streamline the development of server-side applications.
Why is it important to secure Express.js applications?
Securing Express.js applications is critically important. Express.js makes use of third-party modules, and that increases the risk of security breaches. The asynchronous nature of JavaScript in Node.js, upon which Express.js is built, can create challenges related to callback hell and promise chaining, increasing the likelihood of overlooking security considerations. Without robust security measures, these applications become vulnerable to a range of exploits, including injection attacks, cross-site scripting (XSS), and data breaches.
How to secure your Express.js APIs
The basics of Express.js security
When building APIs with Express.js, it is important to consider security to protect your application and its users from potential threats.
Understanding the basics of Express.js security can help you implement effective security measures.
One key aspect is handling user input safely to prevent security vulnerabilities like cross-site scripting (XSS) or SQL injection attacks. You can achieve this by using parameterized queries or escaping user input before using it in your code.
Another important consideration is implementing authentication and authorization mechanisms to control access to your application. Express.js provides middleware functions like Passport.js that make it easy to integrate these security features.
So, by understanding and implementing these basics of Express.js security, you can ensure your application is robust and secure.
To prevent XSS attacks, it is crucial to properly sanitize and validate user input. Below is a code example:
const sanitizeInput = (input) => {
// code to sanitize input
return sanitizedInput;
};
const validateInput = (input) => {
// code to validate input
return isValidInput;
};
Express.js provides various middleware modules, such as Helmet.js, that help automatically set security-related HTTP headers. Here is how to set this up:
const express = require('express');
const helmet = require('helmet');
const app = express();
app.use(helmet());
It is also a good practice to escape any user-generated content when rendering it in views to prevent the execution of any embedded scripts. For example, using the escape
function in a template engine can ensure that user data is displayed safely.
const templateEngine = require('template-engine');
const escape = require('escape-html');
app.get('/page', (req, res) => {
const userGeneratedContent = getUserGeneratedContent();
// Escaping user-generated content before rendering
const escapedContent = escape(userGeneratedContent);
res.render('page', { content: escapedContent });
});
By taking these precautions, you can significantly enhance the security of your Express.js application.
Securing Express.js against common security threats
Here are some common security threats in Express.js and how to handle them:
Cross-Site Scripting (XSS) Attack:
- Helmet is a separate middleware package that you can use with Express.js to enhance security that makes it easy to enable XSS protection.
To use Helmet, just add the following code snippet to your application:
To keep your Express.js application secure, it's important to implement Cross-Site Scripting (XSS) Protection. This helps prevent malicious code from being injected into your website, which can lead to theft of sensitive information or manipulation of user sessions. As mentioned before, to prevent XSS attacks, always validate and sanitize user input:
const userInput = req.body.input;
const sanitizedInput = sanitize(userInput);
app.use(helmet.xssFilter());
With this line of code, Express.js will set the X-XSS-Protection
header, which activates the browser's built-in XSS filter. This filter helps to prevent malicious scripts from executing in the user's browser. Helmet also offers other useful features, such as contentSecurityPolicy()
and hsts()
. The contentSecurityPolicy()
function allows you to define a policy for allowed content sources, further enhancing the security of your application. On the other hand, hsts()
enforces HTTP Strict Transport Security (HSTS), which protects against man-in-the-middle attacks. By implementing these XSS protections with just a few lines of code, you can greatly enhance the security of your Express.js application.
Cross-Site Request Forgery (CSRF) Attack:
- Implement CSRF tokens in your forms to prevent these attacks.
Example:
<form action="/update" method="POST">
<input type="hidden" name="_csrf" value="<%= csrfToken %>">
...
</form>
SQL Injection Attack:
- Use parameterized queries or prepared statements to prevent unauthorized SQL queries.
- Example:
const query = "SELECT * FROM users WHERE id = ?";
db.query(query, [userId], (err, result) => {
...
});
Best practices for Express.js security
To secure your Express.js applications, follow these best practices:
- Keep your dependencies up to date:
npm install -g npm-check-updates
ncu -u
npm install
- Implement strong authentication and authorization measures:
const bcrypt = require('bcrypt');
const saltRounds = 10;
const hashedPassword = await bcrypt.hash(password, saltRounds);
Authentication and authorization are important for keeping an Express.js application secure. You can verify the identity of users and make sure they're who they say they are using different strategies. For example, you can use username and password authentication or let them log in with social media accounts. As mentioned before, you can also use packages like Passport.js that help with token-based authentication.
After authentication is successful, we need to determine what users are allowed to do. This is where authorization comes in. You can use middleware like express-jwt
and connect-roles
to handle authorization efficiently. These tools let you decide what resources and features each user can access, based on their role or permissions.
By implementing authentication and authorization correctly, you can make sure that only users who are authenticated and authorized can use our application. We prepared a special deep dive for you below for an even better implementation of the best authentication and authorization practices.
- Be cautious with user input and implement proper validation and sanitization:
const { body, validationResult } = require('express-validator');
app.post('/login', [
body('username').isLength({ min: 5 }),
body('password').isLength({ min: 8 }),
], (req, res) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
// Continue with secure logic
});
One way to achieve this is by using libraries like express-validator, which provides an easy-to-use API for validating and sanitizing input. Let's take a look at a simple example:
const { body, validationResult } = require('express-validator');
app.post('/login', [
body('username').trim().not().isEmpty().withMessage('Username is required'),
body('password').trim().not().isEmpty().withMessage('Password is required')
], (req, res) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
// Proceed with authentication logic
});
In this code snippet, we are using express-validator to validate and sanitize the input for a login request. We make sure that the username
and password
fields are not empty and display appropriate error messages if they are. By doing this, we can prevent an attacker from bypassing authentication by submitting empty values. Input validation and sanitization are essential in securing our Express.js applications and should be applied to all user input throughout the system.
By validating and sanitizing user input, we can prevent common security vulnerabilities such as cross-site scripting (XSS) attacks and SQL injection.
- Implement security headers:
app.use((req, res, next) => {
res.setHeader('Content-Security-Policy', "default-src 'self'");
res.setHeader('Strict-Transport-Security', 'max-age=31536000');
next();
});
Deep dive - Implementing secure authentication and authorization for Express.js
How to implement role-based access control (RBAC) in Express.js
Role-Based Access Control (RBAC) is a method of restricting access to resources based on roles assigned to users. In Express.js, you can implement RBAC using middleware. Here's a simple example using middleware functions:
// Assuming you have an array of roles defined in your application
const roles = ['user', 'admin', 'moderator'];
// Middleware to check if the user has the required role
function checkRole(role) {
return (req, res, next) => {
// Assuming you have a way to identify the user's role (e.g., from a user object)
const userRole = req.user.role; // Adjust this based on your user authentication setup
if (roles.indexOf(userRole) >= roles.indexOf(role)) {
// User has the required role or a higher-level role
next();
} else {
// User does not have the required role
res.status(403).send('Permission Denied');
}
};
}
// Example routes with RBAC middleware
app.get('/admin', checkRole('admin'), (req, res) => {
res.send('Admin Dashboard');
});
app.get('/moderator', checkRole('moderator'), (req, res) => {
res.send('Moderator Dashboard');
});
app.get('/user', checkRole('user'), (req, res) => {
res.send('User Dashboard');
});
In this example:
By using role-based access control, we can ensure the security of our system and comply with regulations by restricting certain actions to specific roles. This way, we can prevent unauthorized access and keep our system intact.
roles
is an array containing the different roles in your application.- The
checkRole
middleware is created to verify if the user has the required role. - Routes are protected using the
checkRole
middleware, ensuring that only users with the appropriate roles can access specific routes.
Keep in mind that this is a basic example, and in a real-world scenario, you would integrate this with your user authentication mechanism. The req.user.role
assumes you have a user object with a role
property.
Also, consider using an established library for more complex RBAC implementations, such as "casl" or "accesscontrol." These libraries can provide a more robust and feature-rich solution for handling roles and permissions.
Multi-factor authentication (MFA)
Implementing MFA in an Express.js application involves adding an extra layer of security. Below is a simplified example using the speakeasy
library for Time-based One-Time Passwords (TOTP) as the second factor. Please note that this is a basic example, and in a production environment, you would need to adapt it to your specific requirements and integrate it with your user authentication system.
Firstly, install the necessary library: npm install speakeasy
Now, you can implement MFA in Express.js:
const express = require('express');
const speakeasy = require('speakeasy');
const QRCode = require('qrcode');
const bodyParser = require('body-parser');
const app = express();
const port = 3000;
app.use(bodyParser.urlencoded({ extended: false }));
app.use(bodyParser.json());
// Generate a secret and a QR code for the user to scan
app.get('/setupMFA', (req, res) => {
const secret = speakeasy.generateSecret({ length: 20 });
QRCode.toDataURL(secret.otpauth_url, (err, data_url) => {
res.json({ secret: secret.base32, qrCode: data_url });
});
});
// Verify the TOTP code provided by the user
app.post('/verifyMFA', (req, res) => {
const { secret, token } = req.body;
const verified = speakeasy.totp.verify({
secret: secret,
encoding: 'base32',
token: token,
window: 2, // Allow tokens within this many steps from the current time.
});
if (verified) {
res.send('MFA successfully verified!');
} else {
res.status(401).send('Invalid MFA token');
}
});
app.listen(port, () => {
console.log(`Server is running on port ${port}`);
});
In this example:
/setupMFA
route generates a secret for the user and a corresponding QR code. The user can scan this QR code using a TOTP authenticator app (like Google Authenticator or Authy)./verifyMFA
route verifies the TOTP token entered by the user against the generated secret.
This is a basic example, and you might need to adapt it based on your specific authentication strategy and requirements.
Password encryption and hashing
To implement password encryption and hashing in Express.js, you'll typically use a hashing library like bcrypt
. Bcrypt automatically handles salting - a technique used to add an additional layer of security to password hashing. The salt value is then stored alongside the hashed password. This enhances security by ensuring that even when two users share the same password, their hashed values will be distinct, thanks to the inclusion of unique salts.
Basic password hashing:
If your primary concern is to hash passwords securely without explicitly managing salt, you can use the basic example that doesn't involve manually generating a salt.
const express = require('express');
const bcrypt = require('bcrypt');
const bodyParser = require('body-parser');
const app = express();
const port = 3000;
app.use(bodyParser.json());
// Mock database to store user information
const users = [];
// Register a new user with hashed password
app.post('/register', async (req, res) => {
const { username, password } = req.body;
try {
// Hash the password with a cost factor of 10 (recommended value)
const hashedPassword = await bcrypt.hash(password, 10);
// Store the user information in your database
users.push({
username: username,
password: hashedPassword,
});
res.send('User registered successfully!');
} catch (error) {
console.error('Error during registration:', error);
res.status(500).send('Internal Server Error');
}
});
app.listen(port, () => {
console.log(`Server is running on port ${port}`);
});
In this example:
- The
/register
endpoint hashes the user's password using bcrypt before storing it in your database. - The
/login
endpoint compares the provided password with the hashed password stored in the database usingbcrypt.compare()
.
Password hashing with manual salt generation:
If you want to explicitly handle salt generation, you can use the example that involves generating a salt before hashing the password.
const express = require('express');
const bcrypt = require('bcrypt');
const bodyParser = require('body-parser');
const app = express();
const port = 3000;
app.use(bodyParser.json());
// Mock database to store user information
const users = [];
// Register a new user with hashed password and automatic salting
app.post('/register', async (req, res) => {
const { username, password } = req.body;
try {
// Generate a salt with a cost factor of 10 (recommended value)
const saltRounds = 10;
const salt = await bcrypt.genSalt(saltRounds);
// Hash the password with the generated salt
const hashedPassword = await bcrypt.hash(password, salt);
// Store the user information in your database
users.push({
username: username,
password: hashedPassword,
});
res.send('User registered successfully!');
} catch (error) {
console.error('Error during registration:', error);
res.status(500).send('Internal Server Error');
}
});
app.listen(port, () => {
console.log(`Server is running on port ${port}`);
});
Implementing these encryption and hashing techniques helps protect user passwords, reducing the risk of unauthorized access and potential data breaches.
Secure token-based authentication
Token-based authentication is a great way to keep user authentication secure. Instead of using passwords, each user is assigned a unique token after they successfully login. This token is then used to authenticate the user with each request. For express.js you can use libraries like JSON Web Tokens (JWT) or OAuth to easily generate, validate, and manage tokens. Take a look at this simple code snippet to see how token-based authentication with JWT works:
// Server-side code
const jwt = require('jsonwebtoken');
// Generate a token
const generateToken = (user) => {
const token = jwt.sign(user, process.env.JWT_SECRET, { expiresIn: '1d' });
return token;
};
// Validate a token
const validateToken = (req, res, next) => {
const token = req.headers.authorization.split(' ')[1];
jwt.verify(token, process.env.JWT_SECRET, (err, decoded) => {
if (err) {
return res.status(401).json({ message: 'Invalid token' });
}
req.user = decoded;
next();
});
};
// Client-side code
fetch('/api/login', {
method: 'POST',
body: JSON.stringify({ username, password }),
headers: {
'Content-Type': 'application/json',
},
})
.then((response) => response.json())
.then((data) => {
const token = data.token;
// Store the token in local storage or a cookie
localStorage.setItem('token', token);
});
// Include the token in subsequent requests
fetch('/api/data', {
headers: {
Authorization: `Bearer ${localStorage.getItem('token')}`,
},
});
By implementing token-based authentication with JWT, we can ensure that only authenticated users can access protected resources. This provides a secure and seamless user experience.
Implementing secure password reset processes
To implement secure password reset processes, we need to follow some best practices to protect user accounts. We can require users to verify their identity before resetting their password. Here's an example of how we can use email verification to do that:
// Send email verification link
function sendVerificationEmail(userEmail) {
const verificationLink = generateUniqueLink();
sendEmail(userEmail, "Reset Password", `Click this link to reset your password: ${verificationLink}`);
}
// Verify email link
function verifyEmailLink(userEmail, verificationLink) {
if (isValidLink(verificationLink)) {
resetPassword(userEmail);
} else {
showErrorMessage("Invalid verification link");
}
}
It's also important to make sure that the reset link is only valid for a limited time to prevent unauthorized access. Here's an example of how we can enforce a time limit on the reset link:
// Set expiration time for reset link
const resetLinkExpirationTimeInMinutes = 10;
// Check if reset link is still valid
function isResetLinkValid(resetLinkTimestamp) {
const currentTime = getCurrentTimestamp();
const timeDifference = currentTime - resetLinkTimestamp;
return timeDifference < resetLinkExpirationTimeInMinutes;
}
Furthermore, enforcing strong password requirements adds an extra layer of security to user accounts. We can set rules for passwords, such as a minimum length and a combination of different types of characters:
// Password requirements
const minimumPasswordLength = 8;
const passwordRegex = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]+$/;
// Check if password meets the requirements
function isPasswordStrong(password) {
return password.length >= minimumPasswordLength && passwordRegex.test(password);
}
By implementing these measures, we can help protect user accounts and ensure a secure password reset process.
User activity logging and monitoring
User activity logging and monitoring in an Express.js application involves tracking and recording various user actions or events for analysis, debugging, or security purposes.
Below is a basic example of how you can implement user activity logging using middleware in Express.js (to install morgan
follow this tutorial)
const express = require('express');
const morgan = require('morgan');
const fs = require('fs');
const path = require('path');
const app = express();
const port = 3000;
// Set up a write stream for logging to a file
const accessLogStream = fs.createWriteStream(path.join(__dirname, 'access.log'), { flags: 'a' });
// Use morgan middleware for HTTP request logging
app.use(morgan('combined', { stream: accessLogStream }));
// Custom middleware for user activity logging
app.use((req, res, next) => {
// Log user activity
console.log(`[${new Date().toISOString()}] User '${req.user.username}' accessed ${req.method} ${req.originalUrl}`);
// Continue with the request processing
next();
});
// Your routes go here...
app.listen(port, () => {
console.log(`Server is running on port ${port}`);
});
- We use the
morgan
middleware to log HTTP requests to a file (access.log
). This provides a comprehensive overview of all incoming requests, including details like IP address, status code, method, and more. - Additionally, we implement a custom middleware function that logs user activity. This middleware assumes that your application has some form of user authentication (
req.user.username
).
Make sure to adjust the logging format and content based on your specific requirements.
How to implement rate limiting in express.js
Implementing rate limiting in Express.js involves controlling the number of requests a client can make to your server within a specified timeframe. This helps prevent abuse or misuse of your API. Distributed denial-of-service (DDoS) attacks often involve overwhelming a server with a high volume of requests. Rate limiting can help mitigate the impact of such attacks by restricting the rate at which requests are processed, making it more challenging for attackers to disrupt services.
- Install the
express-rate-limit
package:
npm install express-rate-limit
2.Integrate it into your Express.js application:
const express = require('express');
const rateLimit = require('express-rate-limit');
const app = express();
// Define a rate limiter
const limiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100, // limit each IP to 100 requests per windowMs
message: 'Too many requests from this IP, please try again later',
});
// Apply the rate limiter to all requests
app.use(limiter);
// Your routes and middleware go here
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
console.log(`Server is running on port ${PORT}`);
});
In this example:
windowMs
: Specifies the time window for which requests are checked.max
: Sets the maximum number of requests a client can make within the defined window.message
: Custom message to be sent when the limit is exceeded.
3.Customize for Specific Routes:
You may want to apply rate limiting only to specific routes. To do this, place the limiter
middleware only for those routes:
app.use('/api/specific-route', limiter);
- Handle Rate Limit Exceeded:
By default, theexpress-rate-limit
middleware responds with a 429 status code when the rate limit is exceeded. You can handle this in your application using a custom error handler:
app.use((err, req, res, next) => {
if (err instanceof rateLimit.RateLimitExceeded) {
res.status(429).send('Too many requests from this IP, please try again later');
} else {
next();
}
});
Remember to adjust the windowMs
and max
values based on your specific use case and requirements. This is a basic example, and depending on your application, you may need more advanced rate-limiting strategies or additional considerations.
Conclusion
In conclusion, securing APIs built with Express.js is a multifaceted task requiring a comprehensive approach to mitigate potential risks and ensure the confidentiality, integrity, and availability of data. By implementing robust authentication and authorization mechanisms, token-based security, and rate limiting, developers can establish a solid foundation for API protection.
A faster and more reliable alternative to manual assessments is to use a dedicated API security testing tool with Express.js support like Escape. In just minutes, you can uncover exposed API endpoints, continuously monitor for evolving risks, and prioritize vulnerabilities that matter most to your business. Escape’s actionable remediation code snippets help you resolve issues quickly, all without the need for traffic monitoring, agents, or complex integrations.
Want to experience this seamless security solution? Book a demo today and let us show you how Escape can protect your APIs.