Skip to Content

Passwords

Hashing, verifying and upgrading stored passwords.

A password must never be stored, logged or compared directly. Store a hash made by a function that is deliberately slow, so a stolen database cannot be reversed by guessing.

import sukuCrypto from '@sukujs/crypto';

const stored = await sukuCrypto.hashPassword('correct horse battery staple');
// "$pbkdf2-sha256$i=600000$kQ1p...$9bX2..."

await sukuCrypto.verifyPassword('correct horse battery staple', stored);   // true
await sukuCrypto.verifyPassword('wrong', stored);                          // false

Save the whole string in one column. It carries the algorithm, the work factor and the salt, so nothing else needs storing.

What it does

  • PBKDF2-HMAC-SHA256 with 600,000 iterations, the minimum OWASP recommends.
  • A fresh 16-byte random salt per password, so identical passwords produce different hashes and precomputed tables are useless.
  • A constant-time comparison of the result.
  • Unicode normalisation, so the same password typed on different keyboards matches.

verifyPassword returns false for anything it cannot parse. It never throws on a bad stored value, so a corrupted row cannot crash a login route.

Why PBKDF2 and not Argon2?

PBKDF2 is the only password function available in every Web Crypto runtime, which keeps this package dependency-free and portable to edge platforms. Argon2id resists GPU attacks better. If you run only on servers and can accept a native dependency, prefer it; the two can coexist while users migrate.

Registering a user

route.post('/register', {
  body: z.object({ email: z.email(), password: z.string().min(10).max(200) }),
  services: { users: Users },
  status: 201,
  handle: async ({ body, users }) => {
    const passwordHash = await sukuCrypto.hashPassword(body.password);
    const user = await users.create({ email: body.email, passwordHash });
    return { id: user.id };
  },
});

Cap the length. Hashing a multi-megabyte "password" is an easy way for an attacker to burn your CPU.

Logging in

Three details matter here: the same response for an unknown user and a wrong password, the same amount of work in both cases, and an upgrade of old hashes.

import sukuCrypto from '@sukujs/crypto';

// Hashed once at startup, so unknown emails cost as much as known ones.
const DUMMY_HASH = await sukuCrypto.hashPassword('not-a-real-password');

route.post('/login', {
  body: z.object({ email: z.email(), password: z.string().max(200) }),
  services: { users: Users, tokens: Tokens },
  status: 200,
  handle: async ({ body, users, tokens }) => {
    const user = await users.findByEmail(body.email);
    const ok = await sukuCrypto.verifyPassword(body.password, user?.passwordHash ?? DUMMY_HASH);
    if (!user || !ok) throw new UnauthorizedException('Invalid email or password');

    if (sukuCrypto.needsRehash(user.passwordHash)) {
      await users.setPasswordHash(user.id, await sukuCrypto.hashPassword(body.password));
    }
    return { token: await tokens.sign({ role: user.role }, { subject: user.id }) };
  },
});

Without the dummy hash, a request for an unknown email returns instantly while a known one takes tens of milliseconds, which tells an attacker which emails are registered.

Raising the work factor

Hardware gets faster, so the right number of iterations rises over time. Because each hash records its own, you can raise the target and upgrade users as they log in.

const policy = { iterations: 900_000 };

await sukuCrypto.hashPassword(password, policy);
sukuCrypto.needsRehash(user.passwordHash, policy);   // true for hashes made with fewer

needsRehash is also true for a value it does not recognise, such as a hash from an older system, so the same branch handles migration.

Cost

One hash takes tens of milliseconds of CPU: about 45 to 70 ms on a recent laptop, and more on smaller cloud instances. That is the point, and it has two consequences:

  • Rate-limit login and registration. Each attempt is expensive for you as well.
  • It is asynchronous. Web Crypto does the work off the JavaScript thread on Node, so other requests keep being served while a hash is computed.

Things not to do

  • Do not hash passwords with sha256. It is built to be fast, which is the opposite of what a password hash needs.
  • Do not add your own "pepper" by concatenating strings unless you have a rotation plan for it.
  • Do not log request bodies on authentication routes.
  • Do not email a password, including a temporary one. Send a reset link.

On this page