Passwords & Migration
Password policy, HIBP breach detection, lazy rehash, legacy hash support, pepper, history, expiration, and the users:import command.
AuthKit's Lucid account store bundles a PasswordManager that handles the full lifecycle
of passwords: validating new ones against a configurable policy, checking for known breaches
(HaveIBeenPwned, k-anonymity), transparently rehashing legacy hashes on login, and
enforcing hygiene rules like history reuse prevention and expiration.
Configuration overview
Password options live under the Lucid store's password config key. Only infrastructure
fields belong here — policy is set via the password_policy runtime setting:
import { lucidAccountStore } from '@adonis-agora/authkit-server'
import AuthUser from '#models/auth_user'
accountStore: lucidAccountStore(AuthUser, {
password: {
// Infra fields only — secrets and legacy verifier stay in config
pepper: env.get('PASSWORD_PEPPER'), // or [newPepper, oldPepper] for rotation
legacyVerifier: async (hashedPassword, plainPassword) => {
if (!hashedPassword.startsWith('$2y$')) return null
const normalized = '$2b$' + hashedPassword.slice(4)
const bcrypt = await import('bcrypt')
return bcrypt.compare(plainPassword, normalized)
},
},
})pepper and legacyVerifier are infrastructure — they are code and secrets, and
stay in config. Password policy (minLength, requireUppercase, checkPwned, etc.)
is managed via the password_policy runtime setting (see Runtime Settings).
Password policy
Applied to every new password: signup, reset, change-password, and admin-create.
Managed entirely via the password_policy runtime setting — there is no config equivalent:
node ace authkit:settings:set password_policy \
'{"minLength":10,"requireUppercase":true,"requireNumbers":true}'| Option | Type | Default | Notes |
|---|---|---|---|
minLength | number | 8 | Minimum character count. |
requireUppercase | boolean | false | At least one A–Z. |
requireLowercase | boolean | false | At least one a–z. |
requireNumbers | boolean | false | At least one 0–9. |
requireSymbols | boolean | false | At least one non-alphanumeric character. |
blockCommon | boolean | true | Reject passwords on the offline common-password list (see below). |
Violations are returned as i18n keys (password.policy.min_length, password.common, etc.)
and surfaced in the form with localized messages.
Common passwords (offline block)
When blockCommon: true (the default), AuthKit rejects passwords that appear on a bundled
offline list of ~10,000 of the most common passwords, compiled from public datasets
(rockyou, SecLists Common-Credentials, NIST SP 800-63B). The check is:
- Case-insensitive —
"Password123"is checked as"password123". - In-memory — the list is loaded once into a
Set<string>on the first check (~50–80 KB). - Runs before HIBP — it's cheaper than a network call, so common passwords are rejected before the k-anonymity request is even made.
- Fail-safe — if the bundled file is absent (e.g. a misconfigured custom build), the
Setis empty and the check is a silent no-op.checkPwnedcan still catch these passwords via HIBP.
The error key is password.common. Disable it explicitly if you have a policy reason:
node ace authkit:settings:set password_policy '{"blockCommon":false}'blockCommon and checkPwned are complementary: blockCommon is an instant offline
check covering the most common weak passwords; checkPwned queries HIBP for the full
breach database. Enabling both gives the best coverage with minimal latency.
Breach detection (HaveIBeenPwned)
When checkPwned is enabled, AuthKit queries the HIBP Range API using
k-anonymity: only the first 5 hex characters of the SHA-1 hash are sent, and the
suffix is matched locally — the full password or hash is never transmitted.
Enable via the password_policy runtime setting:
node ace authkit:settings:set password_policy '{"checkPwned":true}'The HIBP timeout (default 2 000 ms) can be tuned via pwnedTimeoutMs in the
lucidAccountStore password config — it is infrastructure and stays in config.
| Field | Type | Default | Notes |
|---|---|---|---|
checkPwned | boolean | false | Enable HIBP check via the password_policy setting. |
pwnedTimeoutMs | number (config) | 2000 | Infra — set in lucidAccountStore password config. |
Fail-safe: a network error, a timeout, or a non-OK response from HIBP does not block the user from setting a password. The check is skipped and a warning is logged. Only an explicit "found in breach database" result rejects the password.
Pepper (HMAC-SHA256 pre-hashing)
A pepper is a server-side secret mixed into every password hash via HMAC-SHA256 before the password reaches the Argon2/bcrypt hasher. This is an OWASP-recommended defense-in-depth measure: if the password database is leaked, hashes are useless without the pepper value.
password: {
pepper: env.get('PASSWORD_PEPPER'), // single string
}How it works
- On password set (signup, reset, change):
HMAC-SHA256(pepper, plainPassword)is computed; the resulting hex digest is fed to Argon2 (or your configured hasher) as if it were the password. - On verify: the same HMAC is applied and the hash comparison runs on the peppered value.
- On rehash: if the stored hash verifies but needs rehashing (cost bump, pepper rotation), a new hash of the peppered value replaces the old one transparently.
Pepper rotation
To rotate, set pepper to an array — new pepper first, old ones after:
password: {
pepper: [
env.get('PEPPER_V2'), // current — applied to all new hashes
env.get('PEPPER_V1'), // previous — tried on verify for back-compat
],
}Lazy re-pepperization: on the next successful login, if the stored hash was verified
using an old pepper, a new hash using PEPPER_V2 is stored transparently. No forced
migration needed — accounts upgrade on login.
Legacy back-compat: when migrating existing accounts that have hashes stored without a pepper, AuthKit also tries verifying without any HMAC (the pepper-less path). On success the account is silently re-hashed with the current pepper.
The pepper is infra and lives in config (not in runtime settings). Store it in an environment variable backed by a secret manager. Losing the pepper without rotation will lock out all accounts — back it up.
Lazy rehash (transparent upgrade)
When a user logs in with a valid password, the store checks whether the stored hash needs
rehashing (e.g. bcrypt cost factor was bumped, pepper rotated, or needsRehash returned
true). If so, the new hash is persisted silently in the background — the user
experience is unchanged. This emits a password.rehashed audit event.
Legacy verifier
When migrating from another system, users' existing hashes (bcrypt with PHP's $2y$
prefix, PBKDF2, MD5, etc.) can be accepted during login until the account rehashes to the
current format. Provide a legacyVerifier:
legacyVerifier: async (hashedPassword, plainPassword) => {
// Return true → password matches (store will rehash immediately)
// Return false → wrong password
// Return null → this verifier doesn't recognize the format; fall through to native
if (!hashedPassword.startsWith('$2y$')) return null
const normalized = '$2b$' + hashedPassword.slice(4) // PHP bcrypt → Node bcrypt
const bcrypt = await import('bcrypt')
return bcrypt.compare(plainPassword, normalized)
},The legacyVerifier is called only when the native AdonisJS hasher fails the
verification. On success, the account is rehashed with the current hasher; the legacy hash
is never stored again.
Password history
Prevent users from reusing recent passwords. The feature is capability-probed: it
requires the auth_password_history table — created automatically on boot by schema
auto-management (the default) — and is enabled via the password_history runtime setting.
With schema: { autoManage: false }, call ensureAuthkitSchema(this.db) in a migration or
create it yourself:
Database setup (manual reference)
CREATE TABLE auth_password_history (
id SERIAL PRIMARY KEY, -- or UUID
account_id TEXT NOT NULL,
password_hash TEXT NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX ON auth_password_history (account_id, created_at DESC);Once the table exists, lucidAccountStoreAsync detects it automatically and mounts the
PasswordHistoryCapability. Use the async variant so the probe runs at boot:
accountStore: await lucidAccountStoreAsync({
model: () => import('#models/user'),
// …
})Enabling the setting
node ace authkit:settings:set password_history '{"enabled":true,"count":5}'Or via the Admin Console ({prefix}/settings → Passwords section).
| Field | Type | Default | Notes |
|---|---|---|---|
enabled | boolean? | false | Activate reuse prevention. No-op if the table is absent. |
count | number? | 5 | How many previous hashes to check. Minimum: 1. |
Behaviour: before a new password is accepted (on change or reset), the last count
hashes from auth_password_history are verified against the candidate. If any matches, the
password is rejected with an i18n error. After a successful change the old hash is appended
to the history and the table is pruned to keep at most count rows per account.
Fail-safe: if the history check fails (DB error), the password change is allowed —
availability is preferred over strict enforcement. authkit:doctor emits a warn if the
setting is enabled but the table is absent.
Password expiration
Force users to change their password after a configurable number of days. The feature
requires an optional password_changed_at column on the user model and is enabled via the
password_expiration runtime setting.
Database setup
// In a migration
table.timestamp('password_changed_at').nullable()The column name in camelCase is passwordChangedAt. The Lucid store detects it via
capability-probing (lucidAccountStoreAsync only).
Enabling the setting
node ace authkit:settings:set password_expiration '{"enabled":true,"maxAgeDays":90}'| Field | Type | Default | Notes |
|---|---|---|---|
enabled | boolean? | false | Activate expiration enforcement. No-op if the column is absent. |
maxAgeDays | number? | 90 | Days since last password change before a reset is required. Minimum: 1. |
Login flow: when a user with an expired password successfully authenticates (correct
credentials), they are redirected to a forced change-password step before the OIDC
interaction completes. Emits a password.expired_change_forced audit event.
Fail-safe: if the password_changed_at column is absent or the check errors, the
expiration gate is silently skipped. authkit:doctor warns if the setting is enabled but
the capability is absent.
User import command
Bulk-import users from a JSON or NDJSON file, preserving existing hashes as-is (the lazy rehash upgrades them on first login):
node ace authkit:users:import --file users.json
node ace authkit:users:import --file users.ndjson --dry-run| Flag | Notes |
|---|---|
--file <path> | Path to a JSON array or NDJSON file. |
--dry-run | Validate and count without writing to the database. |
File format — each record:
{
"email": "user@acme.com",
"password_hash": "$2b$12$...", // optional; null = no password
"name": "Alice", // optional
"email_verified": true, // optional; default false
"global_roles": ["ADMIN"] // optional
}Both formats are accepted:
[
{ "email": "alice@acme.com", "password_hash": "$2b$12$..." },
{ "email": "bob@acme.com", "password_hash": null }
]{"email":"alice@acme.com","password_hash":"$2b$12$..."}
{"email":"bob@acme.com","password_hash":null}The command is idempotent by email: duplicate emails are skipped (not overwritten).
After the import finishes it prints a summary: created, skippedDuplicate, and any
per-line errors.
React: usePasswordStrength + PasswordStrengthMeter
PasswordStrengthMeter is the whole widget: give it the password and it scores it, draws
the four-segment bar, prints the label, and lists the actionable tips. You do not compute
the score yourself and you do not pass one in.
import { useState } from 'react'
import { PasswordStrengthMeter } from '@adonis-agora/authkit-react'
function PasswordField() {
const [password, setPassword] = useState('')
return (
<>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
<PasswordStrengthMeter password={password} />
</>
)
}Reach for the usePasswordStrength hook instead when you want the score without the
markup — to disable a submit button below a threshold, say, or to render your own bar:
import { usePasswordStrength } from '@adonis-agora/authkit-react'
function SignupForm() {
const [password, setPassword] = useState('')
const { score, feedback } = usePasswordStrength(password)
return (
<>
<input type="password" value={password} onChange={(e) => setPassword(e.target.value)} />
{feedback?.map((tip) => (
<p key={tip}>{tip}</p>
))}
<button type="submit" disabled={score < 2}>
Create account
</button>
</>
)
}Swapping the scorer
The default scorer, exported as heuristicScorer, is deliberately dependency-free: it
scores by length and character-class variety. It is a fast visual cue, not a real strength
estimator. Pass a scorer to either the component or the hook to swap in something
serious, such as zxcvbn — the component forwards it straight to the hook:
import { zxcvbn } from '@zxcvbn-ts/core'
import { PasswordStrengthMeter } from '@adonis-agora/authkit-react'
const scorer = (password: string) => {
const { score, feedback } = zxcvbn(password)
return { score, feedback: feedback.suggestions }
}
<PasswordStrengthMeter password={password} scorer={scorer} />The meter is a client-side hint only. The authoritative check is the password_policy
runtime setting, enforced on the server — see Password policy.
PasswordStrengthMeter props
| Prop | Type | Default | Description |
|---|---|---|---|
password | string | — | Required. The password to score. The component derives the score itself. |
scorer | (password: string) => { score, feedback? } | heuristicScorer | Custom scorer, forwarded to usePasswordStrength. |
showFeedback | boolean | true | Render the tips list under the bar. |
labels | [string, string, string, string, string] | Very weak … Strong | Score labels, for translation. |
className | string | — | Appended to the root class, authkit-strength. |
The markup carries stable class hooks for styling: authkit-strength on the root,
authkit-strength__bar on the bar (which is role="meter" and carries data-score),
authkit-strength__segment and --filled on the segments, authkit-strength__label, and
authkit-strength__feedback on the tips list.
usePasswordStrength options
| Option | Type | Notes |
|---|---|---|
scorer | (password: string) => { score, feedback? } | Custom scorer. Defaults to heuristicScorer. |
It returns { score: 0 | 1 | 2 | 3 | 4, feedback?: string[] }, memoised on the password and
the scorer.
Audit events
| Event | When |
|---|---|
password.rehashed | A hash was transparently upgraded on login. |
password.changed | A user explicitly changed their password. |
password.expired_change_forced | Login blocked — forced change-password step triggered by expiration. |
password.common | A password was rejected by the offline common-password check (surfaced as validation error, not a separate audit event; the policy violation key is password.common). |