How to Authenticate Users in Your Node.js App with Cookies, Sessions, and Passport.js

Authentication is a critical part of virtually every web application. At its most basic, authentication allows you to verify the identity of your users and prevent unauthorized access to protected resources. It serves as the foundation for personalization, access control, and other key features.

However, authentication is also a common weak point. According to the 2020 Verizon Data Breach Investigations Report, credentials are the most sought-after data type in breaches, and hacking via stolen or brute-forced credentials is the leading attack vector, accounting for over 80% of breaches.

As a developer, you have a responsibility to your users to implement authentication correctly and robustly. Failing to do so can expose sensitive user data, compromise accounts, and permanently damage your reputation. Fortunately, Node.js provides several good options for handling authentication that we‘ll explore in this article.

Overview of Authentication Methods

There are three main aspects to any authentication system:

  1. Verifying the user‘s identity, typically with a username and password
  2. Securely storing the authenticated state between requests
  3. Protecting authenticated pages or routes from unauthenticated access

In Node.js, there are three common ways to handle these aspects:

  1. Cookies – Store the authenticated state in an encrypted cookie on the client-side
  2. Sessions – Store the authenticated state on the server-side and send a session ID to the client
  3. Tokens (JWT) – Encode the authenticated state in a JSON Web Token and send it to the client

Each of these methods has its own tradeoffs in terms of simplicity, scalability, and security. In this article, we‘ll focus on using cookies and sessions as they are the most common and easiest to implement.

We‘ll also look at using the popular Passport.js authentication middleware. Passport provides a standardized way to handle over 500 different authentication strategies, ranging from simple username/password to integration with OAuth providers like Facebook and Twitter.

Authentication with Cookies

Cookies are small pieces of data (up to 4KB) that are stored on the user‘s browser. They are typically used to personalize the user‘s experience or to maintain their authenticated state across requests.

Setting and Getting Cookies

In Express, you can easily work with cookies using the built-in cookie-parser middleware. First, install it:

npm install cookie-parser

Then add it to your Express app:

const express = require(‘express‘);
const cookieParser = require(‘cookie-parser‘);

const app = express();
app.use(cookieParser());

Now you can set cookies on the response object using res.cookie():

app.get(‘/setcookie‘, (req, res) => {
  res.cookie(‘username‘, ‘john doe‘, { maxAge: 900000, httpOnly: true });
  res.send(‘Cookie has been set‘);
});

This sets a cookie named username with the value john doe that will expire after 15 minutes (900000 ms). The httpOnly flag ensures the cookie is not accessible from JavaScript, which helps prevent cross-site scripting (XSS) attacks.

To retrieve a cookie, use req.cookies:

app.get(‘/getcookie‘, (req, res) => {
  const username = req.cookies.username;
  res.send(`Welcome back ${username}`); 
});

Using Cookies for Authentication

Here‘s a basic example of using cookies for authentication:

app.post(‘/login‘, (req, res) => {
  const { username, password } = req.body;

  // Check username and password against DB
  if (username === ‘john‘ && password === ‘secret‘) {
    res.cookie(‘username‘, username, { maxAge: 900000, httpOnly: true });
    res.send(‘Logged in‘);
  } else {
    res.status(401).send(‘Invalid username or password‘);
  }
});

app.get(‘/protected‘, (req, res) => {
  if (req.cookies.username) {
    res.send(`Welcome ${req.cookies.username}, here is the secret content`);
  } else {
    res.status(401).send(‘Not logged in‘);
  }
});

In the /login route, we check the supplied username and password. If they are valid, we set a username cookie. Then in the /protected route, we check for the presence of this cookie to determine if the user is authenticated.

Signing Cookies

One issue with the above approach is the cookies can be easily modified by the client. A user could change their username cookie to gain unauthorized access.

To prevent this, you should sign your cookies with a secret key:

app.use(cookieParser(‘my-super-secret-key‘));

app.post(‘/login‘, (req, res) => {
  // ...
  res.cookie(‘username‘, username, { signed: true });
  // ...  
});

Then to read a signed cookie, use req.signedCookies instead of req.cookies:

app.get(‘/protected‘, (req, res) => {
  if (req.signedCookies.username) {
    // ...
  } else {
    // ...
  }
});

If a signed cookie has been altered, req.signedCookies will not contain it, so the user won‘t be authenticated.

Limitations of Cookies

While cookies are simple to use, they have some significant limitations:

  1. They can only store a limited amount of data (4KB).
  2. They are sent with every request, even if the data isn‘t needed, which can impact performance.
  3. They are not accessible from other domains, which can be an issue for single sign-on (SSO) scenarios.

For these reasons, cookies are not recommended for storing large amounts of data or sensitive information. They are best used for small, non-sensitive data like user preferences or session identifiers. For authentication, sessions or tokens are generally preferred.

Authentication with Sessions

Sessions are a way to store data on the server that is associated with a particular user. Unlike cookies, session data is not sent back and forth with each request, only a small session identifier is. This makes sessions more scalable and secure than cookies.

Setting Up Sessions

To use sessions in Express, you need the express-session middleware:

npm install express-session

Then add it to your app:

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

app.use(session({
  secret: ‘my-super-secret-key‘,
  resave: false,
  saveUninitialized: true
}));

The secret option is used to sign the session ID cookie, similar to signing regular cookies. The resave and saveUninitialized options control whether sessions are saved back to the store even if they weren‘t modified during the request.

Using Sessions for Authentication

Here‘s how you could use sessions for authentication:

app.post(‘/login‘, (req, res) => {
  const { username, password } = req.body;

  // Check username and password against DB
  if (username === ‘john‘ && password === ‘secret‘) {
    req.session.username = username;
    res.send(‘Logged in‘);
  } else {
    res.status(401).send(‘Invalid username or password‘);
  }
});

app.get(‘/protected‘, (req, res) => {
  if (req.session.username) {
    res.send(`Welcome ${req.session.username}, here is the secret content`);
  } else {
    res.status(401).send(‘Not logged in‘);
  }
});

This is very similar to the cookie example, but instead of setting a cookie, we store the username in the session. The session ID is automatically stored in a cookie by express-session, so we don‘t need to manually set any cookies.

Session Storage

By default, express-session uses an in-memory store for session data. This is fine for development, but for production you should use a more robust storage system like Redis or MongoDB.

For example, to use MongoDB for session storage:

npm install connect-mongo
const MongoStore = require(‘connect-mongo‘)(session);

app.use(session({
  store: new MongoStore({ url: ‘mongodb://localhost/sessions‘ }),
  secret: ‘my-super-secret-key‘,
  resave: false,
  saveUninitialized: true
}));

This will store sessions in a sessions collection in MongoDB. Using a proper session store ensures that sessions persist even if the server restarts and allows you to scale your application across multiple servers.

Authentication with Passport.js

Passport is authentication middleware for Node.js that makes it easy to add authentication to your application. It provides a consistent API for over 500 authentication strategies, including:

  • Local – username and password
  • OAuth – login with Facebook, Twitter, Google, etc.
  • OpenID – authentication using OpenID Connect
  • SAML – authentication using SAML

Passport uses what it calls strategies to authenticate requests. Strategies can range from verifying a username and password to delegated authentication using OAuth or OpenID.

Setting Up Passport

To use Passport, first install it and the strategy you want to use. For example, to use the local username/password strategy:

npm install passport passport-local

Then set up Passport in your app:

const passport = require(‘passport‘);
const LocalStrategy = require(‘passport-local‘).Strategy;

passport.use(new LocalStrategy(
  function(username, password, done) {
    // Check username and password against DB
    if (username === ‘john‘ && password === ‘secret‘) {
      return done(null, { id: 1, username: ‘john‘ });
    } else {
      return done(null, false);
    }
  }
));

passport.serializeUser(function(user, done) {
  done(null, user.id);
});

passport.deserializeUser(function(id, done) {
  // Find user by ID in DB
  done(null, { id: 1, username: ‘john‘ });
});

app.use(passport.initialize());
app.use(passport.session());

The LocalStrategy checks the supplied username and password against your database. In a real application, you‘d query your user table here instead of using hard-coded values.

The serializeUser and deserializeUser methods control how the user object is stored and retrieved from the session. Typically, you store only the user ID in the session and retrieve the full user object on each request.

Authenticating Requests

To authenticate a request with Passport, you pass it as middleware:

app.post(‘/login‘, 
  passport.authenticate(‘local‘, { failureRedirect: ‘/login‘ }),
  function(req, res) {
    res.redirect(‘/‘);
  });

This will use the LocalStrategy we defined earlier. If authentication fails, it will redirect back to the /login page. If it succeeds, the authenticated user will be stored in req.user.

You can then check req.user in your route handlers to see if the user is authenticated:

app.get(‘/protected‘, (req, res) => {
  if (req.user) {
    res.send(`Welcome ${req.user.username}, here is the secret content`);
  } else {
    res.redirect(‘/login‘);
  }
});

Other Authentication Strategies

Passport makes it easy to add other types of authentication to your application. For example, to allow users to log in with their Facebook account:

npm install passport-facebook
const FacebookStrategy = require(‘passport-facebook‘).Strategy;

passport.use(new FacebookStrategy({
    clientID: ‘YOUR-FACEBOOK-APP-ID‘,
    clientSecret: ‘YOUR-FACEBOOK-APP-SECRET‘,
    callbackURL: "http://localhost:3000/auth/facebook/callback"
  },
  function(accessToken, refreshToken, profile, done) {
    // Find or create user in DB based on Facebook profile
    done(null, profile);
  }
));

app.get(‘/auth/facebook‘, passport.authenticate(‘facebook‘));

app.get(‘/auth/facebook/callback‘,
  passport.authenticate(‘facebook‘, { failureRedirect: ‘/login‘ }),
  function(req, res) {
    res.redirect(‘/‘);
  });

This uses the passport-facebook strategy to authenticate with Facebook‘s OAuth 2.0 API. When a user visits /auth/facebook, they will be redirected to Facebook to log in. After logging in, they will be redirected back to /auth/facebook/callback with their Facebook profile information.

You can use a similar process to add authentication with Google, Twitter, GitHub, or any other service that supports OAuth.

Security Best Practices

No matter which authentication method you choose, there are several important security practices to keep in mind:

  1. Always use HTTPS. This encrypts all data sent between the client and server, preventing man-in-the-middle attacks.

  2. Never store passwords in plain text. Always hash passwords with a secure one-way hashing algorithm like bcrypt or scrypt before storing them in your database.

  3. Use strong, random session secrets. The session secret is used to sign and encrypt session data. It should be a long, random string and should be kept secret.

  4. Set appropriate cookie options. Use the httpOnly, secure, and sameSite options to prevent cookie theft and cross-site request forgery (CSRF) attacks.

  5. Validate and sanitize all user input. Never trust data from the client. Always validate and sanitize input to prevent injection attacks.

  6. Limit failed login attempts. Implement rate limiting to prevent brute-force attacks on passwords.

  7. Use two-factor authentication. For extra security, consider implementing two-factor authentication using SMS, email, or an authenticator app.

  8. Keep dependencies up to date. Make sure to keep all dependencies, especially those related to security, up to date with the latest patches.

By following these practices, you can greatly reduce the risk of security vulnerabilities in your application.

Conclusion

In this article, we‘ve covered three ways to handle authentication in a Node.js application:

  1. Cookies – Simple but limited, suitable for small amounts of non-sensitive data
  2. Sessions – More secure and scalable, can handle larger amounts of data
  3. Passport.js – Flexible authentication middleware supporting 500+ strategies

We‘ve also discussed some important security practices to follow regardless of which method you choose.

Authentication is a complex topic and there‘s a lot more we could cover, such as handling password resets, account lockouts, and single sign-on. But hopefully, this article has given you a good starting point for implementing secure authentication in your own Node.js applications.

Remember, as a developer, the security of your users‘ data is in your hands. Always prioritize security and never cut corners when it comes to authentication. Your users are trusting you with their personal information – don‘t let them down!

Similar Posts