I automatically converted passwords I once stored as SHA-256 to bcrypt at each login
I moved SHA-256 passwords to bcrypt without stopping the service. Covers rehashing at the moment a login succeeds, and when the legacy check can be deleted.
#Security #bcrypt #Authentication #Backend
Honestly, in the early days I stored passwords hashed with SHA-256. Looking at it now, that was a bad choice. SHA-256 is a hash that was built to be fast, so with today's hardware you can run through hundreds of millions of password candidates per second. I learned later that for storing passwords you're supposed to use something like bcrypt , which is designed to be slow on purpose. I was curious how big the difference was, so I measured it on my own server. sha256 x1000: 4.6 ms (0.005ms per run) bcrypt hash rounds=10: 78 ms bcrypt compare rounds=10: 72 ms bcrypt hash rounds=12: 275 ms SHA-256 takes under 5ms even for a thousand runs, while bcrypt takes 78ms for one. From an attacker's point of view, the number of passwords they can try in the same amount of time drops by a factor of more than ten thousand. For a normal user it's a cost paid just once at login, so they don't feel it. The problem was that there were already users stored with SHA-256. A hash can't be reversed, so I don't know the original passwords, which means I can't swap them all over to bcrypt in one go. And telling everyone to reset their password would be pushing the inconvenience onto the users, so I didn't want to do that. In this post I'll go over how I switched hashing methods without stopping the service and without users noticing. There is exactly one moment when I can know the original password It's when the user logs in. At that moment the plaintext they typed is on the server for a short while. If it verifies correctly the old way, I can hash that plaintext again with bcrypt and store it. So starting with whoever logs in successfully, people move over to the new method one by one without knowing it. People who come often move over quickly, and people who don't come stay on the old hash and move over the next time they log in. There's no need to stop the service and no need to send a notice email. How do I tell the two methods apart? First I have to tell whether a stored value is a bcrypt hash or a SHA-256 hash. Luckily they look completely different. SHA-256 is an unbroken string of 64 hex digits, and a bcrypt hash always has this shape. $2b$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy ^^ ^^ ^^^^^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ ver cost salt (22 chars) hash (31 chars) The $2b at the front is the bcrypt version, and the 10 after it is the cost. Every time this value goes up by 1, the amount of computation doubles. As measured above, 10 is 78ms and 12 is 275ms. The salt is included inside the hash, so there's no need for a separate column to store it. So if the stored value starts with $2 it's bcrypt, and otherwise I can treat it as the old SHA-256. // db/auth.js const SALT_ROUNDS = 10; const sha256 = (s) = crypto.createHash("sha256").update(s).digest("hex"); const hashPassword = (plain) = bcrypt.hash(plain, SALT_ROUNDS); // verify with bcrypt if the stored hash is bcrypt, otherwise with legacy SHA-256 const verifyPassword = async (plain, stored) = { if (!stored) return false; if (stored.startsWith("$2")) return bcrypt.compare(plain, stored); return sha256(plain) === stored; // legacy SHA-256 }; I left SALT_ROUNDS at 10. Raising it to 12 is safer, but it adds close to 300ms to every login. On a single server for personal projects, I thought that was a bit much. The bcrypt docs also recommend 10 as the default. Rehashing automatically at login In the login handler, after the password passes verifyPassword, if what's stored is the old SHA-256, it gets hashed again with bcrypt right there and written with an UPDATE. The actual code looks like this. // db/auth.js router.post("/emailLogin", loginLimiter, async (req, res) = { const { email, password } = req.body; const [rows] = await pool.query( "SELECT id, email, nickname, role, pwd FROM Users WHERE email = ? AND social_type = 0", [email] ); if (rows.length === 0) { return res.status(401).json({ error: "The email or password is incorrect." }); } const user = rows[0]; const ok = await verifyPassword(password, user.pwd); if (!ok) { return res.status(401).json({ error: "The email or password is incorrect." }); } // if it was a legacy SHA-256 hash, migrate to bcrypt automatically if (!user.pwd.startsWith("$2")) { const newHash = await hashPassword(password); await pool.query("UPDATE Users SET pwd = ? WHERE id = ?", [newHash, user.id]); } delete user.pwd; // keep the hash out of the response/token const token = generateToken(user); return res.json({ token, user }); }); From the user's side they just logged in as usual, but behind the scenes the storage method quietly changes. Only on that one login with the old hash does the SHA-256 check get an extra 78ms of bcrypt hashing, and from then on it only costs the 72ms bcrypt compare. Either way it's not an amount of time a person can feel. Using the same sentence for the error message when the email doesn't exist and when the password is wrong was also deliberate. If I said "there's no such email" separately, someone outside could find out whether an email is registered or not. I added loginLimiter for the same reason. If one IP tries to log in too often, it gets blocked for a while. As a bonus, not leaking the hash in responses While I was at it, I also made sure to delete the password hash field before putting user info into a response or a token. That's the delete user.pwd in the code above. Even though it's a hash, there's no reason at all to send it out to the client, and a JWT is base64, so anyone can open it and see what's inside. The old code put the row fetched with SELECT * straight into the token, so the hash was sitting inside the token. When can I delete it? The problem with this approach is that I can't decide when it ends. This is still in the code today. // SHA256 helper: kept only for verifying legacy passwords (new/upgraded ones use bcrypt). const sha256 = (s) = crypto.createHash('sha256').update(s).digest('hex'); As long as even one user who hasn't logged in yet remains, I can't delete this line. That person might never come back, and there's no way to tell whether they're gone or just haven't come yet. In the end I do have to decide how long to wait. But before setting an arbitrary deadline, I plan to count how many accounts are left first. The hashes look different, so one query is enough. SELECT COUNT(*) FROM Users WHERE social_type = 0 -- email signups only (Kakao login has no password) AND pwd NOT LIKE '$2%'; -- accounts still on SHA-256 If it's few enough to count on my fingers, contacting them individually is better than a blanket reset, and after that I can delete the sha256 helper. And there was one more piece of old login code left While going back over this post I dug through the project and found the old login route still sitting in db/user.js. It's code like this. // db/user.js (old code) router.post('/doLogin', async (req, res) = { const hashedPassword = crypto.createHash('sha256').update(password).digest('hex'); const query = 'SELECT * FROM Users WHERE email = ? AND pwd = ?'; connection.query(query, [email, hashedPassword], (err, results) = { ... }); }); It hashes with SHA-256 and compares directly against the value in the DB. Logging in through this path fails for every account that has been switched to bcrypt. Luckily app.js doesn't mount this router, so the code was never actually called, but if someone attaches it again later, logins break from that point on. Now that I've confirmed it isn't used, the right thing is to delete it while I'm at it. If you need to change your password hashing and already have users, this approach of migrating at login is worth trying once. It takes a few lines of code and puts no burden on the users. Just make sure to also search for any code left elsewhere that still verifies the old way.