Skip to content
Security6 min read

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.

Part ofCybersecurity & Information Security

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.

LocationReadable by injected script?Sent automatically?Verdict
httpOnly cookieNoYes — needs CSRF defenceCorrect default
localStorageYesNoAvoid — any XSS is a full account takeover
sessionStorageYesNoSame flaw, shorter window
In-memory onlyPartiallyNoViable for short-lived tokens with a cookie refresh

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.

ts
// Every attribute here is doing something. None is optional.
cookies().set("session", token, {
  httpOnly: true,   // JavaScript cannot read it — the XSS mitigation
  secure:   true,   // HTTPS only, so it is never sent in plaintext
  sameSite: "lax",  // not sent on cross-site POSTs — the CSRF mitigation
  path:     "/",    // scope it as narrowly as the app allows
  maxAge:   60 * 60 * 8,
  // domain omitted deliberately: setting a parent domain shares the
  // cookie with every subdomain, including any you do not control.
});

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.

ModelRevocationCost per request
Opaque identifier, server-side storeImmediate — delete the rowOne fast store lookup
Signed token, short expiry, refresh tokenWithin one expiry windowNone, until refresh
Signed token, long expiry, no storeEffectively noneNone
Signed token plus a revocation listImmediateOne lookup — which is the first model again

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.

text
Leaks membership:
  Login:    "No account found with that email"
  Reset:    "That email is not registered"
  Register: "Email already in use"

Does not:
  Login:    "Email or password is incorrect"
  Reset:    "If that email has an account, we have sent a link"
  Register: "Check your email to continue"   <- then mail either a
            verification link or a "someone tried to register with
            your address" notice, depending on which case it was.

Also leaks: returning faster for an unknown address because no hash
was computed. Verify against a dummy hash so both paths cost the same.

What to verify before launch

  1. The session cookie carries httpOnly, secure and an explicit sameSite, and no token is in local storage.
  2. The session identifier changes on login, on privilege elevation and on password change.
  3. Logout revokes server-side, verified by replaying a captured request afterwards and confirming it fails.
  4. Changing a password invalidates every other active session, and the interface says so.
  5. Login and reset are rate-limited per address and per source, with lockout that cannot be used to deny a real user access.
  6. Error messages reveal nothing about which accounts exist, and both branches take comparable time.
  7. 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.

Want this applied to your systems?

Most of what is described here begins as an assessment: a fixed-scope review producing a prioritised written report you can act on with any supplier.

Prefer email? hello@kornspire.com