What this usually means
The session middleware is running but the session ID cookie isn't being sent back by the client, or the server is rejecting the cookie. Common causes: wrong `sameSite` or `secure` settings, CORS cross-origin requests, secret rotation that invalidates all sessions, or the session middleware registered after routes that modify the session. Also check if the session store (default MemoryStore) is being wiped by restarts or scaling across multiple processes.
The first ten minutes — establish facts before touching code.
- 1Check if `req.sessionID` changes between requests: log it in two consecutive handlers. If it changes, the cookie is not being sent back.
- 2Inspect the Set-Cookie header on the first response. Verify `httpOnly`, `secure`, `sameSite`, and `path` match your client's origin.
- 3Open browser DevTools > Application > Cookies. Confirm the session cookie exists and has the correct domain/path.
- 4If using a custom store (Redis, DB), verify the store is reachable and the session data is actually saved after `req.session.save()`.
- 5Check if your app uses `app.set('trust proxy', ...)` when behind a reverse proxy like Nginx or Cloudflare.
- 6Search your codebase for any call to `req.session.regenerate()` or `req.session.destroy()` that might be triggered inadvertently.
The specific files, logs, configs, and dashboards that usually own this bug.
- searchThe file where you configure `express-session` (usually `app.js` or `server.js`)
- searchBrowser DevTools Application tab -> Cookies for the site
- searchNetwork tab: compare request headers for Cookie presence
- searchSession store connection status (e.g., Redis `PING` or DB query logs)
- searchReverse proxy config (Nginx `proxy_set_header` directives, Cloudflare SSL/TLS settings)
- searchApplication logs for any session store errors (e.g., `connect-redis` emits errors as warnings)
Practical causes, not theory. These are the things you will actually find.
- warning`sameSite: 'strict'` on modern browsers blocks cross-origin requests from sending the cookie
- warning`secure: true` when the app is served over HTTP (not HTTPS) — cookie is never set
- warning`secret` changed between server restarts (MemoryStore uses secret to sign, but any store loses sessions on secret change)
- warningSession middleware registered after a route that calls `req.session.save()` — the save never happens because middleware hasn't run
- warningCORS configuration missing `credentials: true` and `Access-Control-Allow-Origin` with explicit origin (not '*')
- warning`res.cookie()` or `res.clearCookie()` accidentally overwrites the session cookie
Concrete fix directions. Pick the one that matches your root cause.
- buildSet `sameSite: 'lax'` or `'none'` (with `secure: true`) for cross-origin requests
- buildUse a persistent session store like `connect-redis` or `connect-mongo` to survive restarts
- buildPin your `secret` in environment variables and never change it without a migration plan
- buildPlace session middleware before any routes that need session access
- buildFor CORS: use `cors({ origin: 'https://yourfrontend.com', credentials: true })` and ensure the response includes the proper headers
- buildIf behind a proxy, set `app.set('trust proxy', 1)` so `req.secure` is correct for `secure: true`
A fix you cannot prove is a guess. Close the loop.
- verifiedAfter fix, set a session value in one route, then call another route and log the value — it should persist
- verifiedClear browser cookies, reload, and confirm a new session is created with a consistent ID across requests
- verifiedCheck the session store (e.g., Redis `KEYS sess:*`) to see entries being created and updated
- verifiedUse `curl` with `--cookie-jar` to simulate a session: first request gets a cookie, second sends it back
- verifiedRun a stress test with multiple concurrent users and verify no session collisions or data loss
- verifiedRestart the server and confirm that existing sessions survive (if using a persistent store)
Things that make this bug worse or harder to find.
- warningNever use `sameSite: 'strict'` without understanding the implications for cross-origin requests from your frontend
- warningDon't use the default MemoryStore in production — it leaks memory and loses sessions on restart
- warningAvoid changing the secret without invalidating old sessions gracefully (e.g., use a key rotation mechanism)
- warningDo not set `secure: true` if your app might be accessed over HTTP in any environment (development, internal network)
- warningDon't assume `req.session` is available before the session middleware has run — check middleware order
- warningNever ignore warnings from your session store (e.g., Redis connection failures) — they silently break persistence
The Phantom Session Loss Behind an Nginx Reverse Proxy
Timeline
- 10:15Deploy new feature that calls an internal API from frontend
- 10:30User reports that after login, any subsequent request fails with 401
- 10:45Check logs: req.session.user is undefined despite login setting it
- 11:00Inspect browser cookies: session cookie exists but not sent in XHR requests
- 11:15Notice XHR requests have Origin: https://app.example.com, but cookie domain is example.com
- 11:30Discover sameSite: 'strict' in session config blocks cross-origin cookie sending
- 11:45Change to sameSite: 'lax' and add CORS credentials: true
- 12:00Deploy fix, verify session persists across all requests
We had a solid session setup: Redis store, secure cookies, everything worked in staging. Then we added a new microservice endpoint that the frontend called directly. Within minutes, users reported being logged out after any action. I checked the server logs — every request had a new session ID. The browser was getting a Set-Cookie header, but the cookie wasn't being sent back.
I opened DevTools and saw the session cookie in the Application tab, but under Network tab the XHR requests had no Cookie header. The cookie had SameSite=Strict. Our frontend runs on app.example.com, but the API is on api.example.com — that's a cross-site request. Strict same-site blocks cookies on cross-origin requests. Worse, we had CORS setup but with Access-Control-Allow-Origin: * — which doesn't allow credentials.
The fix was two-fold: change sameSite to 'lax' (or 'none' with secure) and set CORS to explicit origin with credentials: true. I also added trust proxy because Cloudflare terminates SSL. After deployment, session persisted. The lesson: sameSite is not a set-and-forget; it must match your architecture's cross-origin needs.
Root cause
sameSite: 'strict' on session cookie prevented the browser from sending the cookie on cross-origin XHR requests, combined with CORS configuration that didn't allow credentials.
The fix
Changed session sameSite to 'lax', updated CORS to use explicit origin and credentials: true, and set app.set('trust proxy', 1) for proper secure flag detection behind Cloudflare.
The lesson
Always verify cookie behavior in the actual deployment environment with cross-origin requests. Test with browser DevTools and curl to see what the client actually sends.
express-session works by generating a unique session ID, storing it in a cookie on the client (default name 'connect.sid'), and keeping the session data server-side in a store. On each request, the middleware reads the cookie, looks up the session in the store, and attaches it to `req.session`. If the cookie is missing or the store returns null, a new session is created.
The `secret` option is used to sign the session ID cookie (via `cookie-signature`). If the secret changes, the signature verification fails, and the session is treated as invalid — effectively a new session. This is a common pitfall when deploying with a new secret or when using multiple servers with different secrets.
Modern browsers enforce SameSite cookie restrictions. If your frontend is on a different origin than your backend, and you use `sameSite: 'strict'`, the browser will not send the session cookie on cross-origin requests. This includes most XHR/fetch calls unless you use `credentials: 'include'` and the server responds with proper CORS headers.
The fix is to set `sameSite: 'lax'` for most cases, or `'none'` with `secure: true` if you need cross-site. But note: `sameSite: 'none'` requires the `secure` flag, so your site must be served over HTTPS. Also, ensure your CORS middleware sets `Access-Control-Allow-Origin` to an explicit origin (not '*') and `Access-Control-Allow-Credentials: true`.
The default MemoryStore stores sessions in a JavaScript object in the same process. It does not persist across server restarts, and it leaks memory over time because expired sessions are not cleaned up (unless you manually set a `ttl`). In production, always use a shared store like Redis, MongoDB, or a database.
When you restart your server (even for a deployment), all sessions in MemoryStore are lost. Users will be logged out. Additionally, if you run multiple Node.js processes (e.g., with PM2 cluster mode), each process has its own MemoryStore, so session data is not shared — requests might hit different processes and lose session state.
Frequently asked questions
Why does `req.session` exist but my data is gone on the next request?
This usually means the session was saved but the cookie wasn't sent back. Check the Set-Cookie header and browser cookies. Also ensure you're not calling `req.session.regenerate()` or `req.session.destroy()` inadvertently. Another possibility: the session store is not persisting (e.g., Redis connection dropped) so the session data is lost between requests.
Can I use multiple secrets for rolling rotation?
Yes. The `secret` option can be an array of secrets. The first one is used to sign new cookies, but all secrets are tried when verifying incoming cookies. This allows you to rotate secrets without invalidating all existing sessions. For example: `secret: ['new-secret', 'old-secret']`. Rotate by adding a new secret at the front and removing the old one after a grace period.
My session works locally but not on production behind a load balancer. What gives?
Two common issues: 1) The load balancer might be terminating SSL, so your app receives HTTP requests. If you have `secure: true`, the cookie won't be set because `req.secure` is false. Use `app.set('trust proxy', 1)` (or your proxy count) so Express trusts the X-Forwarded-Proto header. 2) If you're using MemoryStore, sessions are not shared between processes. Switch to a shared store like Redis.
How do I see what the session cookie looks like?
In your browser's DevTools, go to the Application tab, select Cookies under Storage, and find the cookie named 'connect.sid' (or whatever you named it). Inspect its properties: Name, Value (the signed session ID), Domain, Path, Expires, HttpOnly, Secure, SameSite. For the request headers, use the Network tab, click on a request, and look for the Cookie header under Request Headers.
Why does clearing my browser cookies fix the session issue?
Clearing cookies removes a potentially corrupted or misconfigured session cookie. After clearing, the server will issue a new cookie on the next request. This is a workaround, not a fix. The root cause is likely a mismatch between cookie settings (e.g., domain, path, secure) and the actual request context.