How to Generate and Validate an OAuth 2.0 State Parameter with Node.js
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:
- Node.js installed on your system
- Basic knowledge of Express.js
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.