DEV Community

Cover image for Password1! passes your password rules. Here's what NIST actually asks for
rahul patwa
rahul patwa

Posted on

Password1! passes your password rules. Here's what NIST actually asks for

Here's a password most sign-up forms would accept:

Password1!
Enter fullscreen mode Exit fullscreen mode

8 characters, an uppercase letter, a lowercase letter, a number and a symbol. Every box ticked. It's one of the first things anyone trying to break into your account will guess.

I maintain a small React library for password validation, use-password-policy . For a long time it did exactly that: counted character types and turned the strength bar green. This year I rewrote it around what the current guidelines actually say. This post covers what I learned, and how to enforce a sensible policy in a React form and on your server without the two drifting apart.


What NIST says in 2025

NIST SP 800-63B is the US government's guide to authentication, and a lot of companies follow it. Revision 4 was finalised in August 2025. For passwords, the key requirements are:

  • Length is what matters. Passwords used on their own "SHALL" be at least 15 characters. If the password is one factor of multi-factor login, the minimum is 8.

  • Allow long passwords. Verifiers "SHOULD permit a maximum password length of at least 64 characters".

  • No composition rules. Verifiers "SHALL NOT impose other composition rules (e.g., requiring mixtures of different character types)".

  • Check a blocklist. Verifiers "SHALL compare the prospective secret against a blocklist that contains known commonly used, expected, or compromised passwords."

The third point surprises people. The "one uppercase, one number, one symbol" rule is so common it feels like best practice, but in practice people meet it the same predictable way: capital first letter, 1 or the year at the end, ! after that.

So a NIST-style policy is short:

  1. At least 15 characters (or 8 with MFA), and at least 64 allowed.

  2. No character-type rules.

  3. Reject passwords that are common, predictable, or already leaked.

Points 1 and 2 are easy. Point 3 is where the work is.


Three kinds of bad password

Common passwords

password, qwerty123, iloveyou, P@ssw0rd. A small blocklist catches the worst of these. It needs to catch the decorated versions too (Password123!, p@ssw0rd, 123qwerty), or people add a 1! and walk straight past it.

Predictable patterns

This is where my initial implementation fell short. I shipped the NIST presets, then sat down and tried to break them by hand. All of these passed a 15-character minimum and my blocklist:

aaaaaaaaaaaaaaa

123456789012345

qwertyqwertyqwerty

passwordpassword

(15 spaces)

πŸ”₯πŸ”₯πŸ”₯πŸ”₯πŸ”₯πŸ”₯πŸ”₯πŸ”₯

Enter fullscreen mode Exit fullscreen mode

The last one is sneaky. In JavaScript, 'πŸ”₯'.length is 2, because most emoji take two UTF-16 code units. 8 emoji passed a 15-character minimum. NIST says each Unicode code point should count as one character, so length should be Array.from(password).length, not password.length.

The others needed a pattern check: repeated characters, repeated chunks, sequences and keyboard runs, and passwords made of only a few distinct characters. After fixing them, I ran 4,000 random passwords and passphrases through the NIST preset. The only one rejected was lemon lemon lemon, which deserved it.

Leaked passwords

No blocklist you ship can cover hundreds of millions of leaked passwords. Have I Been Pwned can, and it's free. When I tested it, passwordpassword came back as seen 50,572 times in breaches.

The worry is always "I'm sending my users' passwords to a third party?" You're not. The Pwned Passwords API uses k-anonymity:

  1. You SHA-1 hash the password in the browser.

  2. You send only the first 5 characters of the hash.

  3. The API returns every leaked hash starting with those 5 characters, several hundred of them.

  4. You look for your full hash in that list, locally.

The service never sees the password, or even the full hash.


The bug that isn't in your checklist

Even with good rules, there's a problem that has nothing to do with passwords: your client and server disagree.

The form has rules written in React. The API has its own rules, written months earlier by someone else, maybe with a different minimum. Somebody updates one and not the other. Now the form says βœ“ and the API returns a 400. Or worse, the form enforces a rule the API doesn't, and anyone who calls the API directly skips it.

So define the policy once, and use the same code in both places.


Doing it in React and on the server

Here's the whole setup with use-password-policy.

One shared policy file:


// password-policy.ts, imported both by the client and the server

import { presets, type PasswordPolicyOptions } from 'use-password-policy/core';

export const policy: PasswordPolicyOptions = {

...presets.nist, // 15–64 chars, no composition rules, blocks common passwords + patterns

breachCheck: true, // Have I Been Pwned, as part of the result

};

Enter fullscreen mode Exit fullscreen mode

The form:


import { usePasswordPolicy } from 'use-password-policy';

import { policy } from './password-policy';

export function SignUp() {

const [password, setPassword] = useState('');

const { isValid, requirements } = usePasswordPolicy({ ...policy, password });

return (

setPassword(e.target.value)} />

{requirements.map((r) => (

{r.message}

))}

Create account

);

}

Enter fullscreen mode Exit fullscreen mode

The breach check runs once the other rules pass, debounced as the user types. Until it answers, its requirement is pending and isValid stays false, so the button can't be pressed with a leaked password. If you'd rather not build the UI, there's a drop-in with a strength meter and an accessible checklist.

The API route:

import { validatePasswordAsync } from 'use-password-policy/core';
import { policy } from './password-policy';
export async function POST(req: Request) {const { password } = await req.json();
const { isValid, errors } = await validatePasswordAsync(password, policy);
if (!isValid) return Response.json({ errors }, { status: 400 });
// …create the user}
Enter fullscreen mode Exit fullscreen mode

use-password-policy/core doesn't import React, so it runs in Node, edge functions or a Cloudflare Worker. The result has the same shape and the same messages as the hook, so the errors your API returns match what the form showed.

If you use Zod or react-hook-form, there are helpers that plug the same policy in:


const schema = z.object({ password: z.string().superRefine(zodPasswordRuleAsync(policy)) });


register('password', { validate: passwordValidatorAsync(policy) });

Enter fullscreen mode Exit fullscreen mode

One decision you have to make: what happens when Have I Been Pwned is unreachable? By default the password is let through (failOpen), so an outage doesn't block every sign-up. The result still tells you the check failed, so you can log it. If you'd rather block, set breachCheck: { failOpen: false }.


What about strength meters?

A checklist can't tell Password1! from a strong password. If you want a strength meter that means something, use a real estimator like zxcvbn . It recognises dictionary words, names, dates, keyboard patterns and leetspeak. It's a large dependency, so the library doesn't bundle it; you plug it in with one line if you want it.


Try to break it

I built the demo page as a live test report. Type a password and it runs through the React hook and the server function side by side, and prints whether they agree. Switch to the NIST preset and try to get something weak through.

If you manage it, please open an issue . That's how the pattern check above came about.


npm i use-password-policy

Enter fullscreen mode Exit fullscreen mode

Top comments (0)