Richard Kovacs

Deleting Cookies in Next.js Middleware

Richard Kovacs
Richard Kovacs
9 min read
Listen

Handling authentication is every web developer's worst nightmare (after timezones, of course). Recently, I was struggling with an annoying bug that prevented me from logging out of my application. The problem was related to Next.js being unable to delete cookies correctly if the backend was on a different subdomain than the frontend.

If you are here for the solution only, feel free to skip to The Final Solution section. However, if you'd like also to understand the why behind the bug, read along.

Handling Cookies in the Next.js App Router

The Next.js App Router offers a convenient way to handle cookies on the server. You can simply import the cookies() function from the next/headers package, instantiate a cookie store with cookieStore = cookies() and you have access to a variety of cookie handling functions.

  • You can get any cookie by its name using cookies().get(name).
  • You can get every available cookie by using cookies().getAll().
  • You can check whether a cookie exists with cookies().has(name).
  • You can set cookies on the outgoing request with cookies().set(name, value, options).
  • Finally, you can delete a cookie with cookies().delete(name).

These functions are available in server components, server actions, route handlers, and the middleware, although the middleware is not mentioned directly in the documentation.

It is almost as easy as it looks, except for the last point: deleting a cookie. If you search for "cannot delete cookie in nextjs" or a similar term, you will quickly see that quite a few people are struggling with the same problem as I did, and most solutions are not accepted as the answer because they do not solve the OP's problem at all.

There's a chance you will never face this problem when working with Next.js, although there's an even higher chance that this is exactly why you are here right now. In this article, I will show you a solution no other article, question, or documentation mentioned so far.

But let me explain the origin (no pun intended) of the problem first.

A Common Client-Server Architecture

If you are using Next.js without a separate backend, the following part will be less useful for you. However, if you have a server running somewhere, for example, an Express backend, then listen closely.

It is not uncommon to have the frontend running under example.com and the server on api.example.com. In fact, you will find this all over the web: the backend is on a subdomain of the original domain. And although both look extremely similar, they are fundamentally different when working with cookies. Here's why.

Since April 2011, it is defined in RFC 6265 Section 4.1.2.3. that if the Domain attribute of a cookie is set to example.com,

the user agent will include the cookie in the Cookie header when making HTTP requests to example.com, www.example.com, and www.corp.example.com.

In other words, the cookie will be available on requests made to the domain and its subdomains. Furthermore, the standard also states that a leading dot (.), if present, is ignored, even though it is not even permitted.

However, if the Domain attribute is not set, the cookie will only be available on the domain, but not on subdomains. If you check the MDN Web Docs for the Set-Cookie header and for Cookies, you will see that both confirm the statement made in the RFC.

That's great news! It looks like everyone agrees on how cookies should be handled. Just to be extra sure, let's confirm that Brave, a highly popular browser, also respects the RFC.

Set-Cookie Response Header in Brave

If you look closely, you can see that the response coming back from ReadSonic's backend contains a Set-Cookie header which instructs the browser to make the cookie available on readsonic.io and all of its subdomains as per the standard. Now, let's see the stored cookie itself.

Stored Cookie in Brave

Surprisingly, I noticed that the Domain was actually slightly changed to .readsonic.io. Note the leading dot. At this point, I should have already felt the storm coming, but I quickly tested my application. The backend accepted authenticated requests, and I considered my job here to be done. For some reason, Brave (and other browsers) included the leading dot when storing the cookie.

Then, I tried to log out.

And nothing happened, even though I used the suggested method shown in the Next.js documentation to delete cookies.

How Next.js Wants You to Delete Cookies

As I mentioned earlier, deleting cookies on the server with Next.js is extremely easy. I have a logout endpoint defined in my middleware with the following code. I also added a log statement for the next step.

if (request.nextUrl.pathname === "/logout") {
  const response = NextResponse.redirect(new URL("/login", request.url));
  response.cookies.delete("connect.sid");

  console.log("Response cookies:", response.cookies.getAll());

  return response;
}

When someone wants to log out, they send a request to /logout, the middleware clears the session cookie and redirects the user to the login page. Let's now check what the middleware does under the hood.

Response cookies: [
  {
  name: 'connect.sid',
  value: '',
  path: '/',
  domain: undefined,
  expires: 1970-01-01T00:00:00.000Z
}
]

The log statement above is from Vercel when the /logout endpoint is accessed. As you can see, Next.js deletes the cookie by expiring it and setting its value to an empty string.

You can also discover another critical detail in the log above: the Domain is set to undefined. Based on the RFC, this means that this cookie should be different from the one defined in the browser and only accessible on the domain but not on subdomains.

However, the Domain attribute of the other cookie sitting in the browser's storage is .readsonic.io, which makes it accessible on subdomains as well. So, even though the leading dot should be ignored and both cookies should virtually be the same, browsers will treat them as different and won't delete the cookie. And that's why I cannot log out.

Delete Set-Cookie Header in Brave

You can see that the Set-Cookie header in the response does not contain the Domain attribute.

It is easy to prove that this is causing the trouble because removing the leading dot from the cookie's Domain attribute and pressing the logout button again works like a charm.

Alternative Ways to Delete Cookies in the Next.js App Router

The Next.js Docs also mentions some alternative ways to delete cookies, although they are the same as what .delete() does under the hood. You have the option to

  • Set the cookie's value to an empty string,
  • Set the maxAge property to 0,
  • Set the maxAge property to any timestamp in the past.

There is also a warning at the bottom of the page saying: “You can only delete cookies that belong to the same domain from which .set() is called.” This is probably also true for delete(), as that is precisely what was causing me the headache.

To my disappointment, none of the alternative examples showed how to delete the cookie with a Domain attribute of .readsonic.io. Here is what worked.

The Final Solution

The solution that consistently worked even across subdomains is below.

if (request.nextUrl.pathname === "/logout") {
  const response = NextResponse.redirect(new URL("/login", request.url));
  response.cookies.set("connect.sid", "", {
    expires: new Date(0),
    path: "/",
    domain: "readsonic.io",
  });
  console.log("Response cookies:", response.cookies.getAll());

  return response;
}

I manually set the domain to readsonic.io, and by doing so, I actually turn it into the same domain attribute as .readsonic.io because the leading dot can be ignored. It would also work with .readsonic.io by the way. In fact, it is a better idea to include the leading dot, as you will see later. The point is that the Domain attribute should be manually set when deleting the cookie if it was also set when it was created.

Vercel logs show that the Next.js middleware correctly sets the domain attribute.

Response cookies: [
  {
  name: 'connect.sid',
  value: '',
  expires: 1970-01-01T00:00:00.000Z,
  path: '/',
  domain: 'readsonic.io'
}
]

Also, the Set-Cookie header on the response contains the Domain attribute, as it should.

Set-Cookie Header with Domain Attribute in Brave

And finally, I could log out of my application.

Note that if you hardcode your domain as I showed you in the example above, you won't be able to log out locally when you develop your application on localhost because localhost is a different domain than the hardcoded value. Your browser will even show a warning that this is the case. I am actually using an environment variable for the domain attribute in my code to support local and production environments. The hardcoded value was just there to make the example easier to understand.

Browsers Do Not Fully Adhere to the RFC

Remember when I mentioned that manually deleting the leading dot proves that an empty Domain attribute is the same as the raw domain without the dot?

Well, this observation also suggests that modern browsers do not fully adhere to the RFC. Because when I deleted the dot, my frontend requests suddenly stopped working with 401 errors. Requests made from readsonic.io to api.readsonic.io did not contain the session cookie even though the specification states that if the Domain attribute is set with or without the leading dot, the cookies should also be available on subdomain requests.

This behavior might also explain why browsers silently add the leading dot to stored cookies, even though the Set-Cookie header coming from the server does not contain it — they treat it differently.

I tested Brave, Firefox, Chrome, Edge, and Safari, and they all behaved the same way. It looks like an intentional decision at this point, but it still makes me wonder why they deviated from this standard.

Conclusion

Battling this bug was a great lesson. I would have never learned so much about session handling otherwise. It convinced me to dig deep enough to find the official specifications and explore that some parts are not supported even by the most modern browsers.

I also suspect that Next.js (or Vercel?) didn't do anything wrong here because when I removed the leading dot from the cookie, authenticated server-side data fetching still worked; only client client-side requests started throwing 401 errors.

That being said, I would be happy if they extended the docs to mention that you can (and should) set the Domain attribute in the set() function if you want to delete a cookie containing a leading dot.

It took me more than a day to debug this, but in the end:

Me vs. Authentication: 1-0.