tutorial // Apr 12, 2021
How to Implement Secure, HTTPOnly Cookies in Node.js with Express
Using Express.js, learn how to implement cookies that are secure in the browser to avoid XSS (cross-site scripting) attacks, man-in-the-middle attacks, and XST (cross-site tracing) attacks.
Cookies are a clever technique for sharing data between a user's browser and your server. The data contained in a cookie can be anything you'd like: a login token, some profile data, or even some behavioral data explaining how the user utilizes your app. From a developer's perspective this is great, but if you're not aware of common security issues, using cookies can mean accidentally leaking data to attackers.
The good news: if you're aware of the techniques required to secure cookies in your app, the work you need to do isn't too difficult. There are three types of attacks we need to guard against:
- Cross-site scripting attacks (XSS) - These attacks rely on client-side JavaScript being injected into the front-end of your application and then accessing cookies via the browser's JavaScript cookies API.
- Man-in-the-middle attacks - These attacks occur when a request is in-flight (traveling from the browser to the server) and the server does not have an HTTPS connection (no SSL).
- Cross-site tracing attacks (XST) - In the HTTP protocol, an HTTP method called
TRACE
exists which allows attackers to send a request to a server (and obtain its cookies) while bypassing any security. While modern browsers generally make this irrelevant due to disabling of theTRACE
method, it's still good to be aware of and guard against for added security.
To get started, we're going to take a look at the server setup where our cookies will be created and then delivered back to the browser.
Creating secure cookies
To give context to our example, we're going to use the CheatCode Node.js Boilerplate which sets us up with an Express server already set up and ready for development. First, clone a copy of the boilerplate to your computer:
git clone https://github.com/cheatcode/nodejs-server-boilerplate.git
Next, make sure to install the boilerplate's dependencies:
cd nodejs-server-boilerplate && npm install
After that, go ahead and start up the server:
npm run dev
Next, let's open up the /api/index.js
file in the project. We're going to add a test route where we'll set our cookies and verify that they're working:
/api/index.js
import graphql from "./graphql/server";
export default (app) => {
graphql(app);
// Our cookie code will go here.
};
Next, let's add the code for setting our cookie and then walk through how and why it's working:
/api/index.js
import dayjs from "dayjs";
import graphql from "./graphql/server";
export default (app) => {
graphql(app);
app.use("/cookies", (req, res) => {
const dataToSecure = {
dataToSecure: "This is the secret data in the cookie.",
};
res.cookie("secureCookie", JSON.stringify(dataToSecure), {
secure: process.env.NODE_ENV !== "development",
httpOnly: true,
expires: dayjs().add(30, "days").toDate(),
});
res.send("Hello.");
});
};
Lots of detail added, so let's step through it. First, at the top of the file, we've added an import for the dayjs
NPM package. This is a library for creating and manipulating dates in JavaScript. We'll use this below to generate the expiration date for our cookie to ensure that it doesn't linger around in a browser indefinitely.
Next, we use the Express app
instance (passed in to this file via the /index.js
file at the root of the project) to call the .use()
method which allows us to define a route in our Express application. To be clear, this is purely for example. In your own app, this could be any route where you'd want to set a cookie and return it to the browser.
Inside of the callback for our /cookies
route, we get to work setting up our cookie. First, we define an example dataToSecure
object with some test data inside.
Next, we set our cookie. Using the res.cookie()
method provided in Express, we pass three arguments:
- The name of the cookie we want to set on the browser (here,
secureCookie
, but this could be whatever you want, e.g.,pizza
). - The stringified version of the data we want to send. Here, we take our
dataToSecure
object and stringify it usingJSON.stringify()
. Keep in mind: if the data you're sending back to the browser is already a string, you do not need to do this. - The settings for the cookie. The properties set here (
secure
,httpOnly
, andexpires
) are Express-specific properties, but the names map 1:1 with the actual settings in the HTTP specification.
Focusing on that last argument, the settings, this is where our security comes in. There are three settings that are important for securing a cookie:
First, the secure
property takes a boolean (true/false) value which specifies whether or not this cookie can only be retrieved over an SSL or HTTPS connection. Here, we set this depending on which environment our application is running in. As long as the environment is not development, we want to force this to be true
. In development this isn't necessary because our application is not exposed to the internet, just us, and it's likely that you do not have an SSL proxy server setup locally to handle these requests.
Second, the httpOnly
property likewise takes a boolean (true/false) value, here specifying whether or not the cookies should be accessible via JavaScript in the browser. This setting is forced to true
, because it ensures that any cross-site scripting attacks (XSS) are impossible. We don't have to worry about the development environment here as this setting does not have a dependency on SSL or any other browser features.
Third, and finally, the expires
property allows us to set an expiration date on our cookie. This helps us with security by ensuring that our cookie does not stick around in a user's browser indefinitely. Depending on the data you're storing in your cookie (and your app's needs) you may want to shorten or extend this. Here, we use the dayjs
library we imported earlier, telling it to "get the current date, add 30 days to it, and then return us a JavaScript Date
object for that date." In other words, this cookie will expire in 30 days from the point of creation.
Finally, at the bottom of our route's callback function, we call to res.send()
to respond to our request. Because we're using res.cookie()
we're automatically telling Express to send the cookie back as part of the response—no need to do anything else.
Handling TRACE requests
Like we mentioned earlier, before we check that our cookies are working as expected, we want to ensure that we've blocked the potential for TRACE
requests. We need to do this to ensure that attackers cannot utilize the TRACE
HTTP method to access our httpOnly
cookies (TRACE
doesn't respect this rule). To do it, we're going to rely on a custom Express middleware that will automatically block TRACE
requests from any client (browser or otherwise).
/middleware/requestMethod.js
export default (req, res, next) => {
// NOTE: Exclude TRACE and TRACK methods to avoid XST attacks.
const allowedMethods = [
"OPTIONS",
"HEAD",
"CONNECT",
"GET",
"POST",
"PUT",
"DELETE",
"PATCH",
];
if (!allowedMethods.includes(req.method)) {
res.status(405).send(`${req.method} not allowed.`);
}
next();
};
Conveniently, the above code exists as part of the CheatCode Node.js Boilerplate and is already set up to run inside of /middleware/index.js
. To explain what's happening here, what we're doing is exporting a function that anticipates an Express req
object, res
object, and next
method as arguments.
Next, we define an array specifying all of the allowed HTTP methods for our server. Notice that this array does not include the TRACE
method. To put this to use, we run a check to see if this allowedMethods
array includes the current req
uest's method. If it does not, we want to respond with an HTTP 405 response code (the technical code for "HTTP method not allowed").
Assuming that the req.method
is in the allowedMethods
array, we call to the next()
method passed by Express which signals to Express to keep moving the request forward through other middleware.
If you want to see this middleware in use, start in the /index.js
file to see how the middleware()
method is imported and called (passing the Express app
instance) and then open the /middleware/index.js
file to see how the /middleware/requestMethods.js
file is imported and utilized.
Verifying secure cookies in the browser
Now, we should be all set up to test out our cookie. Because we're setting the cookie at the route /cookies
, we need to visit this route in a browser to verify that everything is working. In a web browser, open up http://localhost:5001/cookies
and then open up your browser's console (usually accessible via a CTRL + click
on MacOS or by right-clicking on Windows):
In this example, we're using the Brave browser which has an identical developer insepection tool to Google Chrome (Firefox and Safari have comparable UIs but may not use the exact same naming we reference below). Here, we can see our secureCookie
being set, along with all of the data and settings that we passed on the server. To be clear, notice that here because we're in a development
environment, Secure
is unset.
An additional setting that we've left off here SameSite
is also disabled (this defaults to a value of Lax
) in the browser. SameSite
is another boolean (true/false) value that decides whether or not our cookie should only be accessible on the same domain. This is disabled because it can add confusion if you're using a separate front-end and back-end in your application (if you're using CheatCode's Next.js and Node.js boilerplates for your app, this will be true). If you want to enable this, you can, by adding sameSite: true
to the options object we passed to res.cookie()
as the third argument.
Retrieving cookies on the server
Now that we've verified our cookies exist in the browser, next, let's look at retrieving them for usage later. In order to do this, we need to make sure that our Express server is parsing cookies. This means converting the cookies string sent in the HTTP headers of a request to a more-accessible JavaScript object.
To automate this, we can add the cookie-parser
package to our app which gives us access to an Express middleware that parses this for us:
npm i cookie-parser
Implementing this is straightforward. Technically, this is already used in the CheatCode Node.js Boilerplate we're using for our example here, in the middleware/index.js
file at the root of the app:
/middleware/index.js
[...]
import cookieParser from "cookie-parser";
[...]
export default (app) => {
[...]
app.use(cookieParser());
};
Here, all we need to do is import cookieParser
from the cookie-parser
package and then call app.use()
passing a call to the cookieParser()
method like app.use(cookieParser())
. To contextualize this to our example above, here's an update to our /api/index.js
file (assuming you're writing your code from scratch):
/api/index.js
import dayjs from "dayjs";
import cookieParser from "cookie-parser";
import graphql from "./graphql/server";
export default (app) => {
graphql(app);
app.use(cookieParser());
app.use("/cookies", (req, res) => {
const dataToSecure = {
dataToSecure: "This is the secret data in the cookie.",
};
res.cookie("secureCookie", JSON.stringify(dataToSecure), {
secure: process.env.NODE_ENV !== "development",
httpOnly: true,
expires: dayjs().add(30, "days").toDate(),
});
res.send("Hello.");
});
};
Again, you don't need to do this if you're using the CheatCode Node.js Boilerplate.
With this implemented, now, whenever the app receives a request from the browser, its cookies will be parsed and placed on the req
or request object at req.cookies
as a JavaScript object. Inside of a request, then we can do so something like the following:
/api/index.js
import dayjs from "dayjs";
import cookieParser from "cookie-parser";
import graphql from "./graphql/server";
export default (app) => {
graphql(app);
app.use(cookieParser());
app.use("/cookies", (req, res) => {
if (!req.cookies || !req.cookies.secureCookie) {
const dataToSecure = {
dataToSecure: "This is the secret data in the cookie.",
};
res.cookie("secureCookie", JSON.stringify(dataToSecure), {
secure: process.env.NODE_ENV !== "development",
httpOnly: true,
expires: dayjs().add(30, "days").toDate(),
});
}
res.send("Hello.");
});
};
Here, before setting our cookie from our previous example, we call to req.cookies
(automatically added for us via the cookieParser()
middleware), checking to see if either the req.cookies
value is undefined, or, if req.cookies
is defined, is req.cookies.secureCookie
also defined. If req.cookies.secureCookie
is not defined, we want to go ahead and set our cookie as normal. If it's already been defined, we just respond to the request as normal but skip setting the cookie.
The point here is that we can access our cookies via the req.cookies
property in Express. You do not have to do the above check on your own cookie unless you want to.
How to manage cookies in GraphQL
To close the loop on managing cookies, it's worth understanding how to do this in relation to a GraphQL server. This is worth understanding if you want to set or retrieve cookies from a GraphQL resolver, or, during the server instantiation.
/api/graphql/server.js
import { ApolloServer } from "apollo-server-express";
import schema from "./schema";
import { isDevelopment } from "../../.app/environment";
import { configuration as corsConfiguration } from "../../middleware/cors";
export default (app) => {
const server = new ApolloServer({
...schema,
introspection: isDevelopment,
playground: isDevelopment,
context: async ({ req, res }) => {
const context = {
req,
res,
user: {},
};
return context;
},
});
server.applyMiddleware({
cors: corsConfiguration,
app,
path: "/api/graphql",
});
};
Here, to ensure we can both access and set cookies via our GraphQL query and mutation resolvers, we've set the context
property for the server to be equal to a function that takes in the req
and res
(here, because we're tying this to an Express app
instance, these are the Express req
and res
objects) and then assigns them back to the context
object that's handed to all of our query and mutation resolvers:
import dayjs from 'dayjs';
export default {
exampleResolver: (parent, args, context) => {
// Accessing an existing cookie from context.req.
const cookie = context?.req?.cookies?.secureCookie;
// Setting a new cookie with context.res.
if (context.res && !cookie) {
const dataToSecure = {
dataToSecure: "This is the secret data in the cookie.",
};
res.cookie("secureCookie", JSON.stringify(dataToSecure), {
secure: process.env.NODE_ENV !== "development",
httpOnly: true,
expires: dayjs().add(30, "days").toDate(),
});
}
// Arbitrary return value here. This would be whatever value you want to
// resolve the query or mutation with.
return cookie;
},
};
In the above example, we repeat the same patterns as earlier in the tutorial, however, now we're accessing cookies via context.req.cookies
and setting them via context.res.cookie()
. Of note, this exampleResolver
isn't intended to be functional—it's just an example of how to access and set cookies from within a resolver. Your own GraphQL resolver will use more specific code related to reading or writing data in your app.
Ensuring cookies are included in your GraphQL requests
Depending on your choice of GraphQL client, the cookies from your browser (httpOnly or otherwise) may not be included in the request automatically. To ensure this happens, you will want to check the documentation for your client and see if it has an option/setting for including credentials. For example, here is the Apollo client configuration from CheatCode's Next.js Boilerplate:
new ApolloClient({
credentials: "include",
link: ApolloLink.from([
new HttpLink({
uri: settings.graphql.uri,
credentials: "include",
}),
]),
cache: new InMemoryCache(),
defaultOptions: {
watchQuery: {
errorPolicy: "all",
fetchPolicy: "network-only",
},
query: {
errorPolicy: "all",
fetchPolicy: "network-only",
},
mutate: {
errorPolicy: "all",
},
},
});
Here, we ensure to set the credentials
property as 'include'
to signal to Apollo that we want it to include our cookies with each request. Further, because we're using the HTTP Link method from Apollo, for good measure we set credentials
to 'include'
here, too.
Wrapping up
In this tutorial, we looked at how to manage secure cookies in Node.js with Express. We learned how to define a cookie, using the secure
, httpOnly
, and expires
values to ensure they stay separate from attackers as well as how to disable TRACE
requests to prevent backdoor access to our httpOnly
cookies.
We also learned how to access cookies by utilizing the Express cookie-parser
middleware, learning how to access cookies in an Express route as well as via a GraphQL context.