When a regular expression becomes a denial of service

A pattern that works perfectly in testing can hang a server on an input twenty characters long. Here is why, and how to spot one before it ships.

Thu May 28 2026 00:00:00 GMT+0000 (Coordinated Universal Time) · 8 min read

Here is a regular expression that validates a string of letters:

^(a+)+$

It works. Test it against aaaa and it matches instantly. Test it against aaaaaaaaaaaaaaaaaaaaaaaaaaaaaab — thirty a's and a b — and depending on your engine and hardware, it will run for minutes, hours, or longer than you are prepared to wait.

Nothing is broken. The engine is doing exactly what it was asked to do. It is just being asked to do an exponential amount of it.

Why it happens

Most regex engines — including JavaScript's, Python's re, Java's and PCRE — are backtracking engines. When a pattern can match in more than one way, they try one possibility, and if the overall match eventually fails, they back up and try the next.

Now look at (a+)+ against a string of a's. The inner a+ can consume any number of them, and the outer + can repeat that group any number of times. So the a's can be divided between the two quantifiers in an enormous number of ways: one group of thirty, two groups of fifteen, thirty groups of one, and every arrangement in between.

For a matching string this does not matter — the first arrangement the engine tries succeeds and it stops. The catastrophe needs a string that almost matches. The trailing b means the $ anchor can never be satisfied, so the engine must reject the string — and to be certain, it has to try every possible partition before concluding that none works.

The number of partitions grows as 2n. Thirty characters is around a billion attempts. Forty is a trillion. Adding one character doubles the work.

Why this is a security problem

If that regex validates user input in a request handler, an attacker does not need a botnet to take your service down. They need one request containing forty characters. The thread handling it stops responding, consuming CPU, until it is killed or the process dies.

This has a name — ReDoS, regular expression denial of service — and it has caused real, large outages. Cloudflare's global outage in July 2019 was caused by a single regular expression in a WAF rule containing a catastrophic backtracking pattern.

It is particularly nasty because the vulnerable pattern usually arrives through the least suspicious route: a validation regex copied from a Stack Overflow answer, applied to a field nobody thought was interesting.

The shapes to watch for

Almost all catastrophic patterns share one property: two nested quantifiers that can match the same characters. Once you know the shape it becomes easy to spot.

Nested quantifiers

(a+)+
(a*)*
(\d+)*

The inner and outer quantifiers compete for the same input. This is the textbook form.

Alternation with overlap

^(a|a)+$
^(\w|\d)+$

Both branches can match the same character, so each repetition doubles the paths to explore. \w already includes \d, which makes the second example a slow accident rather than a deliberate one.

Adjacent overlapping quantifiers

^\s*\w*\s*$
^.*foo.*$

Milder, but on a long non-matching input the engine still explores far more splits between the two quantifiers than it should.

The email regex problem

Long "RFC-compliant" email validation regexes found online are a common source, because they contain many nested optional groups over overlapping character classes. They are also unnecessary: the only reliable way to validate an email address is to send a message to it.

How to fix it

Make the parts unable to overlap

The real fix is to remove the ambiguity, so there is only one way to match. Instead of ^(a+)+$, write ^a+$ — same language, no nesting, linear time. Instead of ^(\w|\d)+$, write ^\w+$.

Where you need a delimiter, make the repeated part exclude it: ^(?:[^,]+,)*[^,]+$ is safe because [^,] cannot match the comma the outer group depends on.

Anchor and bound

Anchoring with ^ and $ stops the engine retrying from every starting offset. Replacing an unbounded + with an explicit bound like {1,64} caps the work, and usually reflects a real constraint you had anyway.

Limit input length first

Check the length before running the pattern. If a username cannot exceed 32 characters, rejecting anything longer costs nothing and removes the attack entirely. This is the cheapest mitigation available and it is frequently the right one.

Use an engine that cannot backtrack

RE2 — used by Go's regexp package and available for other languages — guarantees linear time by refusing to support backreferences and lookaround. If you are running untrusted patterns, that trade is almost always correct.

Testing for it

The important insight is that a matching string will not reveal the problem. You have to test a near-miss: a long run of whatever the quantifier accepts, followed by one character that makes the whole pattern fail.

Take your pattern, feed it thirty repetitions of the character it loops on plus a single invalid character on the end, and see how long it takes. If it does not return immediately, you have found one.

The regex tester here runs matching in a Web Worker with a hard time limit for exactly this reason. That detail matters more than it might seem: a timeout checked inside the matching loop cannot help, because the runaway happens inside a single operation and JavaScript is single-threaded — nothing else runs until it finishes. A worker can be terminated from outside, so the page stays responsive and tells you the pattern is dangerous instead of freezing.

If you see that warning, do not ship the pattern anywhere it can meet input you do not control.

Tools mentioned in this guide

navigate open esc close