DEV Community

Cover image for Don't make these mistakes; otherwise, your cookies might break in production
Kishan Agarwal
Kishan Agarwal

Posted on Originally published at Medium

Don't make these mistakes; otherwise, your cookies might break in production

Look, if you've deployed something that was working perfectly on localhost and suddenly auth stops working in production, you're not alone. This is one of those bugs that doesn't throw a clear error. The server starts your session just fine. The browser receives the cookie. And then silently, it never sends it back. You spend hours staring at the Network tab, wondering what happened.

The culprit is almost always two cookie properties you probably never bothered setting: SameSite and domain. Let's go through them properly.

What Is a Cookie, Actually?

An HTTP cookie is a small block of data created by a web server while a user is browsing a website and placed on the user's computer or other device by the user's web browser.

Let me explain it in simple words. Think of a cookie like a visitor pass in a residential housing society. The security desk (your server) issues you a pass the first time you arrive (first login). Every time you come back, you show the pass at the gate, and you're let in without going through the whole verification process again. The browser holds your pass and automatically presents it every time you return.

Why Auth Needs Cookies

HTTP is stateless. Every request that hits your server is from a stranger the server has never met before. This is a problem for auth: the very first time a user logs in, you can verify them. But the very next request? The server has no memory of it.

Cookies solve this. After a successful login, the server sets a cookie on the browser. The browser then attaches that cookie to every subsequent request automatically. The server reads it and knows the request is authenticated. This pattern holds whether you are doing session-based auth or issuing JWTs.

You might be thinking, "Can't I just use localStorage for this?"

You can, but then you lose the automatic-sending behaviour, and you are exposed to XSS attacks. A cookie with httpOnly: true keeps the token out of JavaScript's reach entirely. That is the real security win here.

The Real Engineering Part

Most developers set only a handful of cookie options during development:

res.cookie('sessionId', 'xyz123', {
  httpOnly: true,
  secure: process.env.NODE_ENV === 'production',
  maxAge: 24 * 60 * 60 * 1000, // 1 day
  signed: true,
});

This works fine locally. Then production arrives, and auth breaks. Two properties are almost always the reason: SameSite and domain.

SameSite: The Property Nobody Reads Until It Burns Them

SameSite tells the browser when it is allowed to send your cookie with a request. It has three values, and picking the wrong one (or skipping it entirely) is where most production auth bugs are born.

SameSite: Strict means the browser sends the cookie only when the request originates from the exact same site. If a user clicks a link in an email or arrives from an external site and lands on your app, the cookie does not travel with that first request. This is the most secure setting, but it silently breaks OAuth redirect flows and any link-based login patterns.

SameSite: Lax is the sensible default. Cookies travel on same-site requests and on top-level navigations (clicking a link to your domain). But cross-origin fetch or XMLHttpRequest calls go out without the cookie attached.

SameSite: None sends the cookie with all requests, including cross-origin ones. This sounds convenient. There is one hard requirement, though: you must also set Secure: true. Without it, the browser ignores the cookie entirely. None is the only value that allows a backend on one origin to set cookies for a frontend on a different origin.

So if your API lives on api.example.com and your frontend lives on app.example.com, and you never set SameSite, modern browsers default it to Lax. Your cross-origin fetch calls go out with no cookie attached. The server sees unauthenticated requests and throws 401s. Everything looks fine in your server logs. Nothing is obviously wrong. Sounds like a massive headache, right?

Domain: The Address Printed on the Pass

The domain property tells the browser which hosts should receive the cookie. Set it to example.com and the browser sends the cookie to example.com and all its subdomains like api.example.com and app.example.com. Leave it unset, and the cookie is scoped to only the exact origin that created it.

Two mistakes here kill cookies every time.

The first is a stray character in the domain string. A trailing slash, an extra dot in the wrong place, or a typo causes the browser to silently discard the cookie. No error. No log. The cookie just disappears.

Now wait a minute: if the domain setting is this important, why doesn't the server warn you when it is wrong?

Because the cookie is set correctly on the server side. The problem happens at the browser level, where the browser receives the cookie and then rejects it silently. The server never learns that this happened. No log entry, no network error. That is why this bug is so disorienting to debug.

The second mistake is pointing domain at a deployment subdomain. And this is where the real trap is hiding.

The Catch: Supercookies and the Public Suffix List

Here is a scenario that trips up many developers shipping to staging environments for the first time.

A supercookie is a cookie with an origin of a top-level domain (such as .com) or a public suffix (such as .co.uk). Ordinary cookies, by contrast, have an origin of a specific domain name, such as example.com.

Let me explain it in simple words. Imagine your visitor pass had "entire Mumbai" printed as its authorized area instead of "Sector 7, Andheri West." A pass like that would theoretically grant access to every housing society in the city. The security system rejects it immediately because the claim is absurd and dangerous. That is exactly what a supercookie does: it asserts authority over an entire shared domain namespace, and browsers refuse it for the same reason. A cookie covering .railway.app could, in theory, leak into requests made to every other project hosted on Railway.

The obvious question here is: how does the browser know what qualifies as a "public suffix"?

That is where the Public Suffix List comes in. It is a cross-vendor, community-maintained registry of domain suffixes that operate as shared hosting namespaces. When a new cloud provider launches deployment subdomains, they submit their suffix (like .vercel.app, .railway.app, .netlify.app, .pages.dev) to this list. Browsers ship with a local copy of it. When your server tells the browser to set a cookie for a domain that appears on the Public Suffix List, the browser rejects the cookie entirely, regardless of how correctly everything else is configured. This is a browser security boundary with no override.

The fix is straightforward: add a custom domain to your project and point both your frontend and backend to subdomains of that same root domain. The Public Suffix List only blocks shared deployment suffixes. Your own domain is yours.

Let me share a very interesting problem I faced where this exact thing cost us nearly four days. We had a group project with the frontend on Vercel and the backend on Railway. We were setting the cookie domain to the Railway deployment subdomain for staging, and auth kept breaking. Users registered just fine, but every subsequent API call returned 401. We went through the codebase line by line. We re-read the auth logic repeatedly. We opened issues on the framework repository. The real problem was that *.railway.app is on the Public Suffix List, so every cookie the server set was silently rejected by the browser. We were debugging the wrong layer entirely. Once we pointed both services to subdomains of a custom domain we controlled, everything worked on the first try.

There was a second issue layered underneath it, too. The frontend on vercel.app and the backend on railway.app are two entirely different eTLD+1 domains. A cookie set by the backend cannot be sent to the frontend when they live under different root domains. Your frontend and backend need to share a root domain, not just share the same developer account.

DIY: A Central Cookie Config

The most practical lesson from all of this is to stop scattering cookie options across your codebase and consolidate them into one configuration object.

const cookieOptions: CookieOptions = {
  httpOnly: true,
  secure: true,
  sameSite: process.env.NODE_ENV !== 'production' ? 'none' : 'lax',
  maxAge: 10 * 60 * 1000,
  domain: process.env.NODE_ENV !== 'development'
    ? process.env.DOMAIN
    : undefined,
  path: '/',
};

The domain stays undefined in development, which correctly scopes the cookie to localhost. In staging and production, it reads from your environment variable pointing at your custom domain. SameSite flips to none in non-production environments because staging often involves cross-origin setups.

When you need to adjust a single cookie slightly, you spread this base config and override just the properties that differ:

res.cookie('sessionId', token, {
  ...cookieOptions,
  maxAge: 7 * 24 * 60 * 60 * 1000, // longer for "remember me"
});

One object to update. Every cookie in the codebase changes with it. No more hunting through ten different res.cookie() calls before a deployment.

Try implementing this! It is one thing to read about cookie configuration, but it is a whole different feeling when you set up your staging environment and auth just works on the first request.

The next natural step from here is understanding how cookies behave with HTTPS and CORS together, because those two layers interact with SameSite: None in ways that can catch you off guard all over again.

Happy Exploration!

Top comments (0)