What session authentication is
Session authentication is the oldest and still the most common way to keep a user logged in on the web. The idea is small: the server remembers you, and the browser holds a receipt.
When you log in, the server verifies your credentials and creates a session — a record it stores itself. That record might contain your user id, the time it was created, when it expires, and any state such as a shopping cart. The server then sends your browser a session id: a long random string that identifies that one record. The browser stores the id in a cookie and sends it back on every request. Middleware on the server reads the id, loads the record, and now knows who you are.
The defining property is that the browser holds an opaque identifier, not your identity. It is a claim check, not the coat. If the id leaks, an attacker can impersonate you until you or the server invalidates the record — but the id itself reveals nothing, cannot be decoded, and cannot be edited to grant extra permissions. Everything meaningful lives behind the server’s lookup.
This is the opposite of a self-contained token such as a JWT, where the credential carries signed claims and the server trusts them without a database round trip. Session auth chooses a lookup in exchange for control. That trade is the subject of this guide.
The session lifecycle
Every session follows the same arc: created at login, carried by a cookie, loaded per request, and destroyed at logout.
Creation. A successful POST /login is the only place a session should be born. Generate a cryptographically random id — at least 128 bits from a CSPRNG, never a counter or a guessable value — and store a record keyed by it. Set an expiry. If the app supports “remember me”, choose a longer lifetime deliberately rather than by accident.
Transport. Send the id with a Set-Cookie header. The HttpOnly flag keeps it away from JavaScript, Secure restricts it to HTTPS, and SameSite limits when it is attached to cross-site requests. Without these flags the session id is exposed to XSS and network sniffing.
Lookup. On each subsequent request, middleware reads the cookie, looks the id up in the store, and either attaches the user to the request or rejects the request. A missing or expired id means an anonymous request, not an error, so public pages keep working.
Destruction. Logout deletes the record from the store and clears the cookie. Deleting the record is what actually ends the session; clearing the cookie is a courtesy that stops the browser from sending a dead id. A server-side expiry job should also sweep abandoned sessions so the store does not grow forever.
The lifecycle is deliberately boring, and that is a feature. There is one place that creates sessions, one place that loads them, and one place that destroys them — easy to audit and easy to test.
Passwords are the first gate
A session can only be as trustworthy as the login that created it, so password storage deserves a moment before the cookie ever appears.
Never store a password. Store a slow, salted hash produced by a purpose-built algorithm such as Argon2id, bcrypt or scrypt. These are deliberately expensive, which turns a database leak from an instant credential dump into a long, costly cracking job. A general-purpose hash such as SHA-256 is the wrong tool: it is fast, which is exactly what an attacker wants.
import argon2 from "argon2";
export async function hashPassword(password: string): Promise<string> {
return argon2.hash(password, { type: argon2.argon2id });
}
export async function verifyPassword(
password: string,
hash: string
): Promise<boolean> {
try {
return await argon2.verify(hash, password);
} catch {
return false;
}
}
Verification must be constant-time or, better, delegated to the algorithm’s own comparison so that timing does not leak information. Most importantly, return the same error for an unknown email and a wrong password. Saying “no such user” hands an attacker a free account-enumeration oracle. A generic invalid_credentials response, with a comparable amount of work performed in both cases, tells them nothing.
A successful login is also the moment to think about rate limiting, lockout after repeated failures, and multi-factor challenges. All of those happen before the session record is created.
What to keep in a session
A session record should be a pointer, not a photocopy. The temptation to stuff the whole user object into it is strong and usually a mistake.
Store the user id and let the request load the rest. If you cache the user for performance, give it a short lifetime and invalidate on change, because a stale copy in a long-lived session produces bugs that are hard to reproduce: an admin who was demoted keeps their old role until they log in again.
Reasonable things to keep server-side include the user id, a role or tenant id when it is small and changes rarely, a CSRF token, a “remember me” flag, and short-lived UI state such as a flash message or a post-login redirect target. Avoid storing large objects, secrets, tokens for other services, or anything you would not want to appear in a debugging dump.
declare module "express-session" {
interface SessionData {
userId: string;
tenantId: string;
csrfToken: string;
flash?: { type: "info" | "error"; message: string };
}
}
If the session payload grows past a few kilobytes, it is a sign that the data belongs in the database, keyed by the user, and loaded on demand. Small sessions are faster to serialise, cheaper to store and easier to reason about.
Building the middleware stack
Session handling is best expressed as a small, ordered chain: parse the cookie, load the session, attach the user, then guard protected routes. Each piece does one job, which makes the whole thing testable.
import cookieParser from "cookie-parser";
import session from "express-session";
app.use(cookieParser());
app.use(
session({
name: "__Host-sid",
secret: process.env.SESSION_SECRET!,
store,
resave: false, // do not rewrite unchanged sessions
saveUninitialized: false, // do not create a session for anonymous visitors
cookie: {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
path: "/",
maxAge: 1000 * 60 * 60 * 12,
},
})
);
app.use(loadUser()); // attach req.user when a session exists
app.use(csrf()); // issue and verify CSRF tokens
app.use("/api", routes); // handlers can call requireAuth() themselves
Two options cause most of the confusion. resave: false stops the middleware from writing the session back to the store on every request even when nothing changed, which saves a round trip. saveUninitialized: false stops it from creating a session for every anonymous visitor, which prevents the store filling with empty records and avoids setting a cookie before the user has done anything. Both are the settings you almost always want.
The order matters. The cookie parser must run before the session middleware, the session middleware before anything that reads req.session, and the user loader before any route that checks req.user. Guard middleware can be global or attached per route; attaching it per route keeps public endpoints public by default.
SameSite in depth
SameSite is the attribute people get wrong most often, so it is worth understanding what each value actually permits.
Strict never sends the cookie on a request whose site differs from the one that set it. Clicking a link from an email to your app arrives without the session, so the user may appear logged out on that first navigation, then logged in after a refresh. It is the safest setting and is a good fit for high-risk admin tools.
Lax sends the cookie on top-level navigations using safe methods, but not on cross-site POSTs, images or iframes. That covers the common case of following a link and staying logged in, while blocking the classic CSRF form post. It is the sensible default for most web apps and the browser default when the attribute is omitted in modern Chrome.
None sends the cookie on every cross-site request and requires Secure. It is necessary only for genuine cross-site flows such as an embedded checkout or a widget served from a different origin. Every use of None widens your CSRF surface, so add application-level tokens and confirm the Secure flag is present, or the browser will reject the cookie outright.
A subtle point: SameSite compares sites, not origins, and the definition of a site is the registrable domain. app.example.com and api.example.com are the same site, so a compromised subdomain is not protected by SameSite at all. That is another reason the __Host- cookie prefix and strict subdomain hygiene matter.
Rolling your own or using a library
It is tempting to implement sessions by hand: set a cookie, keep a Map, look it up. For learning, that is excellent. For production, it is usually a mistake, because the details that matter are exactly the ones that are easy to omit.
A mature library such as express-session for Node, Django’s session framework or Rails’ session store gives you signed ids, secure cookie defaults, pluggable stores, id regeneration and expiry handling. You should still understand every one of those mechanisms — that is what this guide is for — but you should not be the only person who has thought about them.
If you do build your own, the minimum checklist is: a CSPRNG id of at least 128 bits, a constant-time lookup, HttpOnly and Secure and SameSite cookies, id rotation on login, server-side expiry, and a delete-on-logout path. If any of those is missing, you have a vulnerability, not a session system.
Testing session authentication
Authentication is security-critical, so it deserves tests that assert the negative cases, not just the happy path.
import request from "supertest";
import app from "../app.js";
test("protected route rejects anonymous requests", async () => {
await request(app).get("/api/me").expect(401);
});
test("login sets an HttpOnly session cookie", async () => {
const res = await request(app)
.post("/login")
.send({ email: "[email protected]", password: "correct-horse" })
.expect(200);
const cookie = res.headers["set-cookie"][0];
expect(cookie).toContain("HttpOnly");
expect(cookie).toContain("SameSite=Lax");
});
test("logout invalidates the session", async () => {
const agent = request.agent(app);
await agent.post("/login").send(credentials).expect(200);
await agent.get("/api/me").expect(200);
await agent.post("/logout").expect(204);
await agent.get("/api/me").expect(401);
});
Using an agent that preserves cookies lets a single test follow a real browser’s behaviour. Assert that the session id changes after login, that an expired session is rejected, and that a tampered cookie is ignored. These tests catch regressions that a manual click-through will miss.
Cookie attributes that matter
The session id is only as safe as the cookie that carries it. These attributes are the difference between a solid design and a vulnerable one.
- HttpOnly — the cookie is invisible to
document.cookie. This is the single most important flag, because it neutralises XSS-based session theft. Set it always. - Secure — the browser only sends the cookie over HTTPS. Without it, an attacker on the network can read the id. Set it always in production, and be aware that
Securecookies are dropped on plain HTTP, which affects local development. - SameSite — controls cross-site sending.
Strictnever sends the cookie cross-site, which is safest but breaks inbound links that would otherwise keep you logged in.Laxsends it on top-level navigations such as clicking a link, and is the right default for most apps.Nonesends it everywhere and requiresSecure. - Path — limits the cookie to a URL prefix.
Path=/is typical. Scoping to/appreduces exposure slightly but rarely helps enough to justify surprising behaviour. - Domain — controls which hosts receive the cookie. Omitting it keeps the cookie on the exact host, which is the safer choice. Setting a parent domain shares the session across subdomains, widening the blast radius of a compromised subdomain.
- Max-Age and Expires — when the cookie should be discarded. A session cookie with neither lasts until the browser closes, which is often not what users expect on mobile. An explicit
Max-Ageis clearer. __Host-prefix — naming a cookie__Host-sidforcesSecure,Path=/and noDomain, which prevents a subdomain from overwriting your session cookie.
HTTP/1.1 200 OK
Set-Cookie: __Host-sid=s%3A9f2c...; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=86400
Choosing a session store
Where sessions live is the decision that most affects how your app scales.
In-process memory is the default in many frameworks and is fine for a single-instance app or a prototype. It disappears on restart and cannot be shared, so it fails as soon as you run more than one process.
Redis is the standard production choice. It is fast, supports key expiry natively, and is shared by every instance. Sessions are small key-value records, which is exactly Redis’s strength. A managed Redis is cheap and removes the operational burden.
A relational database works well when you already run Postgres or MySQL and want sessions to survive a Redis outage. You get durability and easy inspection with SQL, at the cost of a slower lookup unless the table is small and indexed by id.
A dedicated session table is common in frameworks such as Django and Rails. The store is a table with a session key, a serialised payload and an expiry column. An index on the key and a periodic cleanup job are the only maintenance required.
CREATE TABLE sessions (
id text PRIMARY KEY,
user_id bigint NOT NULL REFERENCES users (id) ON DELETE CASCADE,
data jsonb NOT NULL DEFAULT '{}'::jsonb,
expires_at timestamptz NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX sessions_expires_idx ON sessions (expires_at);
Memory does not scale
It is worth stating plainly why in-memory sessions are a production bug, because the failure is intermittent and confusing.
Imagine two app instances behind a load balancer with no shared store. A user logs in and lands on instance A, which stores the session in its own memory. The next request is routed to instance B, which has no record of the id, so the user appears logged out. If they log in again, they may bounce between the two. The user sees random logouts; the logs show a session that exists on one node and not the other.
You can paper over this with sticky sessions, where the load balancer pins a client to one instance. It helps, but it is fragile: a deploy, a crash or an autoscaling event drops the node and every session on it. Sticky sessions also fight horizontal scaling and make canary releases harder.
The honest fix is a shared store. Once sessions live in Redis or a database, any instance can serve any request, deploys become invisible to logged-in users, and you can add capacity freely. Use memory only for local development, and make the store configurable so production never accidentally uses it.
Session fixation and rotation
Session fixation is an attack where the attacker obtains a session id that the victim will use, then waits for the victim to authenticate and rides the now-privileged session. The classic delivery is a crafted link such as ?sid=attacker-known-id on a site that adopts a client-supplied session id.
The defence is simple and mandatory: regenerate the session id whenever the privilege level changes. That means at login, after a password reset, after elevating to an admin view, and after any step-up authentication. The pre-login id is discarded and the attacker’s copy becomes useless.
req.session.regenerate((err) => {
if (err) return next(err);
// A brand new id now identifies this session.
req.session.userId = user.id;
res.json({ ok: true });
});
Rotation has a second benefit: it prevents session id reuse across time. An id that has been valid for months is a bigger prize than one that changes at each login. Some frameworks rotate on a timer as well, which limits the window in which a leaked id is useful.
Never accept a session id from a query string, a request body or a custom header. The only source should be the cookie the server itself set, and only after validating that the id exists in the store.
Expiry and idle timeouts
Sessions should not live forever. Two clocks matter, and good systems use both.
An idle timeout expires a session after a period of inactivity, typically 15 to 60 minutes for sensitive applications. Each request that arrives before the timeout refreshes the expiry, so an active user stays logged in while an abandoned laptop loses access. This is the primary defence against a stolen cookie sitting on a device.
An absolute lifetime caps the total age of a session regardless of activity, commonly 8 to 24 hours and shorter for high-risk apps. It forces periodic re-authentication and bounds how long a compromised session can be abused even if it stays active.
Store the expiry with the session and enforce it on every lookup, not just in the cookie. A cookie’s Max-Age is a client-side hint that an attacker can ignore; the server-side expiry is the real rule. On logout, delete the record rather than merely marking it expired, so the store does not accumulate dead entries.
const session = await store.get(id);
if (!session || session.expiresAt < Date.now()) {
await store.delete(id);
return next(); // anonymous
}
CSRF: the cost of cookie auth
Because browsers attach cookies automatically, a page on another origin can cause your browser to send an authenticated request without your knowledge. This is cross-site request forgery: a malicious site submits a form to https://bank.example/transfer and your session cookie rides along, making the request look legitimate.
SameSite is the first line of defence. SameSite=Lax stops cookies from being sent on cross-site POST requests, which blocks the classic attack. But SameSite alone is not a complete strategy: older browsers, some subdomain configurations and legitimate cross-site flows still need application-level protection.
Two patterns cover the rest:
- Synchronizer token. The server stores a random token in the session and renders it into forms or exposes it in a response header. Unsafe requests must echo the token, and the server compares them in constant time. Because an attacker’s page cannot read the token, it cannot forge a valid request.
- Double-submit cookie. The server sets a random value in a non-HttpOnly cookie and the client sends the same value in a header. The server checks that the two match. It is stateless and easy to add, but it depends on the attacker being unable to set cookies for your domain, so pair it with the
__Host-prefix.
const sent = req.get("x-csrf-token") ?? "";
const expected = req.session.csrfToken;
const ok =
sent.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(sent), Buffer.from(expected));
if (!ok) return res.status(403).json({ error: "csrf_failed" });
Apply CSRF protection to every state-changing method — POST, PUT, PATCH, DELETE — and exempt GET, HEAD and OPTIONS, which must never mutate state. Also check the Origin header on unsafe requests as a cheap additional signal.
Signed versus encrypted cookies
A cookie can carry data itself, not just an id, if you protect it. Two mechanisms are common, and they solve different problems.
A signed cookie appends a keyed message authentication code so the server can detect tampering. A user cannot change role=member to role=admin because the signature will not match. But signing does not hide anything: the payload is base64 and readable by anyone with the cookie. Signing gives integrity, not confidentiality.
An encrypted cookie (often called a sealed cookie) both hides and authenticates the payload. The server can store a small amount of session data in the cookie itself, removing the store lookup, while keeping it opaque to the client. Frameworks such as cookie-session and Rails’ encrypted cookies take this approach.
The trade-off is the same as JWTs: a self-contained cookie is harder to revoke, and it grows the request size on every call. A useful middle ground is a hybrid: keep a server-side session for identity and permissions, and use a short-lived signed cookie for a small, non-sensitive flag. Whichever you choose, never put secrets, roles you do not re-check, or large objects in a cookie.
Scaling sessions across servers
Once the store is shared, the remaining concerns are performance and operations.
Keep sessions small. A session should hold identifiers, not full objects. Storing a copy of the user document means every profile change must update every session, and stale data causes confusing bugs. Store userId and load the user, or cache it briefly.
Read efficiently. A Redis GET per request is microseconds, but at high volume it is still a network hop. Many stacks cache the session for the lifetime of a single request so multiple middlewares do not each hit the store.
Handle the store being down. Decide whether a Redis outage should log everyone out or fail closed for protected routes. Failing closed is safer: treat an unreachable store as “no session” for authenticated endpoints, and return a clear error rather than silently granting access.
Clean up. Set a TTL on Redis keys and a periodic DELETE FROM sessions WHERE expires_at < now() for database stores. Without cleanup, the store grows without bound and lookups slow down.
Avoid sticky sessions when you have a shared store. They add operational complexity for no benefit and make zero-downtime deploys harder. If you truly cannot share a store, sticky sessions are a stopgap, not a design.
Sessions versus JWTs
The comparison comes up constantly, and the honest answer is that they solve overlapping problems with different trade-offs.
| Concern | Server-side session | JWT |
|---|---|---|
| Where state lives | Server store | Inside the token |
| Revocation | Immediate | Hard until expiry |
| Per-request lookup | Required | None |
| Sensitive data in client | Never | Avoid; it is readable |
| Scaling | Needs shared store | Stateless by design |
| Browser CSRF risk | Yes | Only if cookie-based |
| Best fit | First-party web apps | APIs, services, mobile |
Sessions win on control. You can revoke a single device, list active sessions, force logout everywhere, and change permissions that take effect on the next request. JWTs win on statelessness: any service can verify a token without a shared lookup, which is attractive for microservices and APIs where the issuer and the verifier are different systems.
A common and sensible architecture uses both. The browser gets an HttpOnly session cookie that references a server-side session. That session may contain a short-lived JWT used to call internal services. The user experience is a normal login, and the internal calls stay stateless. For a deeper look at the other side, read the JWT guide.
Auditing and observability
Sessions are a security boundary, and boundaries should be observable. Log the events that matter and none of the secrets.
Record successful and failed logins with the user id, a coarse source such as the IP and user agent, and a timestamp. Record session creation, rotation and destruction. Never log the session id itself, the password, or the CSRF token; a session id in a log file is a credential in a log file. If you must correlate, log a short hash of the id instead.
logger.info({
event: "session.created",
userId: user.id,
ip: req.ip,
userAgent: req.get("user-agent"),
});
Give users a way to see and revoke their active sessions. A “sessions” page that lists devices with a sign-out button turns a suspected compromise into a one-click fix, and it is a feature users increasingly expect. For administrators, alert on impossible travel, a spike in failed logins, or many sessions created from one IP.
Best practices
- Generate session ids with a cryptographically secure random generator, never a counter or a user-supplied value.
- Set
HttpOnly,Secure,SameSite=Laxand an explicitMax-Ageon the session cookie; consider the__Host-prefix. - Regenerate the session id at login and after every privilege change to defeat fixation.
- Use a shared store such as Redis or a database in production; never rely on process memory across instances.
- Enforce both an idle timeout and an absolute lifetime on the server, not only in the cookie.
- Destroy the record on logout and sweep expired sessions on a schedule.
- Add CSRF protection to every state-changing route and verify the
Originheader. - Keep session payloads small; store ids and load the rest.
- Fail closed when the session store is unreachable on protected routes.
- Re-authenticate for sensitive actions such as changing a password or adding a payment method.
Common mistakes
- Storing sessions in process memory and running more than one instance.
- Forgetting
HttpOnly, exposing the session id to XSS. - Leaving
SameSite=NonewithoutSecure, so browsers silently drop the cookie. - Reusing the session id after login instead of rotating it.
- Accepting a session id from a query parameter or request body.
- Putting roles and permissions in the cookie and trusting them without a re-check.
- Setting a cookie
Max-Agebut never expiring the server-side record. - Returning 200 with an error body for an unauthenticated request instead of 401.
- Skipping CSRF protection because “SameSite handles it”.
- Letting the session store grow forever with no TTL or cleanup job.
Where to go next
Session auth teaches the fundamentals that every other authentication scheme builds on: a credential, a lookup, an expiry and a way to revoke. The natural comparison is JWT, where the claims travel with the client and revocation becomes the hard part. If you need third-party login or delegated access, OAuth 2.0 is the standard. To understand the cookie itself — Set-Cookie, SameSite and the header rules — read the HTTP guide. And to wire the middleware into a real server, the Node.js guide covers the runtime underneath it all.