Passwords, sessions and password reset in a client portal
A portal stored passwords as unsalted SHA-256 and its sign-in could not be revoked. Moving to Argon2id without a mass reset, and sessions that can be ended.
- Company
- Accounting practice, 11 employees, around 150 clients
- Application
- A portal where clients upload documents and download statements
- Team
- Built by a freelancer who is no longer available; a new part-time developer
- Stack
- Node.js, PostgreSQL, React, one VPS
- Constraints
- A mass password reset would swamp the office with phone calls
01Starting point
Passwords were stored in the users table as unsalted hexadecimal SHA-256. After sign-in the server issued a JWT valid for 30 days, which the front end kept in localStorage and sent in the Authorization header. Signing out only deleted the token from the browser.
The password reset link carried a random token stored in the database in readable form, with no expiry.
- Browser (React, JWT in localStorage)
- Nginx
- Node.js API
- PostgreSQL (users, documents)
02The problem
The new developer was asked to add electronic signing to the portal. Reading the sign-in code, they found three related weaknesses and proposed fixing them before another sensitive feature was added.
Passwords: SHA-256 is a fast hash function designed for integrity checks, not for passwords. Anyone who obtains a copy of the table, from a backup or through another flaw, can try billions of candidates per second on an ordinary graphics card. Without a salt, identical passwords have identical hashes and can be looked up in precomputed tables.
Sessions: the token could not be revoked. Signing out, changing the password or blocking a client did not end it; it stayed valid for the full 30 days. In localStorage it is readable by any JavaScript on the page, so an XSS flaw would let it be stolen and used from another computer.
Password reset: anyone who saw the database or an old email with the link could set the password of someone else's account months later.
The company knows of no abuse. These are weaknesses that magnify the impact of another failure: a database leak, an XSS flaw or a stolen email.
03Technical root cause
The original author confused hashing for integrity with password storage. Passwords call for deliberately slow functions with a per-password salt and tunable cost: Argon2id, scrypt or bcrypt. Hashing is also not encryption: a hash cannot be decrypted, only guessed, and a fast function makes guessing cheap.
A JWT is signed, so the client cannot alter it, but the server keeps no record of it once issued. Without a revocation list it has no way to invalidate one before it expires. For signing in to a single web application, that is complexity with no benefit.
The reset token was treated as an ordinary value, although it is a temporary password.
04Investigation
A look at the database: how many accounts share the same hash. Several groups with the same hash meant the same password, most likely a weak one.
SELECT password_hash, count(*) AS accounts
FROM users
GROUP BY password_hash
HAVING count(*) > 1
ORDER BY accounts DESC;An inventory of copies of the users table: nightly backups on an external disk in the office and a copy of the database on the freelancer's laptop from the build. Both are as sensitive as production.
A review of the front end for places where HTML is inserted without escaping (dangerouslySetInnerHTML). There was one, in the notes on documents.
05Remediation
Passwords without a mass reset: at deployment every existing SHA-256 hash was wrapped in Argon2id, so the database now holds argon2id(sha256(password)). The old form disappeared for everyone at once, without anyone entering a password. At the next sign-in the password is rehashed directly as argon2id(password).
import argon2 from "argon2";
import { createHash } from "node:crypto";
import { db } from "./db.js";
// OWASP Password Storage Cheat Sheet: m = 19 MiB, t = 2, p = 1
const OPTS = { type: argon2.argon2id, memoryCost: 19456, timeCost: 2, parallelism: 1 };
const sha256hex = (s) => createHash("sha256").update(s, "utf8").digest("hex");
// one-off, at deploy: wrap every legacy SHA-256 hash, nobody resets anything
export async function wrapLegacyHashes() {
const { rows } = await db.query(
"SELECT id, password_hash FROM users WHERE scheme = 'sha256'",
);
for (const u of rows) {
await db.query(
"UPDATE users SET password_hash = $1, scheme = 'argon2id(sha256)' WHERE id = $2",
[await argon2.hash(u.password_hash, OPTS), u.id],
);
}
}
// at login: verify, and replace a wrapped hash with a direct one
export async function verifyAndUpgrade(user, password) {
const wrapped = user.scheme === "argon2id(sha256)";
const ok = await argon2.verify(
user.password_hash,
wrapped ? sha256hex(password) : password,
);
if (ok && wrapped) {
await db.query(
"UPDATE users SET password_hash = $1, scheme = 'argon2id' WHERE id = $2",
[await argon2.hash(password, OPTS), user.id],
);
}
return ok;
}Parameters follow the OWASP recommendation: 19 MiB of memory, 2 iterations, 1 thread. Old copies holding SHA-256 hashes, meaning the backups and the freelancer's laptop, were deleted or replaced. While they exist, wrapping does not protect those accounts, because an attacker holding them skips Argon2id entirely.
Server-side sessions instead of JWT: signing in creates a session record in the database with a random 256-bit identifier, and the browser receives only a cookie carrying it.
Set-Cookie: __Host-sid=<random 256-bit id>; Path=/; Secure; HttpOnly; SameSite=Lax; Max-Age=28800Every sign-in creates a new session, which prevents session fixation. Signing out deletes the record, a password change or reset deletes all of the user's sessions, and a session lasts at most 8 hours. Requests that change data also check the Origin header, because SameSite does not protect against requests from other subdomains of the same domain.
Password reset: the token is 32 random bytes, only its SHA-256 is stored, it is valid for 30 minutes and deleted once used. A fast SHA-256 is enough here, because 256 bits of randomness cannot be guessed, unlike a password a person made up.
Sign-in: failed attempts are limited per account and per IP address with an increasing delay rather than a permanent lockout, which an attacker could use to lock other people out. Notes on documents are rendered as text, not as HTML.
06Why the fix works
Argon2id is deliberately slow and memory hard. Each guess costs the attacker roughly as much as one sign-in, and the memory requirement limits how many guesses a graphics card can run in parallel. The salt Argon2 generates for each password makes identical passwords hash differently, so precomputed tables do not work.
A wrapped hash is just as expensive to guess: an attacker with the new table must run the full Argon2id for every attempt, and the SHA-256 inside does not help unless they also hold an old copy.
A server-side session only needs deleting and access ends immediately. JavaScript cannot read an HttpOnly cookie, so XSS cannot carry it off to another computer. A browser accepts a __Host- cookie only with Secure, Path=/ and no Domain, so another subdomain cannot plant one.
The database holds only a hash of the reset token. Whoever sees it cannot recover the token from it.
What it does not address: HttpOnly does not stop XSS acting as the user inside their open browser. A slow hash does not fully protect a weak password; it only slows down guessing it. None of this protects against phishing.
07Validation
- After the migration no row with the
sha256scheme remained, and the duplicate hash query returns nothing. - Automated tests: sign-in before and after rehashing, a password change ends the session in a second browser, a reset link fails after 30 minutes and after use.
- In the browser's developer tools the cookie carries all its attributes, and nothing sign-in related is in
localStorage. - The number of accounts still on the wrapped scheme can be read with a single query at any time, and falls with every sign-in.
08Results
Fixed
- There are no unsalted SHA-256 hashes in the database.
- Signing out and changing the password really do end access.
- The reset link is single use and valid for 30 minutes.
Risk reduced
- A leak of the database or a backup would give an attacker a far slower route to the passwords.
- An XSS flaw can no longer steal the sign-in to another computer.
Still open
- Accounts that have not signed in since keep the wrapped form.
- Clients may still have weak passwords.
09Limitations
- A weak password falls under Argon2id too, only more slowly. Checking new passwords against a list of breached passwords helps.
- Until two-factor authentication is on, only the password protects the sign-in, and phishing works.
- Sessions in the database mean one extra query per request. At this portal's size, that is negligible.
10Lessons
- Passwords belong in Argon2id, scrypt or bcrypt, never SHA-256 or MD5.
- An old hash scheme can be retired without a password reset: wrap now, rehash at sign-in.
- A sign-in that cannot be revoked fails exactly when it needs ending.
- A reset token is a temporary password: store only a hash, keep it short lived, use it once.
- Old copies of the database are as sensitive as production.
11Next steps for a small company
- 1.Two-factor authentication (TOTP), mandatory for office staff and optional for clients.
- 2.Checking new passwords against breached ones, for example through the Pwned Passwords API, whose k-anonymity model never receives the password or its full hash.
- 3.A Content-Security-Policy header to limit the impact of any future XSS.
- 4.After a year, ask accounts still on the wrapped scheme to set a new password.
Recognise your own company in this?
Tell us what it involves. We reply within one business day and say whether it is work for us, even when the answer is no.