Security Bug Report

Audit found our "encrypted" passwords were Base64: migrating to Argon2id without a mass reset

A security review of a legacy customer portal found the password column was only Base64-encoded. Here is how we confirmed it, why encoding is not encryption, and how we moved 180,000 accounts to Argon2id with rehash-on-login and a targeted reset.

Nadia Haddad Nadia Haddad @nadiahaddad · July 18, 2026 · 9 min read · 27 Views
Banner: audit found encrypted passwords were Base64, migrating to Argon2id without a mass reset

The finding arrived in the middle of an external security review of a customer portal that a client had inherited from an agency. The reviewer's note was short: "The users.password column appears to be reversibly encoded. Please confirm the storage scheme." The internal documentation, a single README from 2019, said passwords were "encrypted before saving".

The portal had about 180,000 accounts, a login form, a password reset flow and an admin screen. It was a Node.js application with an Express backend and PostgreSQL.

Confirming what the column contains

We did not need to read the code to get a first answer. A password we controlled was enough. We created a test account with the password Correct-Horse-42 and looked at the stored value:

SELECT id, email, password FROM users WHERE email = 'audit-test@example.com';
  id   |         email          |         password
-------+------------------------+--------------------------
 91877 | audit-test@example.com | Q29ycmVjdC1Ib3JzZS00Mg==
echo 'Q29ycmVjdC1Ib3JzZS00Mg==' | base64 -d
# Correct-Horse-42

That settled it. The value was the password itself in Base64, with no key and no salt. Anyone with read access to the database, a backup or a log containing the row could read every password.

Finding the code path

The helper that "encrypted" the password lived in a utilities file:

// lib/crypto.js (legacy)
function encryptPassword(plain) {
  return Buffer.from(plain, 'utf8').toString('base64');
}

function checkPassword(plain, stored) {
  return encryptPassword(plain) === stored;
}

module.exports = { encryptPassword, checkPassword };

It was used in four places: registration, login, password change and the reset flow.

What we ruled out

  • Database-level encryption hiding the real scheme. The database had disk encryption, which protects a stolen disk, not someone who can query the table. The application saw the same Base64 string we did.
  • A second hashing step elsewhere. We traced every write to the column with a search for password in update and insert statements. Only the helper above was used.
  • Recent tampering. Git history showed the helper unchanged since the first commit. This was a design flaw, not an intrusion.

Root cause

The original developers confused encoding with encryption. Base64 changes the representation of bytes so they can be stored as text; it is reversible by anyone and offers no protection. On top of that, even real encryption would have been the wrong tool: passwords should be hashed with a slow, salted function so that nobody, including the application, can recover them.

Why encoding, encryption and hashing are different tools

The previous part of this series covers the difference in detail. In short:

  • Encoding (Base64) is reversible without a key. It is a format.
  • Encryption is reversible with a key. If the key lives next to the data, as it usually does in an application server, a breach of the server exposes both.
  • Password hashing is one-way. To check a login, you hash the submitted password with the stored salt and parameters and compare the results. A stolen database only gives the attacker hashes that are expensive to guess.

Fast hashes such as SHA-256 are also wrong for passwords because they allow billions of guesses per second on commodity hardware. Argon2id, scrypt and bcrypt are designed to be slow and, in the case of Argon2id and scrypt, memory-hard.

Environment

Component Version / value
Runtime Node.js 22 LTS
Framework Express 4.21
Database PostgreSQL 16
Accounts ~180,000, about 31% active in the last 12 months
Password library added argon2 npm package 0.41
Argon2id parameters memory 19 MiB, iterations 2, parallelism 1 (OWASP minimum)

Who is affected

Any system where a stored password, or something derived from it with a reversible or fast transformation, lets someone recover or cheaply guess the original. That includes Base64, hex, ROT13, any encryption with the key on the same server, and unsalted MD5 or SHA-1 hashes.

The plan

We had three constraints: fix the exposure immediately, avoid forcing 180,000 people to reset at once, and end with every account on Argon2id. The approach has three steps.

  1. Wrap every legacy value now. Hash the existing Base64 string with Argon2id and store the result, marked as a legacy wrap. After this step, the database no longer contains any recoverable password.
  2. Rehash on login. When a user logs in successfully, compute a fresh Argon2id hash of the real password and replace the wrapped value.
  3. Reset dormant accounts. After 90 days, accounts still on the wrapped scheme get their password invalidated and receive a reset email on their next visit.

The wrap step is the important one. Waiting for users to log in would leave the inactive majority exposed indefinitely.

Schema change

ALTER TABLE users ADD COLUMN password_scheme text NOT NULL DEFAULT 'base64';

The new password module

// lib/passwords.js
const argon2 = require('argon2');

const ARGON_OPTS = { type: argon2.argon2id, memoryCost: 19456, timeCost: 2, parallelism: 1 };
const legacyEncode = (plain) => Buffer.from(plain, 'utf8').toString('base64');

async function hashPassword(plain) {
  return argon2.hash(plain, ARGON_OPTS);
}

async function verifyPassword(user, plain) {
  switch (user.password_scheme) {
    case 'argon2id':
      return argon2.verify(user.password, plain);
    case 'argon2id-wrapped-base64':
      // stored value = argon2id(base64(plain)); reproduce the inner step, then verify
      return argon2.verify(user.password, legacyEncode(plain));
    default:
      throw new Error(`unsupported password scheme for user ${user.id}`);
  }
}

function needsRehash(user) {
  return user.password_scheme !== 'argon2id' || argon2.needsRehash(user.password, ARGON_OPTS);
}

module.exports = { hashPassword, verifyPassword, needsRehash };

argon2.verify reads the salt and parameters from the encoded hash string, so the stored value is self-describing.

Wrapping the existing values

A one-off script processed the table in batches, inside transactions, using keyset pagination:

// scripts/wrap-legacy-passwords.js
const argon2 = require('argon2');
const { pool } = require('../lib/db');
const OPTS = { type: argon2.argon2id, memoryCost: 19456, timeCost: 2, parallelism: 1 };

async function run() {
  let lastId = 0;
  for (;;) {
    const { rows } = await pool.query(
      `SELECT id, password FROM users
        WHERE password_scheme = 'base64' AND id > $1
        ORDER BY id LIMIT 500`, [lastId]);
    if (rows.length === 0) break;

    for (const row of rows) {
      const wrapped = await argon2.hash(row.password, OPTS);   // hash the Base64 string as-is
      await pool.query(
        `UPDATE users SET password = $1, password_scheme = 'argon2id-wrapped-base64'
          WHERE id = $2 AND password_scheme = 'base64'`, [wrapped, row.id]);
    }
    lastId = rows[rows.length - 1].id;
    console.log(`wrapped up to id ${lastId}`);
  }
}

run().then(() => pool.end());

At roughly 40 ms per hash on the migration worker, 180,000 rows took about two hours. The AND password_scheme = 'base64' guard made the script safe to re-run.

Login and password change

- const ok = checkPassword(req.body.password, user.password);
+ const ok = await verifyPassword(user, req.body.password);
  if (!ok) return res.status(401).json({ error: 'invalid_credentials' });
+ if (needsRehash(user)) {
+   const fresh = await hashPassword(req.body.password);
+   await db.query(
+     `UPDATE users SET password = $1, password_scheme = 'argon2id' WHERE id = $2`,
+     [fresh, user.id]);
+ }

Registration, password change and reset now call hashPassword and set password_scheme = 'argon2id'. The old helper was deleted so nobody could call it again.

Cleaning up copies

The database was not the only place the values lived. We also:

  • deleted database backups older than the wrap, after keeping one encrypted copy under legal hold;
  • searched the log platform for request bodies containing password and purged the indexes that had them;
  • removed the column from a nightly analytics export, where it had been copied for years.

Alternatives and trade-offs

  • Force a reset for everyone immediately. Simplest to reason about, but it locks out every user at once, floods support and trains people to click password reset emails. We used it only for dormant accounts.
  • Encrypt the existing values with a key. Faster to implement, but still reversible. It would have turned a confidentiality failure into a key management problem.
  • bcrypt instead of Argon2id. A valid choice; note that bcrypt only uses the first 72 bytes of input.

Treating it as a possible breach

Because the plain passwords had been readable for years by anyone with database or backup access, we treated them as potentially exposed. Users were told in a short, factual notice, and all sessions were invalidated so every user had to log in again, which also triggered the rehash for active users.

Confirming the database state

SELECT password_scheme, count(*) FROM users GROUP BY password_scheme;
     password_scheme      | count
--------------------------+--------
 argon2id                 |  41212
 argon2id-wrapped-base64  | 138931

There were no base64 rows left. Every stored value started with $argon2id$v=19$m=19456,t=2,p=1$. After 90 days, the wrapped count was about 120,000, and those accounts moved to forced reset.

Regression guard

A unit test fails if any stored value can be decoded back to the test password:

test('stored password is not reversible', async () => {
  const user = await createUser({ password: 'Correct-Horse-42' });
  const row = await db.one('SELECT password FROM users WHERE id = $1', [user.id]);
  expect(row.password.startsWith('$argon2id$')).toBe(true);
  expect(Buffer.from(row.password, 'base64').toString('utf8')).not.toContain('Correct-Horse-42');
});

A database check constraint also rejects any scheme value outside the known list.

Security implications

The bug was a full confidentiality failure for credentials, worse than a typical breach because the passwords were directly readable. Users who reuse passwords elsewhere were at risk on other sites, which is why we notified them. The fix removes recoverable passwords from the database but cannot undo earlier exposure in old backups or exports; those had to be found and deleted separately. Argon2id login verification costs about 40 ms of CPU per attempt, so login rate limiting is now also an availability control.

Prevention checklist

  • Never store passwords with encoding or encryption; use Argon2id, scrypt or bcrypt.
  • Make the hash scheme explicit per row so future migrations are easy.
  • Check backups, logs and exports for copies when fixing a storage scheme.
  • Add a test that a stored password cannot be decoded.
  • Rate-limit login so slow hashing cannot be used for denial of service.

References

Add a label

#nodejs #postgresql #password-hashing #argon2 #base64
Written by
Nadia Haddad @nadiahaddad

I moved into security from software development, and I still think like a developer: what does this mean for the code I am about to merge? Today I run security training and help engineering teams build safer systems without slowing them down. This series is the course I wish I had been given early in my career. It covers the fundamentals, from the CIA triad and networking basics to cryptography and access control, explained for developers with concrete examples and no fear-mongering. Every topic ends with something you can check or change in your own systems this week.

Advertisement

Comments

0

No comments yet. Be the first to comment!

Sign In to post a comment.