~ 5 min read

Poor Express Authentication Patterns in Node.js and How to Avoid Them

share this story on
Tired of seeing poor authentication patterns in Node.js applications and Express code examples? Here's a guide on how to avoid them and what to do instead

It’s ok to roll your own authentication if you want to build that into your Express applications, but following bad security advice is not going to end well. More often than not, I’m double-clicking into security related guides and tutorials on blogs because I’m curious at what and how other developers are teaching security topics and what they consider as best practices.

Unfortunately, I’ve come across a lot of guides that are teaching poor authentication patterns. I’ll skip the name calling but I’ll point out this was a dev.to post that was highly upvoted (279 bookmarked) and it was teaching a poor authentication pattern.

I’ll share two specific snippets that are really obvious: one related to cookie setup in an Express application and the other related to a login route handler in classic Node.js applications.

Here’s an example of a Node.js application using Express.js to implement session authentication, straight from the original blog post:

const express = require('express');
const session = require('express-session');

const app = express();

// Middleware setup
app.use(session({
  secret: 'your_secret_key',
  resave: false,
  saveUninitialized: false,
  cookie: {
    httpOnly: true, // Set the cookie as HTTP-only, Optional
    maxAge: 60*30 // In secs, Optional
  }
}));

Let’s call out the problems:

1. Hardcoded Secrets

Problem: We often see examples with sensitive information like secret keys stored directly in the code (e.g., secret: your_secret_key). I realize this is a tutorial, but it’s a bad practice to hardcode secrets in your codebase and then push it to a public repository. At least, add a reference to .env files or use a process.env.SECRET_KEY code reference to access the secret key that conveys the point about security best practice here.

Solution: Move the secret key definition to a dedicated environment variable. This allows separation of concerns and keeps sensitive information out of the codebase. You can access environment variables in Node.js using process.env.VARIABLE_NAME.

Recommended read on this topic is environment variables and configuration anti patterns in Node.js applications

Not only poor cookie configuration but also a lack of security headers in the response, and generally a lack of good security practices in cookie setup.

Consider the following cookie configuration that are completely lacking:

  • Secure Flag: The secure flag should be set to true to ensure the cookie is only transmitted over HTTPS connections. This mitigates eavesdropping on unsecured connections.
  • SameSite Attribute: The SameSite attribute helps prevent Cross-Site Request Forgery (CSRF) attacks. Setting it to strict provides the strongest protection but might require additional configuration. Consider lax as a compromise for broader compatibility.
  • Domain: The domain attribute specifies the domain for which the cookie is valid. By default, it’s set to the server’s domain. If your application operates on a single subdomain, set the domain to that subdomain (e.g., domain: ‘yoursubdomain.example.com’). For applications spanning multiple subdomains under the same main domain, consider using a wildcard domain (e.g., domain: ‘.example.com’). However, use caution with wildcards as they can introduce unintended behavior with third-party cookies.
  • Path: The path attribute specifies the path on the server where the cookie is valid. Set the path to the specific path where the cookie is needed (e.g., path: ‘/api’). This restricts the cookie’s scope and reduces the risk of exposure on unintended paths.

3. No mention of CSRF

The blog post doesn’t mention CSRF protection at all. CSRF attacks are a common threat to web applications, and it’s important to protect against them. If you are writing about a session-based authentication with cookies, you should at least mention Cross-Site Request Forgery (CSRF) as a potential threat and how propose some ways to mitigate it.

4. No mention of Session Expiration

The example sets a maxAge for the cookie, but it’s recommended to also implement server-side session expiration. This ensures sessions are invalidated even if the user’s browser doesn’t clear the cookie properly.

Avoid Poor Login Route Handlers in Node.js Applications

The following is a bad example of a login route handler in a Node.js application, however this is a copy&paste from the original blog post:

app.post('/login', (req, res) => {
  const { username, password } = req.body;
  const user = users.find(u => u.username === username && u.password === password);

  if (user) {
    req.session.userId = user.id; // Store user ID in session
    res.send('Login successful');
  } else {
    res.status(401).send('Invalid credentials');
  }
});

Let’s call out the problems here too:

1. Plain Text Password Storage

Problem: The code compares the user’s password directly with the stored password (u.password). This assumes passwords are stored in plain text, which is highly insecure.

Solution: Always hash passwords using a strong hashing algorithm like bcrypt before storing them in the database. When a user logs in, hash the provided password and compare the hash with the stored hash. This ensures even if the database is compromised, attackers cannot easily obtain user passwords.

2. Vulnerability to Timing Attacks

In the given code, if the password comparison is done character-by-character, an attacker could potentially exploit the time difference between a correct and incorrect character match. With enough attempts, they might be able to guess the password based on subtle variations in response times.

There are several ways to prevent timing attacks in password comparisons:

  • Constant Time Comparison: Use libraries like crypto.timingSafeEqual (built-in Node.js) to ensure the comparison takes a constant amount of time regardless of password length or correctness. These libraries perform comparisons in a way that avoids timing leaks.

  • Hash-Based Comparison: As mentioned earlier, always store passwords as hashes. During login, hash the provided password and compare it with the stored hash using a constant time comparison function. This eliminates the possibility of timing attacks based on character-by-character comparisons.

What else can be done?

Additional considerations include implementing rate limiting on login attempts to make brute-force attacks with timing analysis significantly slower and less effective, re-generating session ids on sensitive operations (changing a password or email), lockout mechanism after a certain number of failed login attempts to further deter attackers, and more.

More in-depth security practices concerning Node.js, authentication and secure coding in general are: