Skip to content

Sessionless Authentication using JWTs (with Node + Express + Passport JS)

Authentication using stateful user sessions and session_ids stored in the cookie has been a strategy that has worked for decades. But with the rise of service oriented architectures and web services, there has been a push to design applications with the principle of statelessness in mind.

JWTs provide a stateless solution to authentication by removing the need to track session data on the server. Instead, JWTs allow us to safely and securely store our session data directly on the client in the form of a JWT.

A JWT is essentially a session data payload packaged in JSON and signed by the server

JWTs get a lot of  and , but the fact of the matter is that both session and JWT authentication have seen plenty of production usage and both implementations are secure and robust when it comes to handling user authentication. If statelessness is a practice you value in your system architecture, then JWTs are right for you. In this article, we will go over what JWTs are, the trade offs you make in choosing to use JWTs, and how you can implement them securely in your architecture.

How Authentication works

Before we begin, we need to agree what an authentication pipeline looks like:

  1. User POSTs to our server authentication details (over HTTPS):⠀⠀⠀⠀⠀⠀⠀ { username, password }
  2. The server determines whether the user is who she claims to be
  3. If the user’s authentication attempt succeeds, then our server sends some form of data (a token or a session id conventionally) that can be appended to every subsequent request which identifies the user as authenticated or not.

With sessionless auth, the data payload the client receives is our JWT, which should contain an encoded user identifier in JSON format signed by our back-end server. We put the JWT into our cookie so that we don’t have to store it in local-storage and risk XSS attacks. This is what an authentication process for a user named TheLegend27 might look like using JWTs:

cookie is a special header that will be sent along with every subsequent request. It also conveniently persists across user sessions. This means that once TheLegend27 has logged in, her JWT will be sent along with every subsequent request she makes. All we have to do to verify her identity is to check the request cookie and verify the JWT.

Some important things to note:

  • We don’t keep track of user sessions on the server! 🙌 This is the big difference between JWT auth and auth using sessions. We have one less data source to worry about in our architecture with sessionless auth.
  • Our auth pipeline is super simple! If you’re just trying to implement authentication into your web app as intuitively and quickly as possible, then JWTs are the way to go.

So that’s the theory behind sessionless authentication using JWTs. Now if you have familiarity with authentication using sessions, this pipeline might look somewhat familiar. A JWT looks very similar to a HS256 encrypted session_id stored in the cookie. In fact, JWTs by default are signed using HS256! The difference between the two is that a JWT encodes all session data in its payload, while a session_id references a session from a sessions table.

JWTs remove the need keep track of sessions on the back-end. Instead, session data is encoded in the JWT payload. The trade off being made here is that the size of a JWT scales proportionally to the size of its payload. Luckily, a payload that takes the shape of {user_id, expiration_date } is plenty enough for most cases.

So that’s the theory. Lets get on to the practice!

Before discussing implementation, let’s discuss best practices in order to protect ourselves against the most common kinds of attacks / vulnerabilities when it comes to authentication:

  • XSS and SQL / noSQL injection attacks
  • Brute-forcing of user credentials attacks
  • An attacker getting a hold of a user’s JWT / cookie
  • An attacker getting a copy of or read credentials to our database

JWTs and best practices can protect us against all of these attacks. Let’s see how:

XSS attacks

This kind of attack is the simplest attack to protect ourselves against. A naive way to protect ourselves against XSS and code injection is to sanitize user input by doing the something like _.escape(userInput). But the issue with this solution is that when it comes to private data, it’s naive to blindly trust libraries to properly sanitize user input against sql xss attacks. Input sanitization is a great first layer of defense, but isn’t enough by itself.

Some more sturdy ways we can protect our user’s sensitive data is to use an ORM / ODM, which enforces parameterized queries. Or we could use Stored Procedures if we’re using SQL, which defines the query procedure at the database level as opposed to the code level.

If XSS and query sanitization best practices interest you, I highly recommend  by the  on best practices to preventing XSS injection attacks.

Bruteforce User credential attacks

There are two ways an attacker will brute force user credentials:

A) They’ll attack a  until they hit a match.

B) They’ll attack a multitude of users and use a list of the most commonly used passwords to shallowly brute force until they find a hit. Here’s an excellent example of this: 

We can’t protect our users much from attack B. If a user sets their password to p@ssw0rd, then there’s not much we can do besides enforce stricter password policies in the future.

It turns out we can protect ourselves very well from attack A. The key is in making login requests take a non-trivial amount of time to process. An attacker can’t brute-force a user’s credentials if it takes 10¹¹ permutations to crack a 8 character password when each permutation takes 500ms to compute. We can make authentication take a non-trivial amount of time using a library called Bcrypt, which we’ll cover later.

Compromised JWT

This is the worst possible attack we can come across because it’s the hardest to resolve. Luckily, JWTs should rarely become compromised as we’re storing the JWT as a cookie and using HTTPS for all web transactions. Since we’re never storing the JWT in LocalStorage and only ever in the cookie, malicious attackers will not be able to steal our user’s JWT using XSS.

If an attacker somehow manages to steal a user’s JWT, then there’s unfortunately not much that can really be done. To minimize damages, you should design your application to require reauthentication before performing any high profile transaction such as a purchase or the changing of a password. And your JWTs should also have an expiration date. That way a compromised JWT will only work for so long.

This is what appears when you open the developer console on Facebook.com. This is a great way of preventing self-xss

Compromised Database

It turns out there are things we can do to protect user data and keep it obfuscated even if a hacker gets a hold of our database read credentials.

If we have a password, we don’t want to store it as plain-text in our database. Instead, we can salt and hash the password and store the salt and hash instead of our plain-text password.

Imagine we have a password p@ssw0rd. A salt is a random string of characters that we append to that password 'p@ssw0rd' + 'asdf253$n5', which we then pass through a hashing algorithm: SHA256('p@ssw0rd' + 'asdf253$n5'). We then store the salt and the hashed value in our user table.

An attacker can’t compute a user’s password with just a salt and hash.

This protects our user’s passwords pretty well if our database becomes compromised. You’ll still have to take action by for example requesting all users reset their passwords, but this will buy you plenty of time to do so.

It turns out there’s a standardized way of salting and hashing your password in a way that doesn’t require us to have a salt and hash field in our user table. The solution is called Bcrypt.

Bcrypt

Bcrypt is a password hashing algorithm that’s been around since 1999. It standardizes the salting and hashing of passwords. It also protects passwords from being brute-forced by making the process of authentication computationally intensive and “.” We’ll be using this in our authentication implementation.

This is what a password passed through Bcrypt looks like. It contains the hashing algorithm version, hash cost, salt value and hashed password value.

The higher the hash cost, the more computational power it takes to authenticate

Now that we have all this in mind, let’s get to implementing authentication.

I’m going to be using MongoDB but feel free to use any database of choice. Nothing crazy here. This is just an ordinary User Schema:

const mongoose = require('mongoose');
const { Schema } = mongoose;

const userSchema = new Schema({
  username: {
    type: String,
    index: true,
    unique: true,
    dropDups: true,
    required: true,
  },
  passwordHash: { //salted and hashed using bcrypt
    type: String,
    required: true,
  },
});

const User = mongoose.model('User', userSchema);
module.exports = User;

Next, we’re going to register two Passport strategies. We’re going to set up a Local Strategy and a JWT Strategy. In the future, when we call passport.authenticate('local') or passport.authenticate('jwt'), it’s going to invoke these respective middlewares.

const passport = require('passport');
const LocalStrategy = require('passport-local').Strategy;
const passportJWT = require('passport-jwt');
const JWTStrategy = passportJWT.Strategy;
const bcrypt = require('bcrypt');

const { secret } = require('./keys');

const UserModel = require('./models/user');

passport.use(new LocalStrategy({
  usernameField: username,
  passwordField: password,
}, async (username, password, done) => {
  try {
    const userDocument = await UserModel.findOne({username: username}).exec();
    const passwordsMatch = await bcrypt.compare(password, userDocument.passwordHash);

    if (passwordsMatch) {
      return done(null, userDocument);
    } else {
      return done('Incorrect Username / Password');
    }
  } catch (error) {
    done(error);
  }
}));

passport.use(new JWTStrategy({
    jwtFromRequest: req => req.cookies.jwt,
    secretOrKey: secret,
  },
  (jwtPayload, done) => {
    if (Date.now() > jwtPayload.expires) {
      return done('jwt expired');
    }

    return done(null, jwtPayload);
  }
));

The  extracts the username and password from req.body and verifies the user by verifying it against the User table.

The  extracts the JWT from the cookie, and uses the application’s secret to verify its signature:

The signature is dependent on the payload. If a user tampers with and changes the payload, then the signature changes as well

Now, last but not least, we define our /login and /register routes:

const express = require('express');
const passport = require('passport');
const bcrypt = require('bcrypt');
const jwt = require('jsonwebtoken');
const keys = require('../keys');
const UserModel = require('../models/user');

const router = express.Router();

router.post('/register', async (req, res) => {
  const { username, password } = req.body;

  // authentication will take approximately 13 seconds
  // https://pthree.org/wp-content/uploads/2016/06/bcrypt.png
  const hashCost = 10;

  try {
    const passwordHash = await bcrypt.hash(password, hashCost);
    const userDocument = new UserModel({ username, passwordHash });
    await userDocument.save();
    
    res.status(200).send({ username });
    
  } catch (error) {
    res.status(400).send({
      error: 'req body should take the form { username, password }',
    });
  }
});

router.post('/login', (req, res) => {
  passport.authenticate(
    'local',
    { session: false },
    (error, user) => {

      if (error || !user) {
        res.status(400).json({ error });
      }

      /** This is what ends up in our JWT */
      const payload = {
        username: user.username,
        expires: Date.now() + parseInt(process.env.JWT_EXPIRATION_MS),
      };

      /** assigns payload to req.user */
      req.login(payload, {session: false}, (error) => {
        if (error) {
          res.status(400).send({ error });
        }

        /** generate a signed json web token and return it in the response */
        const token = jwt.sign(JSON.stringify(payload), keys.secret);

        /** assign our jwt to the cookie */
        res.cookie('jwt', jwt, { httpOnly: true, secure: true });
        res.status(200).send({ username });
      });
    },
  )(req, res);
});

module.exports = router;

/register is pretty straight forward. We’re creating a new User and saving it to our Users table and responding with status 200 if we succeed.

/login is a bit more complicated. A break down of what’s going on: We’re authenticating with the local strategy. If authentication succeeds, we compose a payload for our JWT, and call req.login, which assigns the payload to req.user. We then compose our JWT by callingjwt.sign, and then we set our JWT to our cookie.

Now, if we need to create a route that requires authentication, we can use our JWT middleware to check if the user is authenticated!

router.get('/protected',
  passport.authenticate('jwt', {session: false}),
  (req, res) => {
    const { user } = req;

    res.status(200).send({ user });
  });

Conclusion

That’s it! There’s a whole rabbit-hole awaiting those who are interested in security and authentication. I’ve personally been stuck in this rabbit hole for far longer than I care to admit.

This article is the culmination of my research into best practices with JWTs and it should be enough to get your feet wet with sessionless auth. JWTs are an elegant solution to authentication and I hope this article has shed some light on how they work and how you can implement them yourself securely.