PropelAuth Logo

Complete Guide to Debugging Cookie Issues

Complete Guide to Debugging Cookie Issues

You flip the dark mode toggle. Works on one page, but resets on another. You embed a site on a different domain within an <iframe>, but your settings vanish. Welcome to the world of browser cookies - a world where things can go wrong in many many different ways.

In this post, we’ll be looking at some of the most common ways cookies don’t work the way you’d expect them to.

Specifically we’ll look at cases where you try to set a cookie, but it doesn’t seem to get persisted, and then we’ll look at cases where your persisted cookie isn’t being included in your requests.

Let’s get started!

The server tries to set a cookie, but it doesn’t persist

Let’s start off with an easy one to get a sense for how to debug these types of issues.

Cookies have a maximum size to them enforced by most browsers (and some web frameworks / reverse proxies). Cookies that are larger than 4KB are rejected with messages like:

  • 400 Bad Request: Request Header or Cookie Too Large
  • Request Header Fields Too Large
  • Request Header Section Too Large
  • The size of the request headers is too long

All pretty clear messages. We can test this directly by making a simple Express route, that sets a very long cookie:

app.get("/", (req, res) => {
  const longCookie = "a".repeat(5000);
  res.cookie("testLongCookie", longCookie, {}).send("Hello!");
});

When we go to that route, we can see that our Hello! message displays, but no cookies were set:

Image in article: Complete Guide to Debugging Cookie Issues

This seems like a pretty obvious bug because the code is 4 lines - but in practice these bugs are usually a bit more subtle.

To debug this, we want to check the request on the Network tab in Chrome’s Dev Tools:

Image in article: Complete Guide to Debugging Cookie Issues

In the Response Headers section, you’ll find our attempt at setting a cookie via the Set-Cookie header. You can also see a very clear error message:

Image in article: Complete Guide to Debugging Cookie Issues
This attempt to set a cookie via a Set-Cookie header was blocked because the cookie was too large. The combined size of the name and value must be less than or equal to 4096 characters.

For completeness, you can also check the Cookies tab, and if you hover over the problematic cookie, you’ll get the same error message:

Image in article: Complete Guide to Debugging Cookie Issues

The same process we went through in the previous example will work for pretty much all the cookie issues where you tried to set it, but it doesn’t persist.

To debug these issues generally, we…

  • Look for the request that should have set the cookie in the Network tab in Chrome
  • Check either the Cookies tab or the Set-Cookie response header
  • Hover over it for a detailed error message

Easy enough, let’s apply this to some more problems:

In addition to having a key and a value, cookies also have a bunch of different configuration points. One of these options is the domain attribute, which dictates which server is allowed to receive the cookie.

Let’s update our code to set the domain in production, but avoid setting it in development:

// This is just an example, there are other options that you'd want to set
// like secure, httpOnly, etc, and you might not want to set domain at all
const getCookieOptions = () => {
  if (process.env.NODE_ENV === "development") {
    return {};
  } else {
    return {domain: "example.com"};
  }
};

app.get("/", (req, res) => {
  res.cookie("testCookie", "it-works!", getCookieOptions())
     .send("Hello!");
});

As long as our response comes from example.com, when we aren’t in the development environment, everything here works great.

Let’s say we deploy our product and set up preview deployments. Unlike our production environment, these preview deployments aren’t hosted on example.com. They instead get a unique URL per PR hosted elsewhere: https://pr_add_feature.hostingprovider.com

In our preview deployments, this cookie will never be set.

We can follow our same process as last time to see what’s wrong:

Image in article: Complete Guide to Debugging Cookie Issues
This attempt to set a cookie via a Set-Cookie header was blocked because it’s Domain attribute was invalid with regards to the current host url.

Put simply:

Quick aside: The Domain attribute can be odd

The behavior of the Domain attribute can feel a little counterintuitive. If you set a Domain attribute of example.com, you are essentially specifying https://**.example.com, because the Domain attribute applies to any subdomain of the specified domain as well.

If your server was responding from example.com, and you want the cookie to be sent ONLY for example.com, you shouldn’t specify a domain attribute at all.

You can read more about this here.

Here are a few more quick cases where the browser blocks a Set-Cookie attempt:

  • Secure Flag Without HTTPS - If you set the Secure cookie attribute on a site served over HTTP (excluding localhost), the browser rejects the cookie.
  • SameSite Requires Secure - ****Setting SameSite=None demands that Secure also be set, or the browser will refuse the cookie. We'll look at SameSite in more depth later.
  • Expired Immediately - A cookie with an expiration date in the past (or maxAge: 0) is deleted right away.
    • This is pretty obviously expected behavior, but if you are debugging a cookie not being set, it’s worth the quick sanity check that the expiration is in fact in the future.

Example: HttpOnly cookies aren’t visible via document.cookie

One last honorable mention: it is a good practice to set the HttpOnly attribute for any cookies that don’t need to be accessed via JavaScript and especially any sensitive cookies.

But for some non-sensitive cookies, you might want to set them via JS (e.g. a dark-mode toggle). Let’s adjust our route for a second:

app.get("/", (req, res) => {
  res.cookie("canJsSeeMe", "yes").send("Hello!");
});

And then note that this cookie will be returned via document.cookie:

Image in article: Complete Guide to Debugging Cookie Issues

But if we set HttpOnly:

app.get("/", (req, res) => {
  res.cookie("canJsSeeMe", "yes", { httpOnly: true }).send("Hello!");
});

then document.cookie returns nothing:

Image in article: Complete Guide to Debugging Cookie Issues

Also note that this works in both directions, this code, for example, does not error but also does not set a cookie:

// Doesn't work, HttpOnly can't be set for cookies set via JS
document.cookie="a=b;HttpOnly"

Now that we’ve gotten a good handle on when cookies have failed to be set in the browser, let’s switch gears to a different class of cookie problems.

There is a cookie set, but it isn’t being sent to our backend

We’ll start again with an easy example to get a better understanding of how to debug these types of errors.

Let’s start by making our example app a little more complicated:

app.use(cookieParser());

app.get("/docs", (req, res) => {
  if ("visitedDocs" in req.cookies) {
    res.send("Welcome back to the docs!");
  } else {
    res.cookie("visitedDocs", "true", { path: "/docs", maxAge: ONE_YEAR });
    res.send("Welcome to the docs for the first time!");
  }
});

app.get("/blog", (req, res) => {
  if ("visitedBlog" in req.cookies) {
    res.send("Welcome back to the blog!");
  } else {
    res.cookie("visitedBlog", "true", { path: "/blog", maxAge: ONE_YEAR });
    res.send("Welcome to the blog for the first time!");
  }
});

We now have two routes, one for the docs and one for the blog. The first time you visit either, it will welcome you and set a cookie indicating you visited it. Every subsequent time, it will remember that you visited before.

You can bounce back and forth between them and see this works perfectly.

What happens when we update our /blog route to look like this?

app.get("/blog", (req, res) => {
  const messages = [];
  if (!("visitedDocs" in req.cookies)) {
    messages.push(
      "It seems you haven't visited the docs yet. You can check them out <a href='/docs'>here</a>.",
    );
  }

  if ("visitedBlog" in req.cookies) {
    messages.push("Welcome back to the blog!");
  } else {
    res.cookie("visitedBlog", "true", { path: "/blog", maxAge: ONE_YEAR });
    messages.push("Welcome to the blog for the first time!");
  }
  res.send(messages.join("<br>"));
});

We want to give our users a light nudge when they visit the blog that they should also check out our docs, and we do that by checking the visitedDocs cookie in our /blog route.

The problem? When a user visits /blog they will always get the message that they haven’t visited the docs yet, even when they have. Even stranger, /docs will correctly display whether or not they visited the docs already.

To debug this, let’s go back to the Network tab of our Dev Console and look at the request. The Request Headers section shows the problem, but not the cause:

Image in article: Complete Guide to Debugging Cookie Issues

The only cookie being sent is visitedBlog=true. The Cookies tab is equally unhelpful at first:

Image in article: Complete Guide to Debugging Cookie Issues

…until you see the option “show filtered out request cookies.” Toggle that on and now we can see the problem clearly:

Image in article: Complete Guide to Debugging Cookie Issues
This cookie was blocked because its path was not an exact match for or a superdirectory of the request url’s path.

In other words, we set the Path attribute for the visitedDocs cookie to /docs, so the browser will only send it to /docs or paths underneath that like /docs/javascript but will NOT send the cookie to any other path like /blog.

To fix this, we can set the visitedDocs's path to /, which means it would be sent in requests to both /docs and /blog.

Once again, the actual process for debugging these issues is pretty straightforward. We…

  • Go to the request in the Network tab of the developer console.
  • Look at the Cookies tab, making sure to turn on “show filtered out request cookies”
  • Our problematic cookies will be highlighted and hovering over them will tell us why the browser didn’t send them in the request.

Let’s look at some more advanced versions of this!

Whenever you make a request from https://example.com to https://otherplace.com (in the browser, that is), you can run into issues where cookies don’t send when you think they should.

Aside: Why are Cross-Site requests more protective of cookies?

One straightforward reason is browsers want to be cautious of including a sensitive cookie when a user visits https://phishing-site.com and it tries to make a request to https://your-bank.com/transfer-funds.

However, the bank can also use CORS to protect against that specifically. CORS actually gives you the option to select which external domains you do allow requests from, and those might be completely reasonable use cases for third-party cookies.

The problem is those types of cookies can also be used to track you across the internet.

Let’s imagine https://yourfavoritesocialmediasite.com was able to set a cookie with a unique ID for you. There’s nothing inherently wrong with that - pretty much every site does a version of that for authentication.

But now let’s imagine that sites like https://momandpopstore.com or https://sensitivehealthtopics.com include advertisements that load from https://yourfavoritesocialmediasite.com. If the browser includes that cookie that uniquely identifies you, your favorite social media site is effectively able to see all the different websites you visit.

CORS won’t protect you here because https://yourfavoritesocialmediasite.com WANTS the cookie to be sent to them, whereas https://your-bank.com definitely doesn’t.

All that to say - if you are doing anything with cookies that need to work across domains, it’s possible, but you will be fighting against user’s (very reasonable) privacy protections, and it’s worth considering if you have other options.

Example: Cookies not sending on initial page load (a.k.a. what is SameSite)

Let’s get back to debugging cookie problems and look at another common one. We’ll update our example app one more time:

app.get("/", (req, res) => {
  res.cookie("check_me", "ok", { sameSite: "strict", maxAge: ONE_YEAR });

  if ("check_me" in req.cookies) {
    res.send("check_me was sent");
  } else {
    res.send("check_me was NOT sent");
  }
});

This seems pretty straightforward. The only notable difference is we are setting SameSite to Strict. SameSite is a cookie attribute that lets you control when cookies are sent in cross-site requests, and strict is… the strictest setting.

The first time we go to our page we get the check_me was NOT sent message and every subsequent time we refresh we get check_me was sent.

Down the road, we start getting reports that users are randomly seeing check_me was NOT sent when they first go to the page. When you narrow it down, you find that it occurs when an external site links to your site.

How do we debug this? Same way we did the rest of them, we go to the request in the Network section, check the cookies tab, and see:

Image in article: Complete Guide to Debugging Cookie Issues
This cookie was blocked because it had the “SameSite=Strict” attribute and the request was made from a different site. This includes top-level navigation requests initiated by other sites.

Put differently, the user navigated from https://othersite.com to https://yoursite.com, so the browser will not send any Strict cookies initially.

In some cases, this is a nice, protective feature. In others, it’s an annoying bug and you’ll want to switch to Lax.

For a brief overview of the different SameSite settings:

  • Strict: Cookies only get sent on same-site requests. In other words, if you follow a link from example.com to example.com/page2, that’s same-site, so the cookie will be sent. But if you submit a form from another-site.com to example.com, that’s cross-site and cookies won’t be included.
  • Lax: Cookies are sent along with top-level navigations (like clicking a link from another-site.com to example.com) but are not sent on other cross-site requests, such as iframes or programmatic requests (e.g. fetch, Ajax, or form submissions from a different domain).
  • None: The cookie can be sent on all cross-site requests. However, setting SameSite=None requires Secure to be set (your site must be served over HTTPS). Without Secure, browsers will reject the cookie.

More cross-domain issues: Don’t forget includeCredentials

We’ve primarily looked at navigation events. A user loaded /docs. A user was redirected to example.com.

Another common way that cookies are sent and received is via fetch calls, or similar requests made from JavaScript.

By default, a cross-site fetch request doesn’t include cookies unless you explicitly allow it:

// By default, cookies are NOT included in cross-site requests
fetch("<https://othersite.com/api>", {
  method: "GET",
});

// To include cookies, you need to add:
fetch("<https://othersite.com/api>", {
  method: "GET",
  credentials: "include",
});

One important thing to note here, is that these requests will still respect all the other settings we talked about. Even when you do add credentials: "include" , your cookies still may not send for SameSite reasons or because the user’s browser blocks all third-party cookies.

Summary

Cookies can both be incredibly easy and a giant pain to work with.

When you are debugging an issue related to cookies, a good default is to always look at the request in your DevTools console.

If the cookie isn’t being persisted when it should be, you should see an error message on the Set-Cookie header.

If the cookie isn’t being sent when it should be, you should see an error message on the Cookies tab, after turning on “show filtered out request cookies”

There are cases where you will get silent failures (like trying to set an HttpOnly cookie from JavaScript) and you’ll need to examine each of those cases individually. Look at the attributes you are setting and see if any of them dictate a requirement that you aren’t meeting. Additionally, if nothing stands out there, double check the default attributes of the cookie being set.

Finally, third-party cookies are a common source of headaches because your legitimate use case could almost certainly be used to violate other people’s privacy (if used by the wrong people). Whenever possible, try to avoid requiring them.

Hopefully you now have the tools to tackle any cookie issue that comes your way!