DEV Community

Surulere Chris. I. Abiye
Surulere Chris. I. Abiye

Posted on

I Audited a Node.js Project and Found SHA-256 Password Hashing. Here's What I Changed.

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

  1. Introduction
  2. Quick Summary
  3. The Discovery
  4. What Was Actually Wrong?
  5. A Surprise Bonus Finding
  6. Why SHA-256 Isn't Ideal for Passwords
  7. What Modern Recommendations Say
  8. Why I Chose bcrypt Instead of Argon2id
  9. The Migration Challenge
  10. Building a Seamless Upgrade Path
  11. Before vs After
  12. Security Improvements Achieved
  13. Lessons Learned
  14. 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");
}
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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");
}
Enter fullscreen mode Exit fullscreen mode

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:

  1. Argon2id
  2. bcrypt
  3. 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);
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode

The logic is simple:

  1. User logs in
  2. Check whether the stored hash is bcrypt
  3. If it is, verify normally
  4. 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
  };
}
Enter fullscreen mode Exit fullscreen mode

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)