Previous Article
What Is Lexical Scope in JavaScript? A Clear Explanation with Examples
25 April 2023
Email validation is the most common reason people reach for a regular expression, and it is also the thing regular expressions are worst at. Understanding why teaches you a lot about what regex is genuinely good for.
A regular expression describes a pattern of characters. In JavaScript you write one between slashes:
const pattern = /cat/;
pattern.test('concatenate'); // true — "cat" appears somewhere
/^cat/ // must START with "cat" /cat$/ // must END with "cat" /^cat$/ // must be EXACTLY "cat"
Forgetting anchors is the single most common regex bug. /\d{5}/ matches a five-digit postcode — and also matches those five digits buried inside a longer string. If you are validating rather than searching, anchor both ends.
[abc] // any one of a, b, c [^abc] // any character EXCEPT a, b, c [a-z] // any lowercase letter [0-9] // any digit — same as \d \d \w \s // digit, word character (letters/digits/underscore), whitespace \D \W \S // the negations of the above . // any character except a newline
a? // 0 or 1
a* // 0 or more
a+ // 1 or more
a{3} // exactly 3
a{2,4} // between 2 and 4
a{2,} // 2 or more
Quantifiers are greedy by default — they take as much as possible. Add ? to make one lazy:
'<a><b>'.match(/<.+>/)[0]; // "<a><b>" — greedy, takes everything '<a><b>'.match(/<.+?>/)[0]; // "<a>" — lazy, stops at the first match
(abc) // capturing group — extract it later
(?:abc) // non-capturing — group without capturing
(?<year>\d{4}) // named group
/cat/i // case-insensitive
/cat/g // global — find all matches
/cat/m // multiline — ^ and $ match at line breaks
/cat/u // unicode — required for \p{…} and correct emoji handling
const m = '2026-07-24'.match(/(?<year>\d{4})-(?<month>\d{2})-(?<day>\d{2})/);
m.groups.year; // "2026"
Here is the pattern almost everyone uses, including the earlier version of this article:
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
Reading it left to right: start of string, one or more characters that are not whitespace or @, an @, more non-whitespace non-@ characters, a literal dot, more of the same, end of string.
This is a good pattern. Not because it is accurate — it is not — but because it catches the realistic mistakes (missing @, missing domain, a stray space from copy-paste) without rejecting anything a real user is likely to type.
It accepts things that are not deliverable:
emailRegex.test('a@b.c'); // true — valid shape, almost certainly not real
emailRegex.test('user@example.invalid'); // true — reserved TLD, cannot exist
emailRegex.test('user@@example.com'); // false, good
emailRegex.test('user@example..com'); // true — double dot, not valid
And it rejects addresses that are genuinely valid under the specification:
emailRegex.test('user@localhost'); // false — valid on intranets
emailRegex.test('"john doe"@example.com'); // false — quoted local parts are legal
emailRegex.test('user@[192.168.1.1]'); // false — IP literals are legal
Those last three are rare enough that rejecting them is a defensible product decision. But be aware you are making a decision, not applying a rule.
RFC 5322 permits quoted strings, comments in parentheses, escaped characters and nested constructs. A regex that implements it faithfully runs to several thousand characters. Nobody on your team can read it, nobody can safely modify it, and it still does not tell you whether the mailbox exists.
The trade-off is not "loose regex versus strict regex". It is "a simple pattern that catches typos" versus "an unmaintainable pattern that catches typos".
Some clever-looking email patterns contain nested quantifiers, and those can be exploited. Consider:
// Vulnerable — nested quantifier
const bad = /^([a-zA-Z0-9]+)*@example\.com$/;
bad.test('aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa!'); // hangs
The engine tries every possible way to split that run of as between the inner + and the outer *. The number of combinations doubles with each added character, so roughly thirty characters is enough to lock a CPU core.
This is Regular expression Denial of Service. On a Node server it is worse than a slow response — JavaScript is single-threaded, so one malicious input freezes the entire process for every user.
The warning signs are a quantifier applied to a group that itself contains a quantifier — (a+)*, (a|aa)+, (\s*\w+)*. Note that the simple email pattern above is safe: [^\s@]+ cannot overlap with @, so there is nothing to backtrack over.
<input type="email" name="email" required autocomplete="email" />
Browsers validate this natively and show a localised error. On mobile it also switches the on-screen keyboard to one with @ and . visible, which is worth more to your conversion rate than any regex.
Note that HTML5's type="email" deliberately uses a willful violation of RFC 5322 — a pragmatic subset. Even the specification authors concluded strict compliance was the wrong goal.
const form = document.querySelector('#signup');
const input = form.elements.email;
const error = document.querySelector('#email-error');
input.addEventListener('blur', () => {
const value = input.value.trim();
if (value === '') {
setError('Email is required.');
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) {
setError('Enter a valid email address, such as name@example.com.');
} else {
setError('');
}
});
function setError(message) {
error.textContent = message;
input.setAttribute('aria-invalid', message ? 'true' : 'false');
}
Validate on blur, not on every keystroke — telling someone their address is invalid while they are still typing the third character is irritating. And point aria-describedby at the error element so screen readers announce it.
Client validation is a convenience. Anyone can bypass it. In PHP:
$email = trim($_POST['email'] ?? '');
if (filter_var($email, FILTER_VALIDATE_EMAIL) === false) {
$errors[] = 'Enter a valid email address.';
}
FILTER_VALIDATE_EMAIL implements a well-tested subset and is more reliable than anything you will write by hand. It is slightly stricter than the spec — it rejects user@localhost, for instance — which for a public signup form is usually what you want.
Optionally confirm the domain can actually receive mail:
[$local, $domain] = explode('@', $email, 2);
if (!checkdnsrr($domain, 'MX') && !checkdnsrr($domain, 'A')) {
$errors[] = 'That email domain does not appear to accept mail.';
}
Use this carefully. It adds a DNS lookup to your signup path, and a temporary DNS failure will reject a perfectly good address.
No amount of pattern matching tells you whether bob@example.com belongs to Bob, exists, or accepts mail. The only way to know is to send a message with a signed, expiring link and see whether someone clicks it.
Everything before this step is about catching typos so users are not left wondering why their confirmation never arrived. That is a real and worthwhile goal — just not the same as validation.
$email = strtolower(trim($email));
Domains are case-insensitive. Local parts are technically case-sensitive, but virtually every provider treats them as insensitive, so lowercasing prevents duplicate accounts for Bob@example.com and bob@example.com.
Resist the urge to strip Gmail's dots or +tags. Those are legitimate and users rely on them for filtering; removing them breaks their setup and surprises them.
^ and $./^[^\s@]+@[^\s@]+\.[^\s@]+$/ for email — simple, readable, ReDoS-safe.Advertisement
Written by
Amit VermaFounder / Senior Software Engineer
Amit leads engineering at Being Idea. With 15+ years building scalable software products across global markets, he drives architecture decisions and engineering culture across every engagement.
More articles by AmitYou might also like
We ship software that scales. Let's work together.