How to Set SameSite on Your Cookies

Deciding what SameSite should be is quick. Shipping it across a real application — every framework default, every third-party script, every embedded flow — is the part that breaks things. This is the migration, in the order that keeps logins working.


This is the implementation guide. If you want the reasoning — what "site" means, why None needs Secure, how the three values differ — read SameSite cookies explained first. What follows assumes you have decided and now have to ship it across an application that already has cookies in production.

Changing SameSite does not invalidate cookiesThe browser keeps the stored cookie and simply applies the new rule from the next response onward. Nobody is logged out by this migration. The visible change is which cross-site requests carry the cookie — so the risk is broken embeds and logged-out-looking landing pages, not lost sessions.

Step 1 — List Every Cookie and Who Needs It Cross-Site

The decision is per cookie, so the migration starts with an inventory. For each cookie you need one fact: does any legitimate flow send it from a page on a different site?

bash
# Everything set on a single response curl -sI https://example.com | grep -i '^set-cookie:' # Login flows usually set the session on a redirect curl -sIL https://example.com/login | grep -i '^set-cookie:'

Then walk the flows that actually cross sites — SSO redirects, hosted checkout, an embedded widget on a partner page, a payment provider returning by POST — with DevTools open on the Network tab. Any cookie that a request in one of those flows depends on is a None candidate. Everything else is not.

CookieSent cross-site by design?Value
Session / authNoStrict
CSRF tokenNoStrict
Login hint, UI stateOn inbound link clicks onlyLax
Locale, theme, consentNoLax
Widget state in a partner iframeYesNone + Secure
Cookie a provider POSTs back toYesNone + Secure

Step 2 — Set a Global Default of Lax

Start by making the framework emit an explicit Lax on everything. It matches the browser default, so nothing changes behaviourally, and it converts "no attribute" into a written decision across the whole application in one commit.

javascript
// Express — session middleware app.set('trust proxy', 1); app.use(session({ secret: process.env.SESSION_SECRET, cookie: { sameSite: 'lax', secure: true, httpOnly: true, path: '/' }, })); // Individual cookies res.cookie('locale', 'en-GB', { sameSite: 'lax', secure: true, path: '/' });
python
# Django settings.py SESSION_COOKIE_SAMESITE = 'Lax' CSRF_COOKIE_SAMESITE = 'Lax' SESSION_COOKIE_SECURE = True CSRF_COOKIE_SECURE = True # Flask app.config.update( SESSION_COOKIE_SAMESITE='Lax', SESSION_COOKIE_SECURE=True, SESSION_COOKIE_HTTPONLY=True, )
php
// PHP 7.3+ — session cookie parameters session_set_cookie_params([ 'path' => '/', 'secure' => true, 'httponly' => true, 'samesite' => 'Lax', ]); session_start(); // Or globally in php.ini // session.cookie_samesite = "Lax"
Check the framework default before you assumeDefaults differ and change between versions — some frameworks emit no attribute, some emit Lax, and a few still emit None for historical reasons. Do not infer it from documentation; read the actual Set-Cookie header from a running instance.

Step 3 — Override the Cross-Site Cookies to None

Now handle the exceptions from your inventory. Two rules make this safe: scope None to the specific cookie that needs it, and never apply it to a session or CSRF cookie.

javascript
// Only this cookie crosses sites. Secure is mandatory, not optional. res.cookie('widget_state', state, { path: '/', secure: true, sameSite: 'none', maxAge: 86400 * 1000, });

The failure mode here is silent, so verify each one immediately. A None cookie sent without Secure is rejected outright — not downgraded — and nothing is logged server-side:

bash
curl -sI https://example.com/widget | grep -i '^set-cookie:' # Every line containing SameSite=None must also contain Secure

Step 4 — Move Session Cookies to Strict, With a Companion

Strict is the right value for a session cookie and it has one visible cost: a user arriving from an email, a search result or a chat link sends no cookie on that first request, so the landing page renders logged out. The second request — now same-site — carries the cookie normally.

The fix is not to weaken the session cookie. Add a second cookie that carries no credential and says only that a session exists:

javascript
// The credential: never sent cross-site res.cookie('__Host-session', sid, { path: '/', secure: true, httpOnly: true, sameSite: 'strict', }); // The hint: safe to send on an inbound link click. No session data in it. res.cookie('signed_in', '1', { path: '/', secure: true, sameSite: 'lax', maxAge: 7 * 86400 * 1000, });

The landing page reads signed_in, renders the logged-in shell, and the real session arrives on the next request. Clear both on logout.

Keep the hint worthlessThe companion cookie must not be usable for anything. No user ID, no email, no token — a bare boolean. If it ever carries something an attacker would want, you have recreated the problem Strict was solving.

Step 5 — Handle Cookies You Do Not Set

Tag managers, chat widgets and embedded players write cookies on your domain, and they appear in any audit as yours. When you cannot change the code, stamp the attribute at the edge:

nginx
# nginx 1.19.3+ — applies to every cookie from this upstream proxy_cookie_flags ~ samesite=lax secure; # Target one cookie by name instead of all of them proxy_cookie_flags _widget samesite=none secure;
apache
# Append SameSite only to cookies that do not already declare it Header always edit Set-Cookie ^((?!.*SameSite).*)$ "$1; SameSite=Lax"

Treat this as a stopgap. A blanket rule applies to every cookie from that upstream, so a widget that genuinely needs None breaks, and the rule is easy to forget when the upstream is later replaced. Prefer updating or removing the script.

Step 6 — Test the Flows That Actually Cross Sites

Same-site testing proves nothing here, because same-site requests carry the cookie under every value. Each of these has to be exercised from a genuinely different site:

FlowHow to testBreaks if
Inbound link while signed inClick a link to your site from another domainSession is Strict with no Lax companion
SSO / OAuth redirectFull sign-in through the identity providerThe state cookie is Strict or Lax
Hosted checkout returning by POSTComplete a test payment end to endThe return cookie is not None; Secure
Your page in a partner iframeLoad the embed from another originThe widget cookie is not None; Secure
Cross-origin fetchCall your API from another site with credentialsThe cookie is Lax or Strict
Any of the above in SafariRepeat with default ITP settingsThe flow depends on a third-party cookie at all

Watch the Network panel while you do it. Chrome flags cookies it dropped and names the reason, which is far faster than inferring the cause from a broken page.

Step 7 — Verify What Shipped, and Keep Verifying

Check the deployed site rather than localhost — the proxy, the environment variable and the TLS termination all differ, and a value that is right locally is routinely wrong in production.

bash
curl -sIL https://example.com/login | grep -i '^set-cookie:'

Then re-run a cookie audit on each release. Values drift: a dependency upgrade changes a default, someone adds None to fix one embed and applies it globally, a new script arrives with its own cookies. A check that ran once at rollout says nothing about the state six months later.

Troubleshooting

SymptomCauseFix
Cookie never stored, no error anywhereSameSite=None without SecureAdd Secure; it is mandatory, not advisory
Landing page looks logged out on first clickSession cookie is StrictAdd the Lax companion cookie
SSO redirect loops foreverState cookie not sent on the cross-site returnSet that cookie to None; Secure
Embed works for you, not for customersThird-party cookies blocked, not a SameSite issueMove the cookie to the top-level site
Attribute missing from the header entirelyTypo in the value, so the browser ignored itOnly Strict, Lax, None are valid
Correct locally, wrong in productionA proxy or CDN is rewriting Set-CookieCheck proxy_cookie_flags and edge rules
Works in Chrome, fails in SafariITP blocking the third-party cookieRedesign the flow to stay first-party
Verify the values you shippedRead the Set-Cookie headers your deployed site returns and see the SameSite value on every cookie.
Run a check

Frequently Asked Questions

1. Do I have to set SameSite if the browser already defaults to Lax?

Functionally no, for cookies that want Lax. Practically yes: an absent attribute is indistinguishable from an unconsidered one in an audit, older embedded webviews still apply the pre-2020 behaviour, and a written value survives a change of browser default. It costs nine characters per cookie.

2. My framework has a global SameSite setting. Can I just use that?

Use it to set the floor, not the answer. A single global value forces every cookie to the same policy, which means either your session cookie is weaker than it should be or your embedded widget is broken. Set the global default to Lax, then override the session and CSRF cookies to Strict and the specific cross-site cookies to None.

3. How do I roll out Strict without logging everyone out?

Changing the SameSite value does not invalidate the cookie — the browser keeps storing it, and it is still sent on same-site requests. Nobody is logged out. What changes is the first render after an inbound external link, which is why the Lax companion cookie in Step 4 exists.

4. Chrome DevTools warns about a cookie in a third-party context. What is it telling me?

That the cookie was sent or set in a cross-site context and is affected by browser third-party cookie policy. Adding SameSite=None; Secure silences the SameSite half of the warning but does not exempt the cookie from partitioning or blocking — those are a separate layer that None cannot override.

5. Does SameSite=None still work if I need cross-site cookies?

It works where third-party cookies are still permitted, and not where they are blocked or partitioned — Safari blocks them via ITP, Firefox partitions them, and Chrome restricts them. If you own both ends, the durable fix is to move the cookie onto the top-level site rather than to keep relying on third-party delivery.

6. How do I confirm the values that actually shipped?

Run the deployed page through the Cookie Security Checker — it reports the SameSite value on every cookie the response sets, including ones from third-party scripts. Check the deployed site rather than localhost, because proxy configuration and TLS termination differ.

Related Articles