/blog
August 1, 2022

How to Generate and Validate an OAuth 2.0 State Parameter with Node.js

tutorials, nodejs, auth

In a typical OAuth flow, the redirect URI that takes a user to an OAuth provider’s page needs a client ID and scope as required parameters. With these two parameters, your OAuth flow will work fine, but it leaves your application vulnerable to cross-site request forgery (CSRF) attacks.

The state parameter, which is an optional parameter, exists to validate if the requests are from a third party, which can help protect your application. OAuth 2.0 state parameters help guard against CSRF attacks, maintain application state, and preserve and restore user sessions.

In this tutorial, you’ll learn how to generate, store, and validate OAuth 2.0 state parameters with Node.js and use them in your OAuth flow using GitHub as an OAuth provider.

What Is an OAuth 2.0 State Parameter

An OAuth 2.0 state parameter is a unique, randomly generated, opaque, and non-guessable string that is sent when starting an authentication request and validated when processing the response.

OAuth 2.0 state parameters are used primarily for mitigating CSRF attacks by confirming if the value of the state parameter coming from the response is the same as the one sent in the request. However, this is not their only use case.

OAuth 2.0 state parameters can also be used to restore an application’s previous state by preserving particular state objects set by the client in authorization requests and making them available in the response sent to the client. They can also be used to restore a user’s session by encoding the application state and then querying an in-memory data grid for cached objects for a specific user. This process will put the user where they were before the authentication process started by pointing to a user session key.

Generating and Validating an OAuth 2.0 State Parameter with Node.js

In the following sections, you’ll be given step-by-step instructions on how to generate and validate an OAuth 2.0 state parameter with Node.js.

Prerequisites

Before you begin, you need to have the following:

In addition, because this tutorial features GitHub as an OAuth provider, you need to set up a GitHub OAuth app.

If you want to clone the project and follow along in your own editor, you can use this GitHub repo.

Generate the State Parameter

To begin the tutorial, you need to generate a state parameter. As stated previously, a state parameter is randomly generated, unique, non-guessable, and opaque. To generate a valid state parameter, you must satisfy all these conditions.

There are several ways to achieve this, but for simplicity, this tutorial will feature the use of a third-party dependency called nanoid. nanoid is a URL-friendly, unique string ID generator for JavaScript.

Install nanoid in your Express app using npm by running the following command:

npm install nanoid

Using this dependency satisfies the first three conditions. The fourth condition can only be met while storing the state parameter by hashing or signing it.

Note that to use this dependency, you have to specify "type":"module" in your package.json file since the dependency only works with ES6 modules and not CommonJS modules.

To generate the random string with nanoid, import {nanoid} from nanoid. Then call the imported nanoid function:

import { nanoid } from 'nanoid';

const randomString = nanoid() // "WeHH_yy2irpl8UYAvv-my"

Please note: the length allowed for a state parameter is not unlimited. By default, the string generated by nanoid will have a length of twenty-one characters.

If you get a 414 Request-URI Too Large error, you can trim the length of the generated string by passing your desired length as an argument into the nanoid function:

const randomString = nanoid(15) // "WeHH_yy2irpl8UY"

Now, you have a way to generate a random, unique, and non-guessable string. Next, you’ll need to store it.

Store the State Parameter

As you know, the state parameter must be sent when starting an authentication request and validated when processing the response.

Validation is technically impossible if there is nothing to validate against; therefore, the randomly generated string must be stored on the client’s application side and retrieved for validation during the validation process. Your storage method will depend on your application type.

Following is a table from Auth0 that will help you choose the appropriate storage method for your application:

| Application Type | Storage Recommendation | | ----------------------- | ----------------------- | | Regular web app | Cookie or session | | Single-page application | Local browser | | Native app | Memory or local storage |

For this tutorial, you’ll store the randomly generated string in the cookies and sign the cookies to maintain opacity.

Express uses a middleware, cookie-parser, which parses cookies attached to the client request object. Install this middleware by running the following:

npm install cookie-parser

Then you need to store and sign your cookie. To do this, import cookie-parser into your application’s entry file:


import cookieParser from 'cookie-parser';

Next, use cookieParser as a middleware in your application’s entry file:

app.use(cookieParser(process.env.COOKIE_SECRET));

cookieParser takes in any string or an array as a secret. The cookies will be signed using the secret. If the secret is not provided, cookieParser will not parse the signed cookies.

Finally, store the randomly generated string in your authentication route handler. Pass in an options object; the options should contain a maxAge (optional but recommended) and set the signed property to true.

The maxAge is a number representing the milliseconds from the time of the cookie creation till expiry. It denotes how long the cookie will exist. signed is a Boolean indicating whether the cookie is to be signed or not:

app.get("/auth", (req, res) => {
  //generate state parameter with nanoid
  const stateParam = nanoid();

  //store state parameter in cookie, set maxAge, and set signed to true
  res.cookie("stateParam", stateParam, { maxAge: 1000 * 60 * 5, signed: true });

The randomly generated string now meets the requirements for becoming a state parameter and has been signed and stored in a cookie.

Next, you’ll need to add the state parameter to a request.

Add the State Parameter to a Request

For a state parameter to be included in the response sent back by the authentication server, you must include it in the initial request made to the authentication server.

In your authentication route handler, when redirecting the user to the authentication server along with your client_id and scope, you’ll need to add the state parameter to the redirect URI.

The client_id is a string that is received from your OAuth provider when you create an OAuth app. The scope is a string that specifies the amount of access an OAuth app has to a user’s information.

To add the state parameter to the redirect URI without having to manually encode it, you’ll have to use the URLSearchParams class. The URLSearchParams class defines utility methods to work with the query string of a URL.

To add the state parameter to the redirect URI, you need to make your code cleaner and more readable. To do so, store all the URI parameters (client_id, scope, stateParam) in an object:

const query = {
    scope: "read:user",
    client_id: process.env.CLIENT_ID,
    state: stateParam,
  };

Convert the query object to a URL-encoded string and store it in a variable. To convert the query object to a URL-encoded string, create a new instance of the URLSearchParams class and call the toString method on it:

const urlEncoded = new URLSearchParams(query).toString();

Then modify the redirect URI by adding the urlEncoded variable after the authorize query:

res.redirect(`https://github.com/login/oauth/authorize?${urlEncoded}`);
});

Validate the State Parameter and Authorizing Requests

The OAuth provider redirects the user to the application after the request has been sent, and the user gives consent.

The OAuth provider will include a state value in this redirect, and you need to compare the state value received with the value stored on the client application’s side. Doing this confirms that the request is not coming from a third party, mitigating CSRF attacks.

To validate that the state parameter being sent back is the same one provided initially, extract the code and state from req.query in your callback route handler. Then extract stateParam from req.signedCookies in your callback route handler or your application’s storage method. Finally, compare the state against the stateParam. If the value matches, allow the application to request to exchange the code for an access token or return a 422 unprocessable entity error and deny it:

app.get("/oauth-callback", (req, res) => {

  //Extracting code and state
  const { code, state } = req.query;

//Extracting state parameter previously signed and stored in cookies
  const { stateParam } = req.signedCookies;

  //Comparing state parameters
  if (state !== stateParam) {
//throwing unprocessable entity error
    res.status(422).send("Invalid State");
    return;
  }

//Exchanging code for access token
  const body = {
    client_id: process.env.CLIENT_ID,
    client_secret: process.env.CLIENT_SECRET,
    code,
    state,
  };

  const opts = { headers: { accept: "application/json" } };
  axios
    .post("https://github.com/login/oauth/access_token", body, opts)
    .then((_res) => _res.data.access_token)
    .then((token) => {
      res.redirect(`/?token=${token}`);
    })
    .catch((err) => res.status(500).json({ err: err.message }));
});

Conclusion

In this article, you learned all the steps required to generate, store, add to a request, and validate an OAuth 2.0 state parameter. Using state parameters in your OAuth flow will mitigate all CSRF attacks on your application and make your authentication flow easier, as user sessions and your application’s previous state are restored.

Sign up for a free Stateful account to unbreak your internal engineering docs and restore faith in your team’s operating procedures and workflows.
Follow us on X or subscribe to our email updates to stay in the loop. - Bye for now! 👋