TechEarl

How to Validate Password Strength with Regex

Password format validation with regex: length, character classes, full-string checks, Unicode, and the limits of regex for assessing strength.

Ishan Karunaratne⏱️ 10 min readUpdated
Share thisCopied
Password format validation with regex: length, character classes, full-string checks, Unicode, and the limits of regex for assessing strength.

A password validation regex checks a format policy; it cannot measure password strength. A predictable password such as Password1! can pass lowercase, uppercase, digit, and symbol checks. Use these patterns when you must implement an existing policy, not as proof that a password is safe.

For a new policy, NIST SP 800-63B-4 requires at least 15 characters for single-factor passwords, permits a minimum of 8 when the password is only part of MFA, and rejects mandatory character-composition rules. It recommends supporting at least 64 characters and requires screening against a blocklist of common, expected, or compromised passwords. Those requirements extend beyond regex.

Below are format-checking examples, including JavaScript, Python, and PHP. Apply the policy on the server too, never log candidate passwords, and keep password hashing and login rate limiting separate from input-format checks.

Quick reference

Minimum 8 characters, no other rules:

code
^.{8,}$

8+ characters with at least one lowercase, one uppercase, one digit:

code
^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).{8,}$

A legacy composition-policy pattern (lower, upper, digit, special, 8+):

code
^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[^\w\s]).{8,}$

For a length-first policy, count characters in application code and perform the server-side blocklist check. There is no single “NIST-compliant password regex”; authentication context and the rest of the verifier requirements matter.

Length-only patterns

Start simple. Length is the most important factor in password strength.

code
Exactly 8 chars:           ^.{8}$
8 to 10 chars:             ^.{8,10}$
8 or more chars:           ^.{8,}$
12 or more chars:          ^.{12,}$

The . matches any character except newline. To exclude whitespace as well, use ^\S{8,}$. To allow only specific characters, replace . with a character class:

code
Lowercase letters, 8+:     ^[a-z]{8,}$
Letters (upper/lower), 8+: ^[a-zA-Z]{8,}$
Alphanumeric, 8+:          ^[a-zA-Z0-9]{8,}$

These restrict the allowed characters but say nothing about which classes must appear. For that, you need lookaheads.

Lookaheads for "must contain"

A lookahead (?=...) is a zero-width assertion: it checks the rest of the pattern can be satisfied starting from the current position, without consuming any characters. Chained at the start of a pattern, lookaheads enforce "must contain" rules without restricting the order in which characters appear.

Require at least one uppercase letter:

code
^(?=.*[A-Z])[a-zA-Z]{8,}$

Reading left to right:

  • ^ anchors to the start.
  • (?=.*[A-Z]) is the lookahead. It scans the whole string (because .* is greedy) looking for at least one uppercase letter. If found, the lookahead passes; the engine doesn't advance.
  • [a-zA-Z]{8,}$ then matches the actual content: 8 or more letters.

Require at least one lowercase AND one uppercase:

code
^(?=.*[a-z])(?=.*[A-Z])[a-zA-Z]{8,}$

Two lookaheads, both must succeed.

Require lowercase, uppercase, AND digit:

code
^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)[a-zA-Z0-9]{8,}$

Add a fourth class (special characters):

code
^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[!@#$%^&*])[a-zA-Z0-9!@#$%^&*]{8,}$

Or accept any non-word character as "special":

code
^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[^\w\s]).{8,}$

The . at the end is intentional: it lets the password contain any character (including Unicode), as long as the four lookahead rules pass. The uppercase and lowercase requirements here are ASCII-only. Unicode input can be accepted without satisfying those particular classes.

For more on how lookahead semantics work, see regex lookaheads and lookbehinds.

Counting required occurrences

To require N occurrences of a character class, use a counted lookahead. Require at least 3 uppercase letters:

code
^(?=(?:.*[A-Z]){3}).{8,}$

The (?:.*[A-Z]){3} is "any characters, then uppercase letter" repeated 3 times. This counts non-overlapping occurrences.

To require exactly 3 uppercase letters (no more), pair the positive lookahead with a negative lookahead:

code
^(?=(?:.*[A-Z]){3})(?!(?:.*[A-Z]){4}).{8,}$

The negative lookahead (?!(?:.*[A-Z]){4}) fails if 4 or more uppercase letters can be found. This is the trick most "exactly N" patterns use.

Require minimum 3 lowercase AND minimum 2 uppercase:

code
^(?=(?:.*[a-z]){3})(?=(?:.*[A-Z]){2}).{8,}$

Forbidden patterns

To reject specific weak patterns, add negative lookaheads.

Reject passwords containing the word "password":

code
^(?!.*password).{8,}$

Reject passwords that are entirely numeric:

code
^(?!^\d+$).{8,}$

Reject passwords where the same character repeats 3+ times in a row:

code
^(?!.*(.)\1{2}).{8,}$

The (.)\1{2} matches any character followed by the same character twice more (using a backreference). Wrapped in a negative lookahead, it rejects passwords containing aaa, 111, !!!, etc.

Examples in JavaScript, Python, and PHP

JavaScript:

javascript
const FORMAT = /^(?=.*[a-z])(?=.*[A-Z])(?=.*[0-9])(?=.*[^\w\s]).{8,}(?![\s\S])/u;

function matchesPasswordPolicy(input) {
  return typeof input === "string" && FORMAT.test(input);
}

matchesPasswordPolicy("Password1!");     // true
matchesPasswordPolicy("password1!");     // false (no uppercase)
matchesPasswordPolicy("PASSWORD1!");     // false (no lowercase)
matchesPasswordPolicy("Password!");      // false (no digit)
matchesPasswordPolicy("Password11");     // false (no special)
matchesPasswordPolicy("Pa1!");           // false (too short)

Python:

python
import re

FORMAT = re.compile(r"^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[^\w\s]).{8,}$")

def matches_password_policy(value: str) -> bool:
    return bool(FORMAT.fullmatch(value))

matches_password_policy("Password1!")    # True
matches_password_policy("short1A!")      # True (exactly 8 chars, all classes)
matches_password_policy("alllowercase1!") # False (no uppercase)

PHP:

php
function matchesPasswordPolicy(string $value): bool {
    $pattern = '/\A(?=.*[a-z])(?=.*[A-Z])(?=.*[0-9])(?=.*[^\w\s]).{8,}\z/u';
    return preg_match($pattern, $value) === 1;
}

matchesPasswordPolicy("Password1!");      // true
matchesPasswordPolicy("nodigit!Aaaa");    // false (no digit)

Engine compatibility

JavaScript, Python re, and PHP/PCRE2 support the lookaheads used above. Go's regexp and Rust's regex crate do not; check each requirement separately in those engines.

DetailJavaScriptPython 3 string patternsPHP/PCRE2
ASCII digits[0-9][0-9][0-9]
Meaning of \dASCII digitsUnicode decimal digits by defaultDepends on UCP mode
Strict whole-string validationStart anchor and (?![\s\S]); no m flagre.fullmatch()\A and \z
Unicode length caveatUse u for code-point regex matchingPython strings count code pointsUse UTF mode for valid UTF-8 input

A code point is not always one displayed character. Keep normalization and length counting consistent at registration and login. Do not silently trim or truncate a password.

A Go implementation can use utf8.RuneCountInString(s) for the code-point length and separate character-class checks. len(s) counts bytes, which is different for non-ASCII input. For user-facing validation, separate checks also let you explain which policy condition failed.

Common mistakes

The bugs I've shipped or seen in code review.

Accepting only a substring. Use a whole-string check. In Python and PCRE, $ may match before a final newline; multiline mode also changes anchors. The executable examples above use strict ending checks. Do not add a JavaScript g or m flag to the validator.

Putting the character-class restriction inside the lookahead's content. A pattern like (?=[a-z]*[A-Z]) requires the uppercase letter to be after zero or more lowercase letters, which rejects a digit or symbol before the uppercase letter even if the rest of the password satisfies your policy. Use (?=.*[A-Z]) (any chars, then uppercase) so order doesn't matter.

Trying to reject Unicode by accident. A pattern that uses [a-zA-Z] rejects Über1! because Ü isn't in the class. If you want to accept Unicode, use . for the content match and let the lookaheads enforce the rules. The pattern in this article does exactly that.

Letting the regex enforce a maximum length. A maximum-length cap (^.{8,64}$) is fine, but rejecting long passwords (e.g., ^.{8,16}$) actively hurts security. NIST 800-63B recommends accepting passwords up to at least 64 characters.

Confusing format with strength or storage. A regex confirms only that the input satisfies the chosen format rules. It does nothing about storage. Hash with bcrypt, scrypt, or argon2 (never MD5 or SHA-256 alone). For application-specific guides, see the storage notes below.

Treating regex as a substitute for breach checks. A user can set a "strong-looking" password like Password1! that may appear in breach datasets. For real security, check the password against the HaveIBeenPwned k-anonymity API before accepting it.

Test cases

InputStrong pattern resultReason
Password1!MatchAll four classes present, length 10
P@ssword1Match@ is the special character
password1!No matchNo uppercase
PASSWORD1!No matchNo lowercase
Password!No matchNo digit
Password11No matchNo special character
Pa1!No matchToo short (length 4)
Correct Horse 1!MatchSpaces allowed by .
Über stark 1!No matchÜ is not in the ASCII uppercase class [A-Z]

FAQ

One legacy composition-policy example is: ^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[^\w\s]).{8,}$. It requires at least one lowercase, one uppercase, one digit, one non-word non-whitespace character, and a length of 8 or more.

The four (?=...) lookaheads enforce "must contain" without restricting the order of characters. The . at the end allows any character (including Unicode), so the password isn't artificially limited.

Add the lookahead (?=.*\d) at the start of the pattern. \d matches a digit; .* means "anywhere in the string"; the lookahead doesn't consume any characters so it composes cleanly with other rules.

To require exactly N digits, use (?=(?:\D*\d){N}\D*$): skip any number of non-digits, match a digit, repeat N times, then anchor to a non-digit-only tail.

An upper cap of around 64-128 characters is fine for protecting against denial-of-service from absurd inputs. Setting a much smaller maximum can exclude useful passphrases because long passwords (like passphrases) are much stronger than short complex ones when chosen unpredictably; length alone is not a strength guarantee.

NIST 800-63B specifically recommends accepting passwords up to at least 64 characters. Any "max 12" or "max 20" policy you see is either legacy or based on outdated advice.

No. The regex validates the input format only. Password storage is a separate problem: hash with bcrypt, scrypt, or argon2 (never MD5, SHA-256 alone, or any encryption). The hash includes a salt; you store the hash, not the password.

For application-specific guides, see the "stored safely" section below.

Because Go's regexp and Rust's regex crate do not include lookarounds in their supported syntax.

The workaround is to run separate regexes per requirement (one for each character class, plus a length check) and combine the booleans in code. Cleaner code anyway. Count Unicode characters consistently with the rest of your application.

Add a negative lookahead at the start: ^(?!.*password)(?!.*123456).{8,}$. Each (?!...) fails the match if the forbidden substring appears anywhere.

For "must not contain the username", you need a variable in the pattern, which is easier to do in code than regex: check password.toLowerCase().includes(username.toLowerCase()) separately and combine with the regex result.

No. Password1! passes a four-class regex but is predictable and may appear in breach datasets. For real security, run the candidate password through a breach check like the HaveIBeenPwned k-anonymity API before accepting it.

A format check and a blocklist check answer different questions. Neither guarantees that a password is unguessable.

Where the password gets stored

The regex enforces the password format at input time. The password also has to be stored safely. For application-specific guides:

See also

External reference: NIST Special Publication 800-63B-4 is the current authoritative guidance on password policies. It recommends length over complexity, breach checks, and against forced periodic resets.

Sources

Authoritative references this article was fact-checked against.

TagsRegexRegular ExpressionsPassword ValidationSecurityLookaheadJavaScriptPythonPHP

Found this useful? Pass it on.

Copied

Ishan Karunaratne

Systems and Network Architect · Chief Technology Officer

Systems and network architect and Chief Technology Officer with more than two decades designing, building, and running production software, cloud and network architecture, Linux systems, and the bare metal underneath them, and lately working AI into the stack. A US Army veteran who served in Operation Iraqi Freedom. What I write here is drawn from the full arc of that work, across architecture, engineering, and operations, not any single job.

Keep reading

Related posts

Validate a US phone number with regex. Practical pattern, stricter NANP pattern, JavaScript / Python / PHP examples, what it lets through, common mistakes, and a test table.

How to Validate a US Phone Number with Regex

Validate a US phone number with regex. The practical pattern, a stricter NANP version, runnable examples in JavaScript, Python, and PHP, what it still lets through, common mistakes, and a test table.

Match a URL with regex. http/https schemes, protocol-relative URLs, ports, paths, query strings, fragments. JavaScript / Python / PHP examples, engine notes, parser alternative, common mistakes, test table.

How to Match a URL with Regex

Match a URL with regex. Covers http/https schemes, protocol-relative URLs, ports, paths, query strings, fragments, runnable JavaScript / Python / PHP, engine notes, and the URL parser alternative.