Agora

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:

config/authkit.ts
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}'
OptionTypeDefaultNotes
minLengthnumber8Minimum character count.
requireUppercasebooleanfalseAt least one A–Z.
requireLowercasebooleanfalseAt least one a–z.
requireNumbersbooleanfalseAt least one 0–9.
requireSymbolsbooleanfalseAt least one non-alphanumeric character.
blockCommonbooleantrueReject 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 Set is empty and the check is a silent no-op. checkPwned can 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.

FieldTypeDefaultNotes
checkPwnedbooleanfalseEnable HIBP check via the password_policy setting.
pwnedTimeoutMsnumber (config)2000Infra — 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.

config/authkit.ts
password: {
  pepper: env.get('PASSWORD_PEPPER'), // single string
}

How it works

  1. 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.
  2. On verify: the same HMAC is applied and the hash comparison runs on the peppered value.
  3. 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:

config/authkit.ts
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).

FieldTypeDefaultNotes
enabledboolean?falseActivate reuse prevention. No-op if the table is absent.
countnumber?5How 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}'
FieldTypeDefaultNotes
enabledboolean?falseActivate expiration enforcement. No-op if the column is absent.
maxAgeDaysnumber?90Days 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
FlagNotes
--file <path>Path to a JSON array or NDJSON file.
--dry-runValidate 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

PropTypeDefaultDescription
passwordstringRequired. The password to score. The component derives the score itself.
scorer(password: string) => { score, feedback? }heuristicScorerCustom scorer, forwarded to usePasswordStrength.
showFeedbackbooleantrueRender the tips list under the bar.
labels[string, string, string, string, string]Very weak … StrongScore labels, for translation.
classNamestringAppended 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

OptionTypeNotes
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

EventWhen
password.rehashedA hash was transparently upgraded on login.
password.changedA user explicitly changed their password.
password.expired_change_forcedLogin blocked — forced change-password step triggered by expiration.
password.commonA 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).

On this page