Hashing
@tekir/hash provides a unified API for hashing and verifying passwords using bcrypt, Argon2, or scrypt. The default driver is bcrypt; switch to Argon2 for stronger memory-hard protection or scrypt for native Node/Bun crypto with no dependencies.
Overview
The HashProvider registers a Hash instance in the DI container. Access it via #services like any other service. The manager lazily instantiates drivers on first use and caches them. All three operations (make, verify, and needsRehash) share a single, consistent interface regardless of which driver is active.
import { service } from '@tekir/core'
import type { Hash } from '@tekir/hash'
export const hash = service<Hash>('hash')// Access via #services (recommended)
import { hash } from '#services'
// Or construct directly without DI
import { Hash } from '@tekir/hash'
const hash = new Hash({ default: 'argon2' })Basic Usage
Hash a plain-text password with hash.make():
import { hash } from '#services'
// Hash a plain-text password (uses the default driver: bcrypt)
const hashed = await hash.make('secret-password')
// => '$2b$10$...'Verify a plain-text value against a previously stored hash with hash.verify(). The method uses a timing-safe comparison internally and returns false for a wrong password or an unrecognized/malformed hash. It no longer swallows infrastructure failures, a genuine runtime error (for example a native module failing to load) now propagates instead of being silently reported as false, so a broken deployment surfaces immediately rather than failing every login quietly:
import { hash } from '#services'
// Verify a plain-text value against a stored hash
const isValid = await hash.verify('secret-password', storedHash)
// => true or false
// Returns false for a wrong password or an unrecognized/malformed hash.
// Real infrastructure errors (e.g. a native module failing to load) are
// thrown rather than swallowed, so a broken deployment is not mistaken
// for a wrong password.Drivers
Bcrypt
The default driver. tekir uses Bun.password.hash and Bun.password.verifyfor bcrypt operations, which are implemented natively in Bun's runtime. The rounds option controls the cost factor, each increment roughly doubles the hashing time. The default of 10 is suitable for most applications; 12 is a common production choice.
bcrypt only considers the first 72 bytes of the input, so any bytes beyond that are silently ignored. make() and verify() now emit a warning when the input exceeds 72 bytes so this truncation is visible. For very long passphrases, pre-hash the input or use Argon2 or scrypt, which have no such limit.
import type { HashConfig } from '@tekir/hash'
export default {
default: 'bcrypt',
bcrypt: {
rounds: 12 // cost factor, higher is slower (default: 10)
}
} satisfies HashConfigimport { BcryptDriver } from '@tekir/hash'
const driver = new BcryptDriver({ rounds: 12 })
const hashed = await driver.make('my-password')
// => '$2b$12$...'
const valid = await driver.verify('my-password', hashed)
// => true
const stale = driver.needsRehash(hashed)
// => false (parameters match)Argon2
Argon2id is the recommended algorithm for new applications. It is memory-hard, making it resistant to GPU-based brute-force attacks. tekir uses Bun.password.hash with algorithm: 'argon2id' internally. The two key parameters are memoryCost (KiB of RAM per hash) and timeCost (number of iterations).
import type { HashConfig } from '@tekir/hash'
export default {
default: 'argon2',
argon2: {
memoryCost: 65536, // memory in KiB (default: 65536 = 64 MiB)
timeCost: 3 // number of iterations (default: 3)
}
} satisfies HashConfigimport { Argon2Driver } from '@tekir/hash'
const driver = new Argon2Driver({ memoryCost: 65536, timeCost: 3 })
const hashed = await driver.make('my-password')
// => '$argon2id$v=19$m=65536,t=3,p=1$...'
const valid = await driver.verify('my-password', hashed)
// => true
const stale = driver.needsRehash(hashed)
// => falseScrypt
The scrypt driver uses Node.js/Bun's built-in crypto.scrypt: no external dependency required. It stores the cost parameters alongside the salt and hash in a single string so that needsRehash can detect parameter changes without extra configuration.
import type { HashConfig } from '@tekir/hash'
export default {
default: 'scrypt',
scrypt: {
N: 16384, // CPU/memory cost (default: 16384)
r: 8, // block size (default: 8)
p: 1, // parallelisation (default: 1)
keylen: 64 // derived key length in bytes (default: 64)
}
} satisfies HashConfigimport { ScryptDriver } from '@tekir/hash'
const driver = new ScryptDriver({ N: 16384, r: 8, p: 1, keylen: 64 })
const hashed = await driver.make('my-password')
// => '$scrypt$N=16384,r=8,p=1,keylen=64$<salt>$<hash>'
const valid = await driver.verify('my-password', hashed)
// => trueSwitching Drivers
Call hash.use(driverName) to change the active driver. It returns this, so you can chain directly into make(). The accepted driver names are 'bcrypt', 'argon2', and 'scrypt'.
import { hash } from '#services'
// hash.use() switches the active driver and returns this for chaining
const argonHash = await hash.use('argon2').make('my-password')
const bcryptHash = await hash.use('bcrypt').make('my-password')
// Note: use() mutates the active driver on the instance.
// For parallel requests on different drivers, construct separate Hash instances.Configuration
Place your hash configuration in config/hash.ts. The file exports a HashConfig object. Only the default key is required, omit any driver section to use that driver's built-in defaults. The HashProvider reads this config automatically from the container.
import type { HashConfig } from '@tekir/hash'
const config: HashConfig = {
default: 'argon2', // 'bcrypt' | 'argon2' | 'scrypt'
bcrypt: {
rounds: 12
},
argon2: {
memoryCost: 65536, // 64 MiB
timeCost: 3
},
scrypt: {
N: 16384,
r: 8,
p: 1,
keylen: 64
}
}
export default configDetecting Stale Hashes
hash.needsRehash(storedHash) checks whether the stored hash was produced with the same driver and cost parameters currently configured. A return value of true means the hash should be regenerated, typically done silently on the next successful login so users never notice the upgrade:
import type { HttpContext } from '@tekir/core'
import { hash, db } from '#services'
export async function login({ body, response }: HttpContext) {
const { email, password } = body
const user = await db.queryOne('SELECT * FROM users WHERE email = ?', [email])
if (!user) return response.unauthorized()
const valid = await hash.verify(password, user.password)
if (!valid) return response.unauthorized()
// Upgrade the hash in the background if parameters changed
if (hash.needsRehash(user.password)) {
await db.run('UPDATE users SET password = ? WHERE id = ?', [
await hash.make(password),
user.id
])
}
// ... generate token or set session
}Automatic Driver Detection
hash.verify() and hash.needsRehash() inspect the stored hash prefix to select the correct driver automatically. You can safely mix hashes from different drivers in the same database column, for example during a bcrypt-to-argon2 migration, without specifying the driver explicitly:
import { hash } from '#services'
// hash.verify() and hash.needsRehash() auto-detect the driver from the hash format.
// You do not need to specify the driver when verifying existing hashes.
const argonHash = '$argon2id$v=19$m=65536,t=3,p=1$...'
await hash.verify('password', argonHash) // uses Argon2Driver automatically
const bcryptHash = '$2b$10$...'
await hash.verify('password', bcryptHash) // uses BcryptDriver automatically
const scryptHash = '$scrypt$N=16384,...'
await hash.verify('password', scryptHash) // uses ScryptDriver automatically$2b$…/$2a$…: bcrypt$argon2id$…: Argon2$scrypt$…: scrypt
For symmetric encryption of non-password data see Encryption.