A couple of Mondays ago, I walked into a gnarly login loop. You know the kind: user signs in, gets bounced to the dashboard, then wham—right back to the login page like nothing happened. I’ve seen countless versions of this, but this time the villain was subtle: cookie attributes. Specifically, a missing SameSite setting and a Secure flag that wasn’t actually secure because the app was sitting behind a proxy that wasn’t telling the framework it was HTTPS.
Ever had that moment when everything looks fine in code, yet the browser whispers “Nope” under its breath? That’s cookies. They’re tiny, but they have rules—and the browser enforces those rules at the door.
In this guide, we’ll talk about how to set SameSite=Lax/Strict, Secure, and HttpOnly the right way. We’ll do it on Nginx, on Apache, and inside your app code. We’ll look at why these flags exist, where they’ll save your skin, and where they can break your login flow if you’re not careful. I’ll share configs and examples I actually use, plus a playbook for migrating without disrupting your users.
İçindekiler
- 1 Why These Cookie Flags Matter (And When They Bite)
- 2 Picking the Right Defaults Without Overthinking It
- 3 Nginx: Setting and Fixing Cookie Flags at the Edge
- 4 Apache: The Headers That Save Your Day
- 5 Set It in Your App: Real Examples That Don’t Flake
- 6 Avoiding Common Traps and Odd Behaviors
- 7 Nginx/Apache vs. App Code: Where Should You Set Flags?
- 8 A Quick Migration Plan That Won’t Panic Your Users
- 9 Debugging: The Browser Is Telling You What’s Wrong
- 10 CORS, OAuth, and Cross-Site Reality
- 11 Hardening Tips That Pay Off for Years
- 12 A Note on Performance and Deliverability
- 13 Putting It All Together: A Friendly Checklist
- 14 Wrap‑Up: Calm Cookies, Happy Users
Why These Cookie Flags Matter (And When They Bite)
Think of cookies like VIP passes. They get your user past the rope and straight into the app, but only if the pass is legit and used in the right place. Browsers check the fine print—what we call attributes—before honoring a cookie. The big four for sessions and auth flows are Secure, HttpOnly, and SameSite (with Lax, Strict, or None as values).
Secure means “only send this cookie over HTTPS.” It’s non-negotiable for anything sensitive. If you’re on HTTP in production in 2025, that’s a whole other conversation—but assume Secure for auth, always. HttpOnly means JavaScript can’t read the cookie. That’s a huge shield against “steal the session via XSS” attacks. You still need to prevent XSS, but HttpOnly limits the blast radius if something slips through.
SameSite is the one that surprises teams most often. With Lax, your cookie will be sent on regular navigations (like clicking a link to your site) but not on cross-site POSTs or iframes. With Strict, the browser only sends the cookie when you’re already on the same site—it’s the paranoid option. And None means “treat it like the old days,” but only if you also add Secure. Modern browsers won’t honor SameSite=None without Secure. If you’re curious about the detailed semantics, the MDN Set-Cookie reference is an excellent, plain-English resource.
Here’s the thing: browsers changed the default. If you don’t specify SameSite, it behaves like Lax in most modern browsers. That’s a smart default for security, but it trips up OAuth and embedded app flows. When your login provider posts back to your site, you might be doing a cross-site POST or loading an iframe—Lax will refuse to send the cookie in those moments. That’s the login loop I met on Monday. If you want the longer story on the change itself, I like the clarity in SameSite cookies explained.
Picking the Right Defaults Without Overthinking It
Let me share the simple rule I use with teams: for session cookies, go Secure + HttpOnly + SameSite=Lax. Nine times out of ten, that’s exactly what you want. It protects you from casual CSRF by refusing cross-site POSTs while still letting a user click a link from an email and land in their account without hiccups. For admin panels or apps that never need to take traffic from other domains, I often go SameSite=Strict and call it a day—just know that sharing links around (like from chat) still works because it’s a top-level navigation, but some flows that rely on cross-origin embeds won’t.
When would you use SameSite=None? In two situations: cross-site single sign-on and embedded experiences. If your app lives inside an iframe on a different domain, or you’re doing OAuth with a provider that posts back from their domain, you need None—and you must include Secure. There’s no way around it. Some browsers also limit third-party cookies aggressively for privacy reasons, so for embedded apps, you may need to think beyond cookies (message passing, tokens scoped to the parent, or prompting the user to open in a new tab to establish first-party context).
One quick reminder: SameSite helps with CSRF, but it’s not a replacement for CSRF tokens on state-changing requests. Good defense stacks up, not swaps out. If you want a crisp refresher, the OWASP CSRF page is a great sanity check.
Nginx: Setting and Fixing Cookie Flags at the Edge
When your app already sets the right flags
If your app code is correct, great—Nginx can step back and chill. But I still like having guardrails at the edge, especially when I’m proxying legacy apps or third-party backends that I don’t fully control. One of the handiest tools is proxy_cookie_flags, which lets you force flags onto cookies coming from upstream.
http {
# ...
server {
listen 443 ssl http2;
server_name example.com;
# Enforce HTTPS for Secure cookies & general good hygiene
# (Consider HSTS once you're confident)
location / {
proxy_pass http://app;
# Add flags to specific cookies by name
proxy_cookie_flags session_id secure httponly samesite=lax;
# Or match multiple cookies with a regex
proxy_cookie_flags ~*^(__Secure-|__Host-|session|sid) secure httponly samesite=lax;
}
}
}
If you’re not sure which names your app uses, you can temporarily log Set-Cookie headers in a staging environment and then add a regex. The regex approach is my favorite because it catches variations (like “sid” vs “session_id”) without needing to micromanage names.
In older Nginx versions that don’t have proxy_cookie_flags, you can append attributes using a path rewrite. It’s a bit of a hack, but it’s reliable:
location / {
proxy_pass http://app;
# Append flags to every Set-Cookie by rewriting the Path attribute
proxy_cookie_path / "/; Secure; HttpOnly; SameSite=Lax";
}
Just be careful not to double-append. If your upstream already sets SameSite or Secure, you might end up with duplicates. Browsers usually pick the last occurrence, but I prefer to keep things tidy by standardizing upstream or using proxy_cookie_flags where available.
Sometimes you want a small edge cookie—maybe for a feature flag, a short-lived banner dismissal, or a routing hint. You can set it directly:
location = /welcome {
add_header Set-Cookie "welcome=1; Path=/; Secure; HttpOnly; SameSite=Lax" always;
try_files /welcome.html =404;
}
Two gotchas: first, don’t set cookies on responses that should be cached by a CDN unless you’ve got cache rules that respect personalization. Second, cookies are per-header; if you add multiple cookies, send multiple Set-Cookie headers—don’t jam them into one line.
And yes, all of this rides on having solid TLS. If you’re cleaning up certificates anyway, I’ve had great results with serving dual ECDSA and RSA certificates for both speed and compatibility. If you’re curious, I wrote about it here: serving Dual ECDSA + RSA Certificates on Nginx and Apache.
Apache: The Headers That Save Your Day
On Apache, the workhorse is mod_headers. You can append flags to Set-Cookie headers with a simple rule. It’s elegant when you need a quick, global fix, especially for apps you can’t edit.
<IfModule mod_headers.c>
# Only when HTTPS is on, to avoid Secure on HTTP
Header always edit Set-Cookie ^(.*)$ "$1; HttpOnly; Secure; SameSite=Lax" env=HTTPS
</IfModule>
If you worry about adding flags twice, you can tighten it up with a case-insensitive regex that tries to avoid cookies that already include attributes. It’s not perfect, but it helps in mixed environments:
<IfModule mod_headers.c>
# Append only when missing (best effort)
Header always edit Set-Cookie
"(?i)^((?!;s*httponly)(?!;s*secure)(?!;s*samesite).*)$"
"$1; HttpOnly; Secure; SameSite=Lax" env=HTTPS
</IfModule>
Inside a VirtualHost, keep it close to your app proxying to make it obvious:
<VirtualHost *:443>
ServerName example.com
ProxyPass / http://app/
ProxyPassReverse / http://app/
<IfModule mod_headers.c>
Header always edit Set-Cookie ^(.*)$ "$1; HttpOnly; Secure; SameSite=Lax" env=HTTPS
</IfModule>
</VirtualHost>
Just like with Nginx, I generally set flags at the source (the app) and use Apache as a safety net. It keeps your behavior consistent if you change reverse proxies or add a CDN later. If you’re running a load balancer in front, especially one that does sticky sessions with a routing cookie, make sure those cookies are also getting the right flags so your users don’t bounce around. I went deep on that in Zero‑Downtime HAProxy: sticky sessions that behave.
Set It in Your App: Real Examples That Don’t Flake
Node.js (Express)
Express makes this easy, but there’s one catch: if you’re behind a proxy (Nginx/Apache/ELB), tell Express to trust it so Secure cookies don’t get downgraded by mistake.
const express = require('express');
const app = express();
// Behind a reverse proxy (important for Secure)
app.set('trust proxy', 1);
app.get('/login', (req, res) => {
res.cookie('session_id', 'abc123', {
httpOnly: true,
secure: true, // requires HTTPS
sameSite: 'lax', // 'strict' or 'none' also valid
path: '/',
maxAge: 60 * 60 * 1000 // 1 hour
});
res.send('Logged in');
});
For OAuth callbacks that require cross-site context, set sameSite: 'none' and keep secure: true. If you do this only for the short-lived login state cookie (and keep your long session cookie at Lax), you get the best of both worlds.
PHP (native)
Modern PHP has a clean options array. Use it—no more string concatenation gymnastics.
setcookie('session_id', $value, [
'expires' => time() + 3600,
'path' => '/',
'domain' => 'example.com', // or omit for host-only
'secure' => true,
'httponly' => true,
'samesite' => 'Lax',
]);
If you’re writing a cookie that must go cross-site (like an OAuth state cookie), set 'samesite' => 'None' and keep 'secure' => true. For session libraries, mirror these settings in your php.ini or framework config so everything aligns.
Django
Django centralizes cookie policy nicely. Put it in settings, and most of your session and CSRF behavior just snaps into place.
# settings.py
SESSION_COOKIE_SECURE = True
SESSION_COOKIE_HTTPONLY = True
SESSION_COOKIE_SAMESITE = 'Lax' # 'Strict' or 'None' (with SECURE)
CSRF_COOKIE_SECURE = True
CSRF_COOKIE_SAMESITE = 'Lax'
If you need a custom cookie, set attributes explicitly when issuing it in a view. For cross-site SSO, consider using a short-lived cookie with SameSite=None just for the handshake.
Go (net/http)
Go gives you the keys, but you have to set every attribute yourself. Good news: it’s straightforward and explicit.
http.SetCookie(w, &http.Cookie{
Name: "session_id",
Value: value,
Path: "/",
Secure: true,
HttpOnly: true,
SameSite: http.SameSiteLaxMode, // StrictMode or NoneMode
MaxAge: 3600,
})
For SameSite=None, use http.SameSiteNoneMode and make sure Secure is true.
If you want to lock things down further, consider the __Host- and __Secure- name prefixes. __Host- requires the cookie to be set with Secure, Path=/, and without a Domain attribute—making it host-only and harder to spoof across subdomains. __Secure- requires Secure. They’re a quiet upgrade that pay off when you’re sharing infrastructure across subdomains.
Avoiding Common Traps and Odd Behaviors
I’ve lost hours to these, so you don’t have to. First, remember that SameSite=Lax still sends cookies on top-level navigations (like clicking a link). It blocks cross-site POSTs and most iframes, but it won’t protect a state-changing GET. So don’t put “delete account” behind a GET URL. Pair Lax with anti-CSRF tokens for POSTs and keep state changes off GET entirely.
Second, if you’re doing OAuth via a provider domain and your callback involves a POST or an iframe, SameSite=Lax might stop your cookie at the door. If you see a login loop only when coming from a particular identity provider, that’s your clue to set SameSite=None for the short-lived handshake cookie.
Third, if Secure is set but the cookie still doesn’t arrive, your app might not realize it’s behind HTTPS. Classic bug: Express or Rails thinks the request is HTTP because the reverse proxy didn’t set X-Forwarded-Proto: https or your framework isn’t configured to trust the proxy. Fix that, and suddenly Secure cookies behave.
Finally, don’t hand your CDN a personalized response to cache. If a response sets a cookie, be explicit with cache headers and vary rules. If you want to dive into safe, user-friendly caching patterns, I wrote about stale-while-revalidate and stale-if-error making caching feel effortless. Cookies and caching can get along; you just have to introduce them politely.
Nginx/Apache vs. App Code: Where Should You Set Flags?
My philosophy: set flags in your app where the cookie is created, then use your proxy (Nginx/Apache) as a seatbelt. The app knows the context—like which cookies need SameSite=None for SSO—and can be precise. Your proxy acts as a safety net for legacy paths, third-party apps you’re proxying, or the odd misconfiguration that sneaks in during a refactor.
There are times when the edge is the only realistic fix. I’ve had to patch marketing tools that drop cookies without HttpOnly by using Nginx’s proxy_cookie_flags. It’s not perfect, but it’s better than leaving a cookie readable to JavaScript when it doesn’t need to be. Just document these edge rules so you remember why they exist in six months.
A Quick Migration Plan That Won’t Panic Your Users
Migrations go smoother when you separate the cookies by purpose. First, identify your primary session cookie. Switch it to Secure + HttpOnly + SameSite=Lax and roll it out on a canary environment or a single region. Watch your error rates and login metrics. If your app supports multiple login providers, test each end-to-end.
Next, handle the special flows. If you’re doing OAuth or embedding your app, create a distinct, short-lived cookie for that handshake with SameSite=None + Secure. Don’t change your main session cookie to None unless you absolutely must. That keeps your day-to-day browsing protected while still allowing the specific cross-site move you need.
When names or scopes need to change (say, you’re switching from a subdomain cookie to a host-only one with __Host-), consider a new cookie name and a temporary compatibility read of the old cookie. You can then sunset the legacy cookie after a comfortable window—typically a few weeks, depending on your user base and session lifetimes. Remember that Expires and Max-Age influence persistence; explicit Max-Age is what I default to for predictability.
Debugging: The Browser Is Telling You What’s Wrong
The best tool for cookie debugging is the browser itself. Open DevTools, head to the Application/Storage tab, and look at your cookies for the site. You’ll see which attributes are actually set—and if the cookie is marked as “This cookie was blocked due to SameSite” on network requests, the browser will tell you in the console or Network panel.
I like inspecting the Network tab for the exact request that fails. Filter for your callback or POST, click the request, and check “Request Headers” to see whether the Cookie header includes what you expect. Then look at the response headers to confirm the Set-Cookie line. When I’m unsure whether a proxy is stripping or rewriting headers, a quick curl -I https://example.com/login in staging helps confirm what’s getting sent from the server versus what the browser chooses to store.
Also, verify the Domain and Path you set line up with where you need the cookie. If you omit Domain, the cookie is host-only—sometimes that’s good (tighter scope), sometimes not (your API is on a subdomain). A small but useful tweak is preferring host-only cookies plus __Host- where possible, and using domain-wide cookies only when you truly need them across subdomains.
CORS, OAuth, and Cross-Site Reality
There’s a dance between cookies and CORS. If your frontend on one domain calls your API on another and you want the browser to send cookies, your fetch/XHR must include credentials: 'include', your server must reply with Access-Control-Allow-Credentials: true, and your cookie likely needs SameSite=None + Secure depending on the exact flow. You also need explicit Access-Control-Allow-Origin to the calling domain—no wildcard with credentials.
For OAuth, I split cookies by purpose. The main session stays at SameSite=Lax. A separate, short-lived state cookie involved in the cross-site handshake uses SameSite=None + Secure. After the handshake, I re-issue the main session cookie as Lax and drop the state cookie. That keeps your daily browsing safer while still making SSO smooth.
And if you’re handling payments, compliance checklists care that session data stays confidential and transport is locked down. It’s all part of a bigger picture: encryption, key rotation, safe storage, and tight scoping. For the hosting side of that world, I’ve put together a practical guide here: PCI DSS for E‑Commerce, without the panic.
Hardening Tips That Pay Off for Years
A few habits have aged really well for me. First, make Secure + HttpOnly your non-negotiable baseline for any cookie that touches auth or session state. Second, prefer SameSite=Lax, and reserve SameSite=None for the few places that truly need it. Third, use __Host- for your main session cookie when feasible: it forces host-only scope, Secure, and Path=/, which shuts a lot of doors evil twins might try to slip through on sibling subdomains.
Fourth, don’t forget the TLS story behind Secure. The browser won’t send a Secure cookie over HTTP. If you’re cleaning up your HTTPS setup, you might enjoy my write-up on serving both ECDSA and RSA certs without drama. Fifth, be explicit about caching. If a response sets cookies or varies by cookie, mark it accordingly. I’ve seen “why am I seeing someone else’s cart?” bugs that were just an over-eager cache ignoring Set-Cookie and Vary.
Finally, document your cookie strategy. A simple comment at the top of your session middleware that says “Main session: Secure, HttpOnly, SameSite=Lax. OAuth state: Secure, SameSite=None, short-lived. Admin cookies: Strict.” has saved me more hours than any tool. People change jobs; README files don’t.
A Note on Performance and Deliverability
Cookies add bytes to every request to their scope. Don’t store anything heavy in them, and avoid setting them on top-level domains if the cookie isn’t relevant for all paths and subdomains. Small cookies, tight scope, and short lifetimes where possible—these little choices add up. If you’re routing traffic across balancers or doing canary checks, a tiny cookie for stickiness is fine. Just give it Secure and, where appropriate, HttpOnly. If you’re evolving your edge stack, you might like how cleanly HAProxy handles health checks and sticky sessions when you want full control.
Putting It All Together: A Friendly Checklist
I’m not a fan of rigid checklists, but this one has become muscle memory for me. First: sessions—Secure, HttpOnly, SameSite=Lax. Second: OAuth/SSO handshake—SameSite=None + Secure, short lifetime, then drop it. Third: admin-only flows—consider SameSite=Strict if you never embed or cross-post.
Fourth: set the right flags in your app, and use Nginx/Apache to enforce or patch as needed. Fifth: verify TLS is solid; if your app is behind a proxy, set trust headers correctly so frameworks recognize HTTPS. Sixth: test in real browsers—Chrome, Firefox, Safari—because their cookie policies can differ in just enough places to surprise you. If you need a deep-dive reference while testing, I keep the MDN Set-Cookie page pinned.
Wrap‑Up: Calm Cookies, Happy Users
I still remember the first time I flipped a cookie to SameSite=Strict on an admin tool and watched CSRF attempts just… stop. It felt like locking a door that had quietly been ajar for years. On the other hand, I’ve also watched a login loop drive a support team up the wall until we realized the callback needed SameSite=None. That’s cookies—small, mighty, and occasionally moody.
If you take nothing else from this, let it be this: make Secure + HttpOnly + SameSite=Lax your baseline, carve out SameSite=None only for the flows that truly need it, and let your reverse proxy help you enforce good behavior. Test your login and checkout flows end-to-end, including in incognito windows and with blocking extensions, and keep DevTools open to see what the browser is actually doing. The browser tells the truth; you just have to ask it the right way.
And if you’re cleaning up the rest of your stack while you’re here, you might enjoy a calm walk through PCI on the hosting side or a practical strategy for caching that doesn’t break personalization. Hope this was helpful! If it saves you from one midnight login loop, I’ll call that a win. See you in the next post.
