# Announcing MCP Authentication: secure your MCP servers with PropelAuth

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**.

### Example: Your cookie is larger than 4KB

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:

```tsx
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:

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:

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:

> 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:

### Debugging these types of 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:

### Example: Your cookie is being set on the wrong domain

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:

```tsx
// 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](http://example.com/?ref=propelauth.mymidnight.blog), 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](http://example.com/?ref=propelauth.mymidnight.blog). 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:

> 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:

- Our server responded with a cookie that was made for [example.com](http://example.com/?ref=propelauth.mymidnight.blog)
- The server itself was hosted NOT on [example.com](http://example.com/?ref=propelauth.mymidnight.blog) but instead on [hostingprovider.com](http://hostingprovider.com/?ref=propelauth.mymidnight.blog)
- The browser prevented that from happening

### 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](http://example.com/?ref=propelauth.mymidnight.blog), and you want the cookie to be sent ONLY for [example.com](http://example.com/?ref=propelauth.mymidnight.blog), you shouldn’t specify a domain attribute at all.

You can read more about this [here](https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/Cookies?ref=propelauth.mymidnight.blog#define_where_cookies_are_sent).

### Other common reasons your server’s Set-Cookie fails

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:

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

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

But if we set HttpOnly:

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

then `document.cookie` returns nothing:

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

```tsx
// 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.

### Example: The cookie wasn’t sent because the paths don’t match

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

```tsx
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?

```tsx
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:

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

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

> 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`.

### Debugging these types of cookie issues

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!

### Cross-Site requests and the inevitable cookie issues

Whenever you make a request from [https://example.com](https://example.com/?ref=propelauth.mymidnight.blog) to [https://otherplace.com](https://otherplace.com/?ref=propelauth.mymidnight.blog) (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](https://phishing-site.com/?ref=propelauth.mymidnight.blog) and it tries to make a request to [https://your-bank.com/transfer-funds](https://your-bank.com/transfer-funds?ref=propelauth.mymidnight.blog).

However, the bank can also use [CORS](/content/post/avoiding-cors-issues-in-react-next-js?ref=propelauth.mymidnight.blog) 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](https://yourfavoritesocialmediasite.com/?ref=propelauth.mymidnight.blog) 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](https://momandpopstore.com/?ref=propelauth.mymidnight.blog) or [https://sensitivehealthtopics.com](https://sensitivehealthtopics.com/?ref=propelauth.mymidnight.blog) include advertisements that load from [https://yourfavoritesocialmediasite.com](https://yourfavoritesocialmediasite.com/?ref=propelauth.mymidnight.blog). 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](https://yourfavoritesocialmediasite.com/?ref=propelauth.mymidnight.blog) WANTS the cookie to be sent to them, whereas [https://your-bank.com](https://your-bank.com/?ref=propelauth.mymidnight.blog) 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:

```tsx
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:

> 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](https://othersite.com/?ref=propelauth.mymidnight.blog) to [https://yoursite.com](https://yoursite.com/?ref=propelauth.mymidnight.blog), 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.
- `Lax`: Cookies **are** sent along with top-level navigations (like clicking a link from `another-site.com` to `example.com`).
- `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:

```tsx
// 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!
