Sessions: the part of authentication that is usually wrong
Login is the part teams test. The session is the part attackers use. A correct password check followed by a token in local storage, no rotation on privilege change, and a logout that only clears the client leaves an authentication system that works perfectly and protects very little.
Authentication answers who someone is, once. A session answers it on every subsequent request, for as long as it lives. The second mechanism handles thousands of times more traffic than the first and receives a fraction of the attention.
Where the session token goes
This is the decision everything else follows from, and the popular answer is the wrong one.
The argument for local storage is that cookies are vulnerable to cross-site request forgery. That is true and it has a standard, cheap fix. The argument against local storage is that any script injection anywhere on the origin — including in a dependency you did not write — can read the token and exfiltrate it. That has no fix at all.
Rotate the session at every privilege change
Session fixation works by getting a known session identifier into a victim's browser before they authenticate, then reusing it afterwards. The defence is to issue a new identifier whenever the trust level of the session changes.
- On login — never carry the pre-authentication identifier into an authenticated session
- On privilege elevation — entering an admin area, assuming another role, switching organisation
- On password or MFA change — and invalidate every other active session at the same time
- Periodically for long sessions — so a stolen token has a bounded useful life
Rotation on password change is the one most often missed, and it is the one users implicitly expect. Someone who changes their password because they believe their account is compromised has taken the action they think ends the attacker's access. If the attacker's session survives it, the interface has lied to them.
Logout must revoke server-side
Clearing a cookie is a client-side hint. If the token is a self-contained signed value with a long expiry, a copy taken earlier remains valid until it expires, and logout has accomplished nothing against the only attacker who matters.
If short-lived access tokens with refresh are the right model, rotate the refresh token on every use and detect reuse. A refresh token presented twice means one of the two holders is not the legitimate user, and the correct response is to invalidate the whole family rather than to serve both.
Password storage, briefly
This is settled territory, so the only real risk is improvising. Use a purpose-built password hash with a per-password salt and a deliberately high cost, and use a maintained library rather than composing primitives.
- Argon2id where available, otherwise scrypt or bcrypt — all three are acceptable; general-purpose hashes are not
- Never SHA-256, MD5 or any fast hash, salted or otherwise — speed is the vulnerability
- A long minimum length rather than composition rules; character-class requirements produce predictable substitutions
- Check candidates against a breached-password corpus, which excludes far more real-world guesses than any complexity rule
- Never truncate, never block paste, and never impose a maximum short enough to rule out a passphrase
Do not leak which accounts exist
Login, registration and password-reset flows commonly reveal whether an address is registered — through the message, the status code, or how long the response takes. That turns any address list into a membership list, which is both a privacy problem and the first step in a credential-stuffing campaign.
What to verify before launch
- The session cookie carries httpOnly, secure and an explicit sameSite, and no token is in local storage.
- The session identifier changes on login, on privilege elevation and on password change.
- Logout revokes server-side, verified by replaying a captured request afterwards and confirming it fails.
- Changing a password invalidates every other active session, and the interface says so.
- Login and reset are rate-limited per address and per source, with lockout that cannot be used to deny a real user access.
- Error messages reveal nothing about which accounts exist, and both branches take comparable time.
- Every authorisation check happens server-side; a hidden interface element is not an access control.
Item three is the one worth doing by hand. Capture an authenticated request, log out, replay it. A surprising number of systems answer it happily, and that single observation tells you whether logout is a security control or a visual one.
Questions
Should a session token be stored in a cookie or localStorage?
An httpOnly cookie, with secure and an explicit sameSite. Local storage is readable by any script on the origin, including one inside a dependency you did not write, so a single injection becomes a full account takeover with no available mitigation. The cookie's weakness — cross-site request forgery — has a standard, cheap fix in the sameSite attribute and a token check.
When must the session identifier be rotated?
On login, on any privilege elevation such as entering an admin area or switching organisation, and on password or MFA change. Reusing a pre-authentication identifier after login is the session-fixation vulnerability. Rotation on password change matters most in practice, because a user changing their password believes they have ended an attacker's access — if the attacker's session survives, the interface has misled them.
Are stateless JWT sessions a bad idea?
Not inherently, but they trade revocation for a saved lookup, and that trade is worth making at large scale rather than by default. A long-lived signed token with no server-side state cannot be revoked, so a stolen copy works until it expires. Either keep expiry short with rotating refresh tokens, or keep a server-side store — at which point an opaque identifier and one indexed lookup is simpler.
How do we avoid leaking which email addresses have accounts?
Return identical responses for both cases. "Email or password is incorrect" on login, "if that email has an account we have sent a link" on reset, and on registration always say to check the email, then send either a verification link or a notice that someone tried to register with that address. Also equalise timing: skipping the hash computation for an unknown address makes the response measurably faster and leaks the same fact.