The developer's guide to strong passwords
Why your password policy is probably wrong, how attackers actually crack passwords, and what the math says you should do instead.
Most password policies were written by someone who read a 2003 security paper and never updated their priors. The result: users cycling through Password1!, Password2!, Password3! across your sign-up form, while your security posture stays essentially unchanged.
This guide is for developers building authentication systems. We'll go deep on the cryptography, real-world attack vectors, NIST SP 800-63B guidelines, and how to implement password policies that actually protect users (without driving them insane).
First, understand how passwords actually get cracked
Before you can write a sensible policy, you need to understand your adversary. Modern password cracking does not involve someone manually guessing passwords one at a time. It involves GPUs running billions of guesses per second against an offline hash dump.
The threat model has two distinct phases:
- Online attacks: An attacker sends login requests to your live system. Rate limiting and lockout policies make this expensive. A slow bcrypt check combined with CAPTCHA and exponential backoff can reduce attempts to a few hundred per hour (effectively neutered).
- Offline attacks: An attacker has obtained a copy of your password hash database (via SQLi, a breach, an insider, a misconfigured S3 bucket). They can now run full GPU-powered cracking locally, completely off your radar.
The offline scenario is the one that kills you. A modern rig with 8× RTX 4090 GPUs can test ~164 billion MD5 hashes per second. Even bcrypt at cost factor 12 drops to ~6,800 hashes/second per GPU. That's still 24 million per hour, and attackers don't stop after an hour.
This is the problem you need to solve: your password policy needs to make passwords resistant to offline cracking. Online brute force is the easy problem. Offline cracking after a breach is the hard one.
The entropy math: why length wins
Password strength is fundamentally about entropy: the number of bits of randomness an attacker must search through. The formula is straightforward:
Now let's compare two passwords that typical policies produce:
This is precisely why NIST SP 800-63 explicitly recommends that services stop mandating complexity rules and start prioritizing length.
The intuition is mathematical: each additional character multiplies the search space. Adding special character requirements grows the base slightly, but minimum length requirements grow the exponent dramatically. Exponents always win.
What NIST SP 800-63 actually says
Requirements (SHALL)
- Minimum 8 characters: though NIST explicitly recommends targeting 15+.
- Support passwords up to at least 64 characters. Silent truncation is explicitly prohibited.
- Accept all printable ASCII and Unicode characters, including spaces. A passphrase like my dog ate my homework must be valid.
- Check new passwords against breached credential lists. Reject passwords found in known breach datasets.
- Never silently truncate or modify passwords. If bcrypt's 72-byte limit applies, surface it explicitly.
Prohibitions (SHALL NOT)
- Require specific character types (uppercase, digits, symbols).
- Force periodic password rotation without evidence of compromise.
- Use knowledge-based authentication (mother's maiden name, etc.).
!!⚠️ If your current policy requires "at least one uppercase, one number, and one symbol," you're out of compliance with current NIST guidelines, and more importantly, you're making your users less secure by encouraging predictable substitutions.!!
Why complexity requirements backfire
Human brains are poor random number generators but excellent pattern recognizers. When forced to include a number and symbol, users reliably do the same things:
- Append digits:
password1,hunter2,sunshine99 - Leet-speak substitutions:
p@ssw0rd,s3cur1ty,@dm1n - Capitalize only the first letter:
Password1!,Welcome1! - Append a symbol at the end:
password!,password#1
Password crackers know this. Tools like Hashcat include rules engines (automated transformations applied to dictionary words) that cover these exact patterns. The popular OneRuleToRuleThemAll ruleset contains tens of thousands of such transforms:
Password1! is effectively a dictionary word to a modern cracker, despite satisfying most complexity requirements. Meanwhile correcthorsebatterystaple fails those requirements but has far higher real-world entropy and cannot be defeated with dictionary attacks.
Stop forcing password rotation
The logic behind mandatory rotation seems sound: if an attacker steals your password, forcing you to change it every 90 days limits how long they can use it. In practice, the policy defeats itself.
The problem is that humans don't generate a fresh random password every quarter; they mutate the old one. Given the requirement to change Summer2024!, the overwhelming majority of users produce Fall2024!, Summer2025!, or Summer2024!!. These transformations are so predictable that Hashcat's rules engine covers them explicitly:
The result is a sequence of passwords that are cryptographically distinct but semantically identical. If an attacker cracks one, they can derive the next with trivial effort. You've added process overhead for users and IT, while providing no meaningful security improvement.
This isn't speculation. In "The Security of Modern Password Expiration: An Algorithmic Framework and Empirical Analysis" (UNC Chapel Hill, ACM CCS 2010), researchers analyzed password histories from over 7,700 defunct university accounts, each subject to mandatory 90-day rotation. They found that knowing a user's previous password allowed them to guess the next one in fewer than 5 attempts for 17% of accounts, and crack it within 3 seconds of offline attacking for 41% of accounts. Users were following the same predictable transformation patterns so reliably that the researchers were able to build an algorithm to exploit them systematically.
You've added process overhead for users and IT, while providing no meaningful security improvement against the attacker who already has your hash database.
What actually limits exposure from a stolen credential
Periodic rotation was trying to solve a real problem: a stolen password is useful indefinitely unless changed. But there are much more effective mitigations:
- Breach monitoring: Services like HIBP notify you when credentials appear in breach datasets. Rotate then, not on a calendar.
- Session management: Short-lived tokens and active session invalidation mean a stolen password doesn't grant indefinite access. If you can revoke sessions, a stolen password becomes far less valuable.
- MFA: A second factor means a stolen password alone isn't sufficient. Rotation becomes nearly irrelevant if TOTP or a passkey is also required.
NIST's position is that rotation should be triggered by evidence of compromise, not by the calendar. If you're implementing an enterprise auth system and a compliance team is pushing for 90-day rotation, point them to section 3.1.1 of SP 800-63B directly. It's explicit.
The breach list check: your most impactful defense
NIST's requirement to check passwords against breach datasets is arguably the single most impactful policy you can implement. Password reuse is endemic: studies consistently find 40–70% of users reuse passwords across sites. When any service is breached, those credentials land in combo lists that attackers use to credential-stuff every other service on the internet.
Checking against a breach list stops this attack cold at registration time.
Implementation: Have I Been Pwned API
The HIBP Pwned Passwords API uses a k-anonymity model, so you never transmit the full hash to an external service:
The API returns all hashes matching that prefix; neither HIBP nor any eavesdropper learns which specific password was checked. You can also download the full dataset (~15 GB) and run this locally for zero latency and no external dependency.
Domain-specific blocklist
HIBP covers the broad universe of breached credentials, but it won't know that your company is called Acme Corp or that your product is called Anvil. Attackers targeting your service specifically will try these first; and so will lazy users setting their password to your brand name followed by 123.
Maintain a custom blocklist that grows over time. Seed it with your product and company names, any terms specific to your domain (a fintech app might add trading, portfolio, stocks), and the patterns your support team keeps seeing in tickets. Keyboard walks like qwerty and 1q2w3e4r belong here too; they satisfy a minimum length requirement while providing essentially no entropy.
Keep the list lowercase and normalize input before checking; users don't get to bypass it with ACMECORP or AcmeCorp123. And treat the list as a living document: any time a breach or a support ticket reveals a new pattern your users are gravitating toward, add it.
Hashing: what to use and how to configure it
Password storage is not encryption; you should never be able to recover a plaintext password. It's one-way hashing, but crucially it needs to be slow hashing, specifically designed to resist GPU-accelerated brute force.
The speed numbers tell the story:
- MD5 and SHA-256 were designed to be fast: that's a feature for checksums and integrity verification, and a catastrophic flaw for password storage.
- The cost factor in bcrypt, scrypt, and Argon2id is the entire point: it makes each individual hash computation expensive, so an attacker with a GPU farm is fighting the algorithm's intentional slowness rather than just your users' password entropy.
The difference between "memory-hard" and plain slow is also worth understanding:
- bcrypt is slow but not memory-intensive: an attacker can run many bcrypt threads in parallel on a GPU with minimal memory overhead.
- scrypt and Argon2id require large amounts of RAM per hash, which limits parallelism more aggressively. A GPU with 24 GB of VRAM that could run thousands of bcrypt threads simultaneously might only manage dozens of Argon2id threads.
That's the memory-hardness advantage.
Don't forget the salt
A salt is a random value generated per-password and stored alongside the hash. Before hashing, the salt is concatenated with the plaintext password, so two users with the same password produce completely different hashes.
Without salting, an attacker who obtains your hash database can use rainbow tables (precomputed lookup tables mapping hashes back to plaintexts) to crack thousands of passwords simultaneously in a single pass. With unique salts, that precomputation is worthless. The attacker is forced back to brute-forcing each account individually.
There's a second benefit: salting means identical passwords don't produce identical hashes in your database. Without it, an attacker who cracks one hash has cracked every account using that password. With salting, each account must be attacked independently.
The practical implication: never implement salting yourself. Argon2id, bcrypt, and scrypt all generate and store a cryptographically random salt automatically as part of the hash output. The hash string they produce looks something like this:
The salt is embedded in the output. You store the whole string, and the library extracts it automatically on verification. If you're rolling your own salting logic on top of SHA-256 or similar, stop. Use a purpose-built password hashing function that handles it for you.
The one thing to verify: that your salt is generated with a cryptographically secure random number generator.
Math.random() is not. Node's crypto.randomBytes() is.
Fortunately, if you're following the recommendation in this guide and using Argon2id, this is handled for you: the library generates a cryptographically secure random salt on every call to hash(), embeds it in the output string, and extracts it automatically on verification. Which brings us to how to configure it correctly.
Argon2id configuration
Argon2id is the current recommendation. OWASP's guidance specifies these minimum parameters:
Calibrate memoryCost and timeCost so hashing takes ~500ms on your production hardware. This is imperceptible to a legitimate user logging in once, but crippling for an attacker running billions of guesses.
The bcrypt 72-byte silent truncation trap
bcrypt silently truncates passwords at 72 bytes. A user setting a 100-character password has only the first 72 bytes hashed; the rest discarded without any warning. More critically, 'a' * 72 and 'a' * 72 + 'b' produce identical hashes. If you're using bcrypt: enforce a visible max-length of 72 bytes in your UI, or pre-hash with SHA-256 before passing to bcrypt.
If you're starting fresh, use Argon2id and skip the problem entirely.
Putting it together: a complete validation function
Notice what's absent: no complexity requirements. No "must include at least one uppercase letter." No character-type counting.
The policy is:
- Long enough.
- Not breached.
- Not blocklisted.
That's it.
UX: helping users choose strong passwords
Policy alone isn't enough. Users need feedback. A strength meter that reflects real entropy is more useful and less alienating than a checklist of requirements.
zxcvbn: realistic strength estimation
Dropbox's zxcvbn library estimates strength by pattern-matching against dictionaries, keyboard walks, and common sequences; the same approach a cracker uses. password1! scores 0/4 despite satisfying most complexity rules; correct horse battery staple scores 4/4.
Show estimated crack time, not just a score. "This password would take approximately 3 hours to crack" is more motivating than a bar shifting from red to green.
Passphrase suggestions
Four random words from a large list produce ~52 bits of entropy and are far more memorable than a random character string. The EFF's word list (7,776 words, designed for dice-roll selection) is a solid foundation:
Rate limiting and account lockout
Even strong passwords need defense-in-depth against online brute force:
- Exponential backoff: After N failed attempts, increase the delay. Start at 1s, double each time, cap at 15–30 minutes.
- Temporary lockout: After 10 consecutive failures, lock the account for 15 minutes. Notify the account owner via email; silent lockouts create support tickets and erode trust.
- Per-IP rate limiting: Combine with per-account limits. NATs and shared proxies can make many legitimate users share one IP, so IP-only limits cause false positives.
- Progressive CAPTCHA: Trigger after 3–5 failed attempts, not from the first request. Frictionless for legitimate users; expensive for bots.
- Credential stuffing detection: Look for high error rates across many different accounts from the same IP or ASN (a distinct signature from single-account brute force).
!!Account lockout is a double-edged sword: it protects against brute force but enables denial-of-service against legitimate users. Never hard-lock indefinitely. Always use time-based lockouts and notify the account owner.!!
Beyond passwords: when to push toward passwordless
The best password policy is one you don't need to enforce because users aren't using passwords. Passkeys (WebAuthn), magic links, and OIDC-based SSO eliminate the password problem at the root.
Passkeys are worth prioritizing for new auth flows. They're phishing-resistant by design (credentials are cryptographically bound to the specific origin), require no server-side secret storage, and browser and OS support is now broad enough for most applications.
That said, passwords aren't going anywhere. Many users still expect them, and B2B applications often must support devices without passkey capability. The policies in this guide are for building the most defensible possible password-based auth. Not as a permanent destination, but as a sound position while the ecosystem matures.
The honest truth is that getting all of this right is a lot of surface area to own: breach list checks, Argon2id tuning, rate limiting, session invalidation, passkey support, credential stuffing detection. Each piece is tractable on its own, but keeping the whole stack correctly configured (and updated as recommendations evolve) is a continuous investment. That's the core argument for delegating auth to a purpose-built platform.
WorkOS handles the full authentication stack out of the box, including passkeys, SSO, MFA, and the kind of bot and fraud detection that makes credential stuffing someone else's problem. If you'd rather ship features than maintain an auth layer, it's worth a look.
Implementation checklist
- Minimum password length of 12 characters.
- Maximum length of at least 64 characters (128 recommended).
- Accept all Unicode characters, including spaces.
- Check new/changed passwords against HIBP Pwned Passwords.
- Maintain a domain-specific blocklist.
- Remove complexity requirements (uppercase, symbols, digits).
- Remove forced periodic rotation.
- Use Argon2id with OWASP-recommended parameters (or bcrypt cost 12+).
- Per-account rate limiting with exponential backoff.
- Realistic strength meter using zxcvbn, not a checkbox counter.
- Surface passphrase suggestions for users who want guidance.