Regex for password rules with lookaheads

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

The pattern checks four rules and a length in one pass, using lookaheads. Each (?=…) stands at the start of the string and asks whether something matches ahead, without moving forward, so all four checks look at the same whole password:

(?=.*[a-z])         somewhere, a lowercase letter
(?=.*[A-Z])         somewhere, an uppercase letter
(?=.*\d)            somewhere, a digit
(?=.*[^A-Za-z\d])   somewhere, a character that is neither: a symbol or a space
.{12,}$             and 12 or more characters in all

What it accepts

The tester below checks one password per line with g and m; for a single value, drop both flags.

Correct-Horse-9        matches
correcthorsebattery    no: no uppercase, digit or symbol
Sh0rt!pw               no: 8 characters
NoDigitsHere!!         no: no digit
ALLCAPS-12345!         no: no lowercase
lower-case-1234        no: no uppercase
Tr0ub4dor&3xyz         matches
Spaces count 1A        matches: a space counts as a symbol

Note what “symbol” means here: anything that isn’t an ASCII letter or digit, so a space or an é counts. . stops at line breaks, which suits a password field. .{12,} counts UTF-16 code units, so an emoji counts as two; add the u flag to count it as one.

Better messages with separate checks

One regex can only say yes or no. Checking each rule on its own tells people what’s missing:

const RULES = [
  [/[a-z]/, 'a lowercase letter'],
  [/[A-Z]/, 'an uppercase letter'],
  [/\d/, 'a digit'],
  [/[^A-Za-z\d]/, 'a symbol'],
];

function missing(password) {
  const needs = RULES.filter(([rule]) => !rule.test(password)).map(([, need]) => need);
  if (password.length < 12) needs.unshift('at least 12 characters');
  return needs; // [] when the password passes
}

Should you have these rules at all?

NIST’s guidelines for passwords, SP 800-63B, advise against composition rules like these. They push people toward predictable patterns such as Password1!, and they don’t stop the passwords attackers try first. What helps is length, and a check against lists of passwords already leaked in breaches. Tr0ub4dor&3xyz passes this regex; a long phrase of ordinary words such as correct horse battery staple fails it and is far harder to guess.

If a policy requires the rules, the regex above implements them. If you set the policy, require a decent length, allow every character including spaces, and reject known breached passwords.

Try it in the Regex Tester

Flags

3 matches

  • The text has line breaks, and without the s flag "." doesn't match them, so ".*" stops at the end of each line. Add s if a match should run across lines.

Matches

#MatchAtGroups
1Correct-Horse-90–15none
2Tr0ub4dor&3xyz91–105none
3Spaces count 1A106–121none

What the pattern means

  • ^The start of a line
  • (?=.*[a-z])Followed by this (lookahead, which checks without consuming)
    • .*Any character except a line break, zero or more times, as many times as possible (greedy)
    • [a-z]One of: a–z
  • (?=.*[A-Z])Followed by this (lookahead, which checks without consuming)
    • .*Any character except a line break, zero or more times, as many times as possible (greedy)
    • [A-Z]One of: A–Z
  • (?=.*\d)Followed by this (lookahead, which checks without consuming)
    • .*Any character except a line break, zero or more times, as many times as possible (greedy)
    • \dA digit (0–9)
  • (?=.*[^A-Za-z\d])Followed by this (lookahead, which checks without consuming)
    • .*Any character except a line break, zero or more times, as many times as possible (greedy)
    • [^A-Za-z\d]Any one character except A–Z, a–z or a digit
      • A-ZA character from A to Z (U+0041 to U+005A)
      • a-zA character from a to z (U+0061 to U+007A)
      • \dA digit (0–9)
  • .{12,}Any character except a line break, 12 or more times, as many times as possible (greedy)
  • $The end of a line
  • g global: Find every match, not just the first. exec() and test() carry on from lastIndex.
  • m multiline: ^ and $ match at the start and end of each line, not just of the whole string.

Runs in your browser. Nothing you enter is sent anywhere.