ViraStack
  • Academy
  • About
  • Support
HomeGet StartedExamples

Getting started

  • Introduction
  • Upgrade from 1.0
  • llms.txt
  • llms-full.txt

Usage

  • useViraPassword()
  • Strength
  • Confirm password
  • React Hook Form
  • Accessibility
  • Customization

Server

  • Server validation
  • Leaked passwords

Reference

  • Helpers
  • Types
  • UI libraries
  • Comparison

Strength

NIST-style password policy with @virastack/password/strength: length, common passwords, repeats and personal info.

definePolicy()

Define the policy once in a shared file without "use client". The form and the server import the same object.

// lib/password-policy.ts
// Shared by the form and the server, so no "use client".
import { definePolicy } from "@virastack/password/strength";
 
export const policy = definePolicy({ preset: "nist", blocklist: ["virastack"] });

Presets

PresetLengthComposition
nist (default)15–64None. Per NIST SP 800-63B-4: length and a blocklist, no composition rules
legacy8–64Lowercase, uppercase, digit and symbol

Every preset value can be changed:

definePolicy({ preset: "nist", minLength: 8 }) // with a second factor (MFA)
definePolicy({ preset: "legacy", require: { symbol: false } })
definePolicy({ blocklist: ["acme", "acme pay"] }) // your product, contained anywhere
definePolicy({ minTokenLength: 4 }) // fewer false positives for short names
definePolicy({ blockCommon: false, blockRepetitive: false })

Conflicting rules (for example minLength above maxLength) throw when the policy is defined.

Result

import { evaluate } from "@virastack/password/strength"
import { policy } from "@/lib/password-policy"
 
evaluate("correct horse battery staple", policy)
// {
//   score: 0,            // 0–1 fail the policy, 2–4 pass
//   level: "weak",       // "weak" | "fair" | "good" | "strong"
//   length: 28,          // code points after NFKC
//   valid: false,
//   checks: { minLength: true, maxLength: true, notCommon: false, ... },
//   issues: ["password.common"],
// }
KeyWhen
password.tooShort / tooLongOutside minLength–maxLength
password.noLowercase / noUppercase / noDigit / noSymbolA required class is missing (legacy)
password.commonA common password, also with digits, symbols or leetspeak around it
password.blockedContains a word from the policy blocklist
password.repetitiveRepeats or sequences: aaaa…, abcabc…, 12345…, qwerty…
password.containsPersonalInfoContains a forbidden value

The score is a heuristic from length and character variety, not a crack-time estimate. For that, see the comparison.

Why is a complex 14-character password weak? The score follows the policy, not a universal rule. The default nist preset asks for 15 characters, so anything shorter fails it and scores 0–1, however mixed it is. From 15 characters the score rises with length and variety. Need shorter passwords? Use minLength: 8 (NIST allows it with a second factor) or the legacy preset.

In the hook

Pass policy and read strength, issues and strengthProps. The hook doesn't import the engine; it comes with the policy, so a toggle-only page doesn't download it.

15 characters or more

// components/new-password-field.tsx
"use client";
 
import { useViraPassword } from "@virastack/password";
 
import { passwordMessages } from "@/lib/password-messages";
import { policy } from "@/lib/password-policy";
 
// Filled bars per level
const bars = { weak: 1, fair: 2, good: 3, strong: 4 };
 
export function NewPasswordField() {
  const { inputProps, btnProps, strength, strengthProps, issues } = useViraPassword({ policy });
  const typed = (strength?.length ?? 0) > 0;
  const filled = typed && strength ? bars[strength.level] : 0;
 
  return (
    <>
      <label htmlFor={inputProps.id}>New password</label>
      <div>
        <input {...inputProps} />
        <button {...btnProps} />
      </div>
 
      {/* The bar is yours: the hook only gives the level. */}
      <div data-level={strength?.level}>
        {[1, 2, 3, 4].map((step) => (
          <span key={step} data-filled={step <= filled} />
        ))}
      </div>
      <p {...strengthProps}>{typed ? `Strength: ${strength?.level}` : "15 characters or more"}</p>
 
      {typed && (
        <ul>
          {issues.map((issue) => (
            <li key={issue}>{passwordMessages[issue]}</li>
          ))}
        </ul>
      )}
    </>
  );
}

forbidden

The package doesn't know your user; pass the values. Matching ignores case, accents and simple leetspeak.

evaluate(password, policy, { forbidden: [email, name, username] })
// "hello@virastack.com" → "hello" (domain ignored)
// "Vira Stack" → "vira stack", "vira", "stack"

Parts shorter than minTokenLength (default 3) are ignored. Short parts cause false positives: with dev@virastack.com, the part dev rejects devoted-garden-path. Raise it to 4 if that matters to you.

Try vira-garden-path

// components/new-password-field.tsx
"use client";
 
import { useState } from "react";
 
import { useViraPassword } from "@virastack/password";
 
import { passwordMessages } from "@/lib/password-messages";
import { policy } from "@/lib/password-policy";
 
export function NewPasswordField() {
  const [email, setEmail] = useState("hello@virastack.com");
  const [name, setName] = useState("Vira Stack");
 
  // Email: the local part and its pieces ("hello"). Name: each word ("vira", "stack").
  const { inputProps, btnProps, issues, strengthProps } = useViraPassword({
    policy,
    forbidden: [email, name],
  });
 
  return (
    <>
      <label htmlFor="forbidden-email">Email</label>
      <input
        id="forbidden-email"
        value={email}
        onChange={(event) => setEmail(event.target.value)}
      />
 
      <label htmlFor="forbidden-name">Name</label>
      <input id="forbidden-name" value={name} onChange={(event) => setName(event.target.value)} />
 
      <label htmlFor={inputProps.id}>New password</label>
      <div>
        <input {...inputProps} />
        <button {...btnProps} />
      </div>
 
      <p {...strengthProps}>
        {issues.includes("password.containsPersonalInfo")
          ? passwordMessages["password.containsPersonalInfo"]
          : "Try vira-garden-path"}
      </p>
    </>
  );
}

Unicode

  • The password is normalized with NFKC, so the same text typed on different keyboards gives the same result.
  • Length counts code points: an emoji or a Turkish letter counts once.
  • Zero-width characters don't count towards the length.
  • Nothing is cut at maxLength; a longer password gets password.tooLong.

Paste

Don't block pasting into password fields. Password managers depend on it, and NIST recommends allowing it. The hook never blocks paste.

PrevioususeViraPassword()NextConfirm password

On this page

  • definePolicy()
  • Presets
  • Result
  • In the hook
  • forbidden
  • Unicode
  • Paste
Something wrong? Fix it