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

Leaked passwords

Optional Have I Been Pwned check with k-anonymity: @virastack/password/pwned.

Usage

Off unless you call it. It uses the free Pwned Passwords range API; no key, no dependencies.

// components/leak-check.tsx
"use client";
 
import { useState, useTransition } from "react";
 
import { useViraPassword } from "@virastack/password";
import type { PwnedResult } from "@virastack/password/pwned";
 
import { checkPassword } from "@/app/signup/pwned-action";
 
export function LeakCheck() {
  const [result, setResult] = useState<PwnedResult | null>(null);
  const [pending, startTransition] = useTransition();
  const { inputProps, btnProps } = useViraPassword({
    inputProps: { name: "password", defaultValue: "password123" },
  });
 
  function onSubmit(event: React.FormEvent<HTMLFormElement>) {
    event.preventDefault();
    const password = String(new FormData(event.currentTarget).get("password"));
    startTransition(async () => setResult(await checkPassword(password)));
  }
 
  return (
    // POST: a submit before hydration never puts the password in the URL.
    <form method="post" onSubmit={onSubmit}>
      <label htmlFor={inputProps.id}>Password</label>
      <div>
        <input {...inputProps} />
        <button {...btnProps} />
      </div>
      <button type="submit" disabled={pending}>
        Check leaks
      </button>
 
      <p aria-live="polite">
        {result?.status === "pwned" &&
          `Seen ${result.count.toLocaleString("en")} times in data breaches.`}
        {result?.status === "safe" && "Not found in known breaches."}
        {/* Fail open: never block the user because the check failed. */}
        {result?.status === "unknown" &&
          `Could not check (${result.reason}). The form still works.`}
      </p>
    </form>
  );
}
// app/signup/pwned-action.ts
"use server";
 
import { checkPwned, type PwnedResult } from "@virastack/password/pwned";
 
// From the server, Have I Been Pwned never sees the user's IP.
// Only the first 5 characters of the SHA-1 hash are sent.
export async function checkPassword(password: string): Promise<PwnedResult> {
  return checkPwned(password, { timeout: 3000 });
}
checkPwned(password, {
  signal,         // AbortSignal
  timeout: 3000,  // ms, default 5000
  fetch,          // custom fetch (tests, proxies)
})

Result

ResultMeaning
{ status: "pwned", count }Seen count times in breaches
{ status: "safe" }Not in the list
{ status: "unknown", reason }empty, unsupported (no crypto.subtle or fetch), network, timeout, aborted, rate-limited, http

It never throws. On unknown, let the user continue (fail open).

Privacy and limits

  • Only the first 5 characters of the password's SHA-1 hash are sent; the password and the full hash never leave. Responses are padded.
  • Call it from the server. From a browser, Have I Been Pwned sees the user's IP.
  • crypto.subtle exists only on HTTPS and localhost; elsewhere the result is unknown.
  • Don't call it on every keystroke; check on submit.
PreviousServer validationNextHelpers

On this page

  • Usage
  • Result
  • Privacy and limits
Something wrong? Fix it