Password rules with regex lookaheads (and when not to)
How to check \"at least one uppercase letter, one digit, eight characters\" in a single regex with lookaheads, why the order-free version needs them, the traps in the common patterns, and why checking each rule separately is often the better design.

· 5 min read
/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).{8,}$/
match
- Secret123
- aB3defgh
- Tr0ub4dor
- 9Lives_cat
spare
- secret123
- SECRET123
- Secretpass
- Ab1
"At least 8 characters, with an uppercase letter, a lowercase letter and a digit." It's one of the most common things people ask a regex to do, and it's a good way to learn lookaheads, because the rules don't say where each character goes.
This post builds the pattern one rule at a time, covers the traps, and ends with a case for not using one big regex at all. Every example is live, so edit the patterns and watch the lines change.
/^ (?=.*[a-z]) (?=.*[A-Z]) (?=.*\d) .{8,}$/At least 8 characters, with at least one lowercase letter, one uppercase letter and one digit, in any order.
Accepts
- Secret123
- 9Lives_cat
- aB3defgh
Rejects
- secret123
- SECRET123
- Ab1
01Why a plain pattern can't do it
A pattern reads left to right. To say "contains a digit" you'd write something like .*\d.*, and that's fine on its own. But "contains a digit and contains a capital" in any order turns into listing every order:
/^(.*[A-Z].*\d.*|.*\d.*[A-Z].*)$/That doesn't scale. You need a way to check a rule without moving through the text.
02Lookaheads check without moving
A lookahead (?=…) checks that something matches from the current position, then goes back to where it started. So several lookaheads at the start can each scan the whole password, one rule each.
One rule: "somewhere ahead there's a digit":
The .* before \d is what makes "somewhere" work. Without it, (?=\d) would mean "the very next character is a digit".
03Stacking the rules
Each rule gets its own lookahead, all anchored at the start, then the length check consumes the text:
/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).{8,}$/Should match3/3
- Secret123
- aB3defgh
- 9Lives_cat
Should not4/4
- secret123
- SECRET123
- Secretpass
- Ab1
Read it as a list of conditions. Adding a rule is adding a lookahead, in any order:
/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[^A-Za-z0-9]).{8,}$/Should match2/2
- Secret123!
- p@ssW0rd
Should not1/1
- Secret123
A lookahead that forbids something is (?!…). "No spaces anywhere":
/^(?!.*\s)(?=.*\d).{8,}$/Should match1/1
- correct1horse
Should not1/1
- correct 1 horse
04Traps in the common patterns
Forgetting the anchors. Without ^ and $, .{8,} only needs eight characters somewhere, and every lookahead runs from wherever the match happens to start. Keep ^ at the front so all the lookaheads start at position 0, and $ at the end so the length check covers the whole string.
A maximum length that doesn't work. .{8,64} without $ happily matches the first 64 characters of a 500-character password. With $ it rejects it, which is what you meant.
Too narrow a "symbol". A class like [!@#$%^&*] rejects ?, ~, -, spaces and every non-English character. [^A-Za-z0-9] ("not a letter or digit") is what people usually mean.
Letters outside A to Z. [A-Z] doesn't count É as a capital. With the u flag, \p{Lu} and \p{Ll} are any uppercase and lowercase letter.
05When not to
One regex gives one answer: pass or fail. Your sign-up form needs to tell the person which rule they missed:
const rules = [
{ test: (p) => p.length >= 12, message: "Use at least 12 characters" },
{ test: (p) => /\d/.test(p), message: "Add a number" },
{ test: (p) => /[A-Z]/.test(p), message: "Add a capital letter" },
];
const problems = rules.filter((r) => !r.test(password)).map((r) => r.message);
Each check is tiny, readable and easy to change, and the error messages come for free.
It's also worth asking whether you want composition rules at all. NIST's guidelines for passwords (SP 800-63B) advise against rules like "one capital, one digit, one symbol". They push people towards Password1!. The guidance favours length, allowing long passphrases (at least 64 characters, spaces included), and checking new passwords against lists of known breached ones.
So the lookahead pattern is worth knowing, both for passwords and for any "must contain all of these, in any order" check. For a real sign-up form, a minimum length plus a breached-password check usually beats it.
06Try one
Catch the strings that contain both a digit and a letter, in any order:
Catch strings with at least one letter and at least one digit, in any order.
Targets · 0/6 caught
- a1target
- 1atarget
- abc123target
- 123abctarget
- x9ytarget
- R2D2target
Innocents · 0 hit
- abcinnocent
- 123innocent
- ---innocent
- a-binnocent
- 4-5innocent
- !!innocent
Type a regex — the board updates as you go.
07Checklist
(?=.*X)means "X appears somewhere ahead". Stack one per rule.(?!.*X)means "X appears nowhere ahead".- Anchor with
^so every lookahead starts at position 0, and with$so a length limit covers everything. [^A-Za-z0-9]is a better "symbol" than a hand-picked list.- For forms, separate checks give separate error messages.
- Length and a breached-password check beat composition rules.
08Keep practising
- The full post on lookahead and lookbehind goes deeper, with live boards.
- Regex wizardry is a practice pack with harder lookaround hunts.