
A server-side session is a random id in a cookie and a row you can delete.
That is still the right default for a first-party browser app. express-session will happily use a default cookie name and flags that are wrong for production if you do not set them. JWT and ‘stateless auth’ are for the case where another process must verify the credential without that row.
The usual mistake is leaving connect.sid on HTTP, or rotating nothing after login, then blaming the library when the id is replayed.
This page is how to store the id, which cookie flags belong in 2026, and how logout actually ends the session.
express-session 1.19.0 last updated on 22 January 2026. The default cookie name is still connect.sid. cookie.sameSite still defaults to false, which means the attribute is omitted. That is the install line most Express tutorials still paste.
The control is a host-bound cookie, an explicit SameSite, a new id at login, and a server row you can delete.
Pair this page with CSRF for the request the browser did not mean, XSS for the script that rides a live cookie, and JWT in Express when you actually need a signed blob. Login UX and password storage sit on secure authentication.
Four belts sit on one __Host-session cookie. The Host prefix refuses Domain. HttpOnly hides the id from script. Lax keeps a foreign POST from attaching it. regenerate drops the pre-auth id at login.
SecureCoding
connect.sid is not a session design
express-session npm page for 1.19.0. name defaults to connect.sid. cookie.httpOnly defaults to true. cookie.path defaults to /. cookie.secure defaults to false. cookie.sameSite defaults to false. Chrome will treat a missing SameSite as Lax for most people, but MDN still documents a two-minute window where a cookie that inherited Lax as the default is also sent on POST. Set the attribute yourself.
A session is a server row plus a random id the browser holds. The cookie is only the id. The row holds userId, issued-at, idle deadline, and absolute deadline. Delete the row and the id is junk. That is logout, password change, and “end every device.”
import session from "express-session";
import RedisStore from "connect-redis";
app.set("trust proxy", 1);
app.use(session({
name: "__Host-session",
secret: process.env.SESSION_SECRET,
store: new RedisStore({ client: redis }),
resave: false,
saveUninitialized: false,
cookie: {
httpOnly: true,
secure: true,
sameSite: "lax",
path: "/",
maxAge: 12 * 60 * 60 * 1000,
},
}));
Identifiers on this page stay __Host-session and sid. Do not also set Domain. A Host-prefix cookie that includes Domain is rejected. saveUninitialized: false so a hit on /login does not mint a row you later promote. The in-process MemoryStore is the development default. express-session’s own readme says do not use it in production. A process restart logs everyone out, and two Node processes do not share the map. Redis or Postgres is the store this page means.
rolling: true refreshes maxAge on every response. That is idle timeout if you also check createdAt for an absolute cap. Rolling without an absolute cap is a session that never dies while the tab stays open. Twelve hours from login is the cap used above. Idle can be shorter: 30 minutes of no request, then 401.
__Host- is the origin lock SameSite is not
MDN’s Set-Cookie page (Cookie prefixes section) is the contract. A name that starts with __Host- must be set from HTTPS with Secure, must have Path=/, and must omit Domain. The browser then sends it only to the host that set it. A docs host, a CDN host, and a customer subdomain cannot plant that name on you.
__Secure- only requires Secure. It still accepts Domain=example.com. That is the weaker prefix. Prefer __Host-session.
MDN also documents __Http- and __Host-Http-: the cookie must be set by Set-Cookie, not by document.cookie. Support is newer than __Host-. Do not assume every engine you ship to honors __Host-Http- in August 2026. Use __Host- plus httpOnly: true today. Treat the Http prefixes as extra friction when your access logs show they survive.
SameSite is a site boundary. https://app.example.com and https://cdn.example.com are same-site. Lax and Strict both send the cookie on a request from the sibling. The Host prefix is the flag that makes the cookie host-only. Gitpod’s 2024 cookie-toss writeup (CVE-2024-21583, fix shipped 1 July 2024) was this gap: the session name had no Host prefix, so a workspace host could write it for the control plane.
Lax versus Strict
MDN: a Lax cookie is sent on a cross-site request only when the request is a top-level navigation and the method is safe (GET, HEAD, OPTIONS). fetch(), a subframe, and an image do not get it. A click from another site onto yours does.
Strict withholds the cookie on that inbound click too. The user lands logged out, then logged in after a same-site navigation. That is the right call for a bank console or an admin host. It is a product fight for a marketing site that deep-links into an authenticated view. Most product apps want Lax plus the CSRF check on POST, PUT, PATCH, and DELETE.
Do not pick SameSite=None unless a real cross-site embed must carry the cookie, and then you inherited the whole CSRF page. None requires Secure. Partitioned (CHIPS) is for the third-party embed case. It is not a first-party session design.
Regenerate on login
Session fixation is the pre-auth id becoming the post-auth id. A sibling, a shared computer, or a planted connect.sid should not become “logged in as you” when you type a password. express-session documents req.session.regenerate for this. The docs call it good practice to help prevent fixation. Run it after you trust the password, then write userId onto the new row.
app.post("/login", async (req, res) => {
const user = await verifyPassword(req.body.email, req.body.password);
if (!user) {
res.status(401).send("Unauthorized");
return;
}
const { email } = user;
req.session.regenerate((err) => {
if (err) {
res.status(500).send("Unavailable");
return;
}
req.session.userId = user.id;
req.session.createdAt = Date.now();
req.session.save((saveErr) => {
if (saveErr) {
res.status(500).send("Unavailable");
return;
}
res.redirect("/app");
});
});
});
Destroy on logout. Rotate again on password change and on privilege change. Idle timeout is a sliding maxAge or a lastSeen you check. Absolute timeout is createdAt plus 12 hours, even if the user keeps clicking. Those two clocks are different jobs. Privilege change without regenerate leaves the old row holding the old role until the next login. Write the new role onto a new id.
Server store versus a JWT
A random sid in __Host-session plus a Redis or Postgres row is the default for a browser app in 2026. Logout is DEL. A stolen id dies when you delete the row. You can show “16 devices” and kill one.
A JWT in a cookie is still a cookie. You inherited CSRF. You did not inherit logout. The token is valid until exp unless you also keep a jti denylist, which is a session store with extra steps. The JWT page is blunt: most Express cookie apps do not need a JWT. Reach for jose when another origin must verify without your Redis.
A JWT in localStorage is worse. It is a portable credential for the whole TTL. XSS reads it and leaves. An HttpOnly cookie cannot be read by script. The script can still call your API while the tab is open. That is the XSS page, not a reason to move the token into JavaScript storage.
Rotate SESSION_SECRET with an array if the library lets you verify the old signature while you mint with the new one. express-session accepts secret as a string or an array. Put the new secret first. A single env string you never rotate is a year of cookies you cannot invalidate without flushing the store. Flushing the store is still the kill switch you want for a breach.
Chrome 145 can bind a first-party cookie to a TPM key with Device Bound Session Credentials. Windows announcement. Header-borne Bearer tokens are outside that ceremony. Do not wait for DBSC to set __Host-session. Treat macOS as not GA there.
Sibling cookie fixation
hdhzy’s point is the sibling, not the foreign site. If docs.example.com is on your eTLD+1 and it can run script, it can document.cookie = "connect.sid=planted; Domain=example.com; Path=/" unless the real name is host-prefixed. The login handler that skips regenerate then attaches your userId to the planted id. The attacker already knows the id. They use it from their browser.
Defenses that stack:
- Name the cookie
__Host-session. The plant ofconnect.sidis a different name. Ignore it. - Regenerate after password check so even a plant of the real name dies.
- Do not share an eTLD+1 with user-controlled hosts. Cookie tossing on
*.yourapp.comworkspaces is the Gitpod shape. - Treat a sibling XSS as session plus CSRF. Lax will send the cookie to the sibling on same-site POST.
The CSRF page is the request check: Sec-Fetch-Site first, fail closed when the header is missing, deny same-site on mutations unless you have listed every hostname. This page is the cookie that check rides on.
Prove the cookie flags
You are not stealing a session. You are proving your own Set-Cookie line.
- Log into your own app. Open DevTools, Application, Cookies.
- Expect name
__Host-session,HttpOnly,Secure,SameSite=LaxorStrict, Path/, no Domain column. - Copy as cURL a state-changing request you already make. Replay it against your origin with
Sec-Fetch-Site: cross-site. Expect 403. A 200 means CSRF is missing. - After logout, replay the same cookie. Expect 401. A 200 means the row survived.
curl -sS -D - -o /dev/null -X POST "https://your-app.example/account/email" \
-H "Cookie: __Host-session=PASTE_FROM_YOUR_DEVTOOLS" \
-H "Sec-Fetch-Site: cross-site" \
-H "Content-Type: application/x-www-form-urlencoded" \
--data "email=you@your-app.example"
# Expect: HTTP/2 403
rg -n "connect\\.sid|cookie\\.sameSite|__Host-session|session\\.regenerate" \
--glob '!node_modules'
A hit on connect.sid or a missing regenerate next to /login is the review. A sameSite: "auto" that sets None on HTTPS is a SAML leftover. Do not leave it on a first-party app.
Questions we keep getting
Should the session cookie be Strict?
Use Strict on an admin host or a money-movement host where an inbound link should not carry the cookie. Use Lax on a product app that deep-links into a signed-in view, and put the CSRF check on every mutation. Do not leave SameSite unset.
Can I put the session id in Authorization instead?
Yes, and the browser will not attach it. You traded CSRF for a storage problem: the script must hold the secret. XSS then exfiltrates it. Prefer the cookie for a first-party browser app. Use a Bearer token for a native client or a service that is not a browser.
Does regenerate break “remember me”?
No. Remember-me is a second, longer-lived secret in __Host-remember, rotated, hashed in the store, and exchanged for a new __Host-session after regenerate. Do not stretch maxAge on the session cookie to thirty days to fake it.



