I Audited a Node.js Project and Found SHA-256 Password Hashing. Here's What I Changed.
Or how a simple code audit turned into an authentication upgrade.
Table of Contents
- Introduction
- Quick Summary
- The Discovery
- What Was Actually Wrong?
- A Surprise Bonus Finding
- Why SHA-256 Isn't Ideal for Passwords
- What Modern Recommendations Say
- Why I Chose bcrypt Instead of Argon2id
- The Migration Challenge
- Building a Seamless Upgrade Path
- Before vs After
- Security Improvements Achieved
- Lessons Learned
- Final Thoughts
Introduction
Recently, I was working on a Node.js project and decided to spend some time reviewing parts of the codebase before moving forward with new features.
I've found that some of the most valuable improvements don't come from adding new functionality. They come from revisiting old decisions and asking:
"If I were building this today, would I still do it this way?"
Before diving in, I should mention that I'm not a security engineer. I'm a software developer who takes security seriously and tries to follow current best practices when building applications.
While reviewing a Node.js project recently, I came across a password hashing implementation that used SHA-256. The code wasn't necessarily broken, but after researching modern password-storage recommendations, I realized there was an opportunity to improve it. That investigation eventually led me to migrate the application to bcrypt.
What started as a quick review of the authentication flow ended up becoming a complete authentication hardening exercise.
Quick Summary
During this audit, I:
- Replaced SHA-256 password hashing with bcrypt (12 rounds)
- Implemented automatic password upgrades during login
- Replaced Base64-encoded session tokens with signed JWTs
- Added access and refresh token support
- Introduced token expiration and verification
The Discovery
As I followed the authentication flow through the application, I noticed that a single hashPassword() helper was being used across the system:
- User registration
- User login
- Password changes
- User creation and updates
- Seed scripts
The implementation looked roughly like this:
export function hashPassword(password: string): string {
return crypto
.createHash("sha256")
.update(password + "app_name_salt_2024")
.digest("hex");
}
At first glance, this doesn't look terrible.
Passwords weren't stored in plain text.
A salt was being used.
Everything appeared to work.
So what's the problem?
What Was Actually Wrong?
The issue wasn't that SHA-256 is insecure.
SHA-256 is still an excellent cryptographic hash function.
The issue is that password storage is a completely different problem.
There was also another detail hiding in plain sight:
"app_name_salt_2024"
That salt was static.
Every single user password was hashed using the same salt.
This means:
- Users with identical passwords generate identical hashes
- Every account shares the same hashing strategy
- The benefits of salting are significantly reduced
- If the database is leaked, attackers have a much easier target than they would with unique salts
The implementation wasn't broken.
But it definitely wasn't where I wanted it to be.
A Surprise Bonus Finding
While reviewing the password implementation, I stumbled across something else.
The application's "tokens" looked like this:
export function generateToken(payload: JwtPayload): string {
return Buffer
.from(JSON.stringify({
...payload,
iat: Date.now()
}))
.toString("base64");
}
At first I laughed.
Then I stopped laughing.
Then I realized this code was probably written to solve a problem quickly, and nobody had revisited it since.
Because technically it worked.
But Base64 is encoding, not security.
Anyone could decode the token, modify it, re-encode it, and potentially impersonate another user.
So while my original goal was improving password security, I ended up modernizing the token implementation as well.
More on that later.
Why SHA-256 Isn't Ideal for Passwords
One thing that surprised me when I started learning more about authentication security is this:
A secure hash function does not automatically mean secure password storage.
SHA-256 was designed to be fast.
For file verification, digital signatures, checksums, and integrity checks, that's fantastic.
For passwords?
Not so much.
Imagine an attacker gets a copy of your database.
Modern hardware can calculate enormous numbers of SHA-256 hashes every second.
That's exactly what attackers want.
Dedicated password hashing algorithms intentionally slow this process down.
The slower password verification becomes, the more expensive brute-force attacks become.
And that's exactly what we want.
What Modern Recommendations Say
Today, password storage recommendations generally point developers toward:
- Argon2id
- bcrypt
- PBKDF2
Unlike SHA-256, these algorithms are specifically designed for password hashing.
They provide features such as:
- Configurable work factors
- Automatic salting
- Increased resistance to brute-force attacks
- Better protection against modern hardware
Argon2id is generally considered the current gold standard.
If I were building a completely new authentication system from scratch today, that's probably where I'd start.
Why I Chose bcrypt Instead of Argon2id
While researching these options, I realized the challenge wasn't choosing the "best" algorithm on paper.
It was choosing the best option for the project's current environment.
This was actually the part that took the most thought.
My first choice was Argon2id.
After evaluating the application's infrastructure and performance requirements, however, I decided to migrate password hashing to bcrypt.
Not because bcrypt is better.
Not because Argon2id is bad.
But because engineering decisions are often about practicality.
I wanted a solution that would:
- Work well in the current environment
- Require minimal deployment changes
- Be easy to maintain
- Provide a significant security improvement immediately
bcrypt checked all those boxes.
I settled on a cost factor of 12.
const BCRYPT_ROUNDS = 12;
export async function hashPassword(password: string) {
return bcrypt.hash(password, BCRYPT_ROUNDS);
}
One thing I particularly like about bcrypt is that it automatically generates a unique salt for every password.
No manual salt management.
No shared salt values.
No accidental mistakes.
The Migration Challenge
Switching algorithms was easy.
Migrating users was not.
Password hashes are one-way functions.
You can't simply convert:
SHA-256 Hash → bcrypt Hash
The user's original password is required.
At this point I had two options.
Option 1
Force everybody to reset their password.
Option 2
Build a migration path.
I chose Option 2.
Building a Seamless Upgrade Path
The solution was a helper called:
verifyAndUpgradePassword()
The logic is simple:
- User logs in
- Check whether the stored hash is bcrypt
- If it is, verify normally
- If it's SHA-256:
- Verify using the legacy method
- Generate a bcrypt hash
- Update the database
- Continue login
Something like this:
export async function verifyAndUpgradePassword(
password: string,
storedHash: string,
) {
if (storedHash.startsWith("$2")) {
return {
ok: await bcrypt.compare(password, storedHash)
};
}
const oldHash = crypto
.createHash("sha256")
.update(password + LEGACY_SALT)
.digest("hex");
if (oldHash !== storedHash) {
return { ok: false };
}
const updatedHash =
await bcrypt.hash(password, BCRYPT_ROUNDS);
return {
ok: true,
updatedHash
};
}
This ended up being my favorite part of the migration.
No password reset emails.
No support tickets.
No downtime.
Users simply log in as usual and get upgraded automatically.
Before vs After
Before
- SHA-256 password hashing
- Static salt
- Base64 encoded tokens
- No token signatures
- No token expiration
- No migration strategy
After
- bcrypt password hashing
- Unique salt per password
- bcrypt cost factor of 12
- Signed JWTs using HS256
- 15-minute access tokens
- 7-day refresh tokens
- Cryptographic token verification
- Token expiration
- Automatic password upgrades
Looking at it side-by-side, the difference is quite significant.
Security Improvements Achieved
The migration delivered several immediate improvements.
Unique Salts
Each password now receives its own salt.
Better Brute Force Resistance
bcrypt is intentionally slow.
Attackers hate that.
Developers love that.
Adjustable Security
The cost factor can be increased as hardware improves.
Stronger Authentication
JWTs are now signed and verified cryptographically rather than simply encoded.
Better User Experience
Users never had to reset their passwords.
Lessons Learned
The biggest lesson from this experience wasn't that SHA-256 is bad.
It wasn't.
The lesson was that authentication code tends to sit untouched for years simply because it works.
Once login works, developers rarely revisit it.
But security recommendations evolve.
Hardware evolves.
Attack techniques evolve.
Periodically revisiting old decisions can reveal opportunities for meaningful improvements without requiring a complete rewrite.
Final Thoughts
One thing I enjoy about reviewing existing code is discovering areas where small improvements can have a big impact.
The original implementation wasn't malicious.
It wasn't reckless.
It worked.
But after spending time understanding the trade-offs and modern recommendations, I felt there was a better approach.
Would I choose Argon2id for a brand-new project today?
Probably.
Was bcrypt the right choice for this project's current environment?
Absolutely.
As developers, we don't need to be security experts to make better security decisions.
Sometimes all it takes is curiosity, a willingness to question old assumptions, and a desire to leave the codebase a little better than we found it.
The most interesting thing about this audit wasn't discovering SHA-256. It was realizing how easy it is for security-related code to remain unchanged for years simply because it works. Authentication code is often written once and rarely revisited. Taking time to periodically review those decisions can reveal opportunities for meaningful improvements without requiring major architectural changes.
For me, this audit started with SHA-256.
It ended with a stronger authentication system, a smoother migration strategy, and a reminder that "working" and "secure" aren't always the same thing.
Top comments (0)