Skip to content

Passwords and Signed Tokens ​

Two primitives that bring-your-own auth builds on. Both live in @forinda/kickjs and are Node only (they use node:crypto). A third, matchesBearer, checks a shared-secret bearer token and runs anywhere.

Passwords: hashPassword / verifyPassword ​

ts
import { HttpException, hashPassword, verifyPassword } from '@forinda/kickjs'

// Sign-up
await users.create({ email, passwordHash: await hashPassword(password) })

// Login
const user = await users.findByEmail(email)
const { ok, needsRehash } = await verifyPassword(password, user?.passwordHash)
if (!ok) throw HttpException.unauthorized('Invalid email or password')
if (needsRehash) {
  await users.update(user!.id, { passwordHash: await hashPassword(password) })
}

scrypt, salted, self-describing. A hash looks like scrypt$16384$8$5$<salt>$<hash>: it records its own cost, so stored hashes keep verifying after you change the defaults. The same password hashes differently every time.

Upgrade on login. needsRehash is true when a password matched but its hash was made with a different cost than you passed (or the defaults). Hash it again and store it, and accounts move to the new cost as people sign in. It is only ever true when ok is, so it can't be used to upgrade a wrong password.

Pass undefined for a missing user. verifyPassword(password, user?.passwordHash) still runs the hash when there's no user, at the cost you pass, so "no such account" takes as long as "wrong password" and response times don't reveal which emails are registered. Return the same message for both.

Never throws on bad input. A hash in another format (bcrypt from a previous system, a corrupt column) returns { ok: false }, unless you pass a hasher for that format, as described in Hashes from another system. So does a hash whose cost would need more than 256 MiB, so a tampered row can't exhaust memory.

Normalised. Passwords are NFKC-normalised before hashing (NIST 800-63B's advice), so café typed with a composed or a decomposed accent is the same password.

Cost ​

The default, { N: 2 ** 14, r: 8, p: 5 }, is one of OWASP's recommended scrypt settings: 16 MiB and roughly 50–100 ms per hash on a server CPU. To raise it, pass the same options to both functions:

ts
const cost = { N: 2 ** 15, r: 8, p: 5 }
await hashPassword(password, cost)
await verifyPassword(password, stored, cost) // needsRehash: true for hashes made at 2^14

While accounts are moving to a new cost, those still on the old one verify at the old speed. That's a timing difference, but it reveals an account's age, not whether it exists. Memory is 128 * N * r bytes per concurrent hash. Mind it on small containers, and in tests pass a low cost ({ N: 2 ** 10, r: 8, p: 1 }) to keep them fast.

Hashes from another system ​

Users imported with bcrypt or argon2 hashes can keep their passwords. Pass a LegacyHasher for the format: matches recognises its hashes, and verify is that library's own check. A match returns needsRehash: true, so the rehash step above moves the account to scrypt at its next login:

ts
import bcrypt from 'bcrypt'
import { type LegacyHasher, verifyPassword } from '@forinda/kickjs'

const bcryptHashes: LegacyHasher = {
  matches: (stored) => /^\$2[aby]\$/.test(stored),
  verify: (password, stored) => bcrypt.compare(password, stored),
  // Any password, hashed at the cost your imported rows use.
  dummy: await bcrypt.hash('dummy', 12),
}

const { ok, needsRehash } = await verifyPassword(password, user?.passwordHash, {
  legacy: [bcryptHashes],
})

Scrypt hashes are never handed to a legacy hasher. A verify that throws (a malformed row) counts as a failed match. dummy keeps logins indistinguishable: every check runs scrypt once and each legacy hasher once, against dummy when the account isn't in that format. A scrypt account, an imported one and a missing one therefore take the same time, provided dummy has the same cost as the rows the hasher matches. If imported rows use several costs, give each cost its own hasher, matched by prefix, with a dummy at that cost. Every login then runs all of them and takes as long as the slowest:

ts
const bcryptAt = async (cost: number): Promise<LegacyHasher> => ({
  matches: (stored) =>
    /^\$2[aby]\$/.test(stored) && stored.slice(4, 6) === String(cost).padStart(2, '0'),
  verify: (password, stored) => bcrypt.compare(password, stored),
  dummy: await bcrypt.hash('dummy', cost),
})
const legacy = [await bcryptAt(10), await bcryptAt(12)]

A matches that throws counts as no match. Scrypt logins pay for one extra bcrypt check while the hasher is configured. Once no rows in the old format remain, drop the hasher and its dependency.

Signed tokens: createSigner ​

For links you send and check later without storing them: password reset, email confirmation, unsubscribe, a magic login link.

ts
import { HttpException, createSigner, getEnv } from '@forinda/kickjs'

const resets = createSigner({ secret: getEnv('SIGNING_SECRET'), salt: 'password-reset' })

// Issue
const token = resets.sign({ userId: user.id, stamp: user.passwordChangedAt.toISOString() })
await mailer.send(user.email, `https://app.example.com/reset?token=${token}`)

// Redeem
const check = resets.verify<{ userId: string; stamp: string }>(token, { maxAge: 60 * 60_000 })
if (!check.ok) throw HttpException.badRequest(check.reason) // 'invalid' | 'expired'
const user = await users.find(check.value.userId)
// A Date from the database never equals the string in the token: compare strings.
if (user?.passwordChangedAt.toISOString() !== check.value.stamp) {
  throw HttpException.badRequest('used')
}
  • Signed, not encrypted. The token is payload.timestamp.signature, URL-safe, and anyone holding it can decode the payload. Put ids in it, not secrets.
  • salt names the purpose. Signers with different salts reject each other's tokens even with the same secret, so an unsubscribe token can never be redeemed as a password reset.
  • maxAge is in milliseconds, checked at verify(). check.issuedAt gives the issue time if you need your own rule.
  • One-time use is yours. A signature can't be revoked. To make a link single-use, sign something that changes when it is used, like the user's password-change time above, and compare it on redeem.
  • verify() never throws. It returns { ok: false, reason } for tampered, malformed or expired tokens.

Rotating the secret ​

Pass a list, newest first. The first secret signs, and every secret verifies:

ts
createSigner({
  secret: [getEnv('SIGNING_SECRET'), getEnv('SIGNING_SECRET_OLD')],
  salt: 'password-reset',
})

Remove the old secret once the longest maxAge has passed. Sessions rotate the same way: see session({ secret: [...] }).

Shared-secret endpoints: matchesBearer ​

An endpoint called by a machine with a shared secret — a cron trigger, a metrics scraper, a webhook — checks Authorization: Bearer <secret>. Compare it in constant time, or the response timing leaks how much of a guess was right:

ts
import { HttpException, getEnv, matchesBearer } from '@forinda/kickjs'

@Post('/hooks/rebuild')
rebuild(ctx: RequestContext) {
  if (!matchesBearer(ctx.headers.authorization, getEnv('HOOK_SECRET'))) {
    throw new HttpException(401, 'Invalid token')
  }
  // ...
}

It returns false for a missing or non-string header, and for an empty secret, so an unset env var doesn't let Bearer through. The comparison doesn't stop at the first wrong character, so a guess's contents don't show in the timing; the secret's length can, so use a long random one. It uses no node:crypto, so it is also exported from @forinda/kickjs/web. The framework's own cron trigger and metricsAdapter({ token }) use it.

See also ​

Released under the MIT License. Built with TypeScript — runs on Express, Fastify, or h3.