Email and Phone Number Regex for Input Validation, Without ReDoS

A validation regex has one job: accept every legitimate value and reject everything else. Most broken validators get the pattern roughly right and the boundaries wrong. A missing anchor lets anything through as long as a valid fragment appears somewhere, a `$` that matches before a newline accepts a trailing payload, and a nested quantifier lets a single crafted input occupy a CPU core for minutes. The boundaries come first below, then the email and phone number patterns.

Anchor the whole value, in every language

Without anchors, a regex looks for a match anywhere in the input. /\d{5}/ accepts abc12345xyz. For validation you want the entire string to match, and each language spells that differently.

// JavaScript: ^ and $ (without the m flag) anchor to the whole string
/^\d{5}$/.test('12345');         // true
/^\d{5}$/.test('12345\n');       // false
/^\d{5}$/m.test('12345\nevil');  // true — the m flag breaks validation
import re
# re.match anchors only at the start; $ also matches before a trailing \n
re.match(r'\d{5}$', '12345\n')      # matches!
re.fullmatch(r'\d{5}', '12345\n')   # None — use fullmatch for validation
re.match(r'\d{5}\Z', '12345\n')     # None — \Z is the true end of string
<?php
preg_match('/^\d{5}$/', "12345\n");    // 1 — $ allows a final newline in PCRE
preg_match('/^\d{5}$/D', "12345\n");   // 0 — D makes $ mean end of string
preg_match('/\A\d{5}\z/', "12345\n");  // 0 — explicit absolute anchors

The trailing-newline behaviour in Python and PHP is the subtle one. It matches what those engines were designed for, reading lines, and it is surprising in validation. Ruby has the same trap in a stronger form: there ^ and $ are always line anchors, so validators must use \A and \z. Rails' format validator raises an error if you use ^/$ without opting in.

Alternation binds more loosely than the anchors, which breaks validators that accept several formats. A US ZIP code check written as ^\d{5}|\d{5}-\d{4}$ accepts 12345-garbage, because the first alternative only needs five digits at the start. Group the alternatives, ^(?:\d{5}|\d{5}-\d{4})$, or make the suffix optional: ^\d{5}(?:-\d{4})?$.

Email: use the browser's definition, then confirm

No regex can tell you an address exists. The practical goal is to catch typos without rejecting valid addresses. The HTML Standard defines what <input type="email"> accepts. It deliberately deviates from RFC 5322, which it calls simultaneously too strict, too vague and too lax to be useful, and it publishes the exact pattern:

/^[a-zA-Z0-9.!#$%&'*+\/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/

Using the same rule on the server as the browser uses on the client avoids the frustrating case where a form accepts an address and the API rejects it. Note what it permits: + tags (ana+news@example.com), apostrophes (o'neil@example.ie) and a domain with no dot (user@localhost). If you need a public domain, add a separate check that the domain contains a dot, rather than tightening the pattern.

Do not reject long top-level domains (.photography), new gTLDs or subdomains. Internationalised domains arrive as punycode (xn--…) when converted, and addresses with non-ASCII local parts (RFC 6531) will not match this pattern. Decide deliberately whether you support them. In the end, only a confirmation email proves an address works.

Phone number regex, postcodes and other formats

For phone numbers, normalise first and validate the normalised form. Strip spaces, dashes, dots and brackets, then check E.164: a +, a country code and at most 15 digits in total (ITU-T E.164).

const normalised = input.replace(/[\s().-]/g, '');
const isE164 = /^\+[1-9]\d{1,14}$/.test(normalised);

That rule only proves the shape. Whether +44 20 7946 0958 is a valid London number depends on per-country numbering plans that change over time. Libraries such as Google's libphonenumber encode those plans. A hand-written regex for "all phone numbers" will be wrong somewhere.

The same logic applies to postcodes, IBANs and card numbers. Use a regex for the character-level shape, then a real check where one exists: the IBAN mod-97 checksum (ISO 13616), the Luhn checksum for card numbers, or a lookup for postcodes.

# Shapes that are reasonable as regex (anchored, bounded)
UUID          ^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$   (i flag)
Slug          ^[a-z0-9]+(?:-[a-z0-9]+)*$
Hex colour    ^#(?:[0-9a-fA-F]{3}){1,2}$
ISO date      ^\d{4}-\d{2}-\d{2}$        (then parse it: 2026-02-31 matches!)
Username      ^[a-zA-Z0-9_]{3,20}$

The ISO date line shows the limit of shape checks. 2026-02-31 has the right shape and is not a date. Anything with semantic rules, such as dates, ranges or checksums, needs code after the regex.

Catastrophic backtracking (ReDoS)

Backtracking engines, which include JavaScript, Python, PHP, Java, .NET, Ruby and Perl, try alternatives one at a time. When a pattern can match the same text in exponentially many ways, a non-matching input forces the engine to try all of them.

const evil = /^(a+)+$/;
evil.test('a'.repeat(30) + '!');   // takes seconds; each extra 'a' roughly doubles it

// Real-world shapes of the same problem
/^(\w+\s?)*$/          // nested quantifier over overlapping tokens
/^(.*a){12}$/           // repeated greedy wildcards
/^([a-zA-Z0-9]+\.?)+@/ // email-ish prefix with nested +

In Node.js one such request blocks the event loop, and every other request waits behind it. That is a denial of service from a single form submission. Incidents like this have taken down large production sites, including Cloudflare in July 2019 and Stack Overflow in 2016.

Defences: avoid nested quantifiers over tokens that can overlap ((a+)+, (\w+\s?)*); limit input length before you run the regex, since maxlength is not enough and the server must check too; prefer atomic groups or possessive quantifiers where the engine supports them (PCRE, Java, .NET, and Python since 3.11); or use an engine that guarantees linear time, such as RE2, Rust's regex crate or Go's regexp. The OWASP page on ReDoS lists vulnerable patterns to test against.

Validate on the server, and do not use regex as sanitisation

Client-side validation (pattern, type="email", JavaScript) is only for user experience. Anyone can send a request without your front end. Repeat every check on the server with the same rules.

Validation is also not escaping. A value can be a perfectly valid email address and still contain ', and valid free text can contain <script>. Prevent injection at the point of use with parameterised SQL queries, context-aware HTML escaping and shell argument arrays. Do not try to strip dangerous characters with a regex beforehand. Deny-list patterns such as /<script>/i are bypassed by <SCRIPT >, <img onerror=…> and hundreds of other variants.

Prefer allow-lists. "Letters, digits and underscore, 3 to 20 characters" is easy to reason about. "Anything except these dangerous characters" never is.

Testing a validation pattern

Build two lists before you write the pattern: values that must pass and values that must fail. Put the edge cases in the lists: empty string, whitespace only, leading or trailing spaces, a trailing newline, maximum length and one more, non-ASCII letters, and a valid value followed by extra text. Run both lists in a regex tester with the same flavour and flags your code uses, then turn them into unit tests.

Check the flags especially. A pattern that passes in a tester with the multiline flag on will behave differently in code where it is off, and the other way round.

Sources and further reading

The WHATWG HTML Standard defines the type=email pattern and its deliberate deviation from RFC 5322. ITU-T Recommendation E.164 defines the international number format. The Python re documentation describes fullmatch and \Z; the PHP manual documents the D modifier. OWASP's "Regular expression Denial of Service – ReDoS" page and Cloudflare's July 2019 outage report describe catastrophic backtracking in practice.

Frequently asked questions

How do I make a regex match the entire input?

Anchor both ends. In JavaScript use `^…$` without the `m` flag. In Python use `re.fullmatch()`, and in PHP use `\A…\z` or add the `D` modifier. Otherwise a trailing newline, or extra text around a valid fragment, can slip through.

What is the best regex for email validation?

Use the pattern the HTML Standard defines for `<input type="email">`, so the server agrees with the browser. It catches typos without rejecting `+` tags or apostrophes. Then confirm ownership with a verification email, because no regex can prove an address exists.

How do I validate a phone number with regex?

Normalise the input by removing spaces, dashes and brackets, then check the E.164 shape `^\+[1-9]\d{1,14}$`. For real validity per country, use a library such as libphonenumber, because numbering plans are too complex and change too often for one regex.

What is ReDoS?

Regular expression denial of service: a pattern with nested or overlapping quantifiers, such as `(a+)+`, takes exponential time on certain non-matching inputs, and one request can block a server. Avoid such patterns, cap input length, or use a linear-time engine such as RE2.

Is client-side regex validation enough?

No. Browser validation improves user experience but is trivially bypassed. Every rule must be enforced again on the server.

Can regex validation prevent SQL injection or XSS?

Not reliably. Validation checks format; injection is prevented at output time with parameterised queries and context-aware escaping. Deny-list regexes that try to strip dangerous input are routinely bypassed.