How a Single Bad Regex Can Take Down an API (ReDoS Explained)
Here's a scenario many engineers will recognize. A monitoring dashboard suddenly lights up like a Christmas tree. Response times spike from 80ms to 45 seconds. Timeout errors cascade across every endpoint. On-call phones buzz with PagerDuty alerts. And the most maddening part: nothing has been deployed in three days. So what happened? In cases like this, the root cause often traces back to just a handful of characters in a regular expression — a pattern written months earlier, without a second thought. Let's walk through how that kind of failure unfolds, why it's so hard to spot, and how to defend against it.
The Innocent-Looking Pattern
Imagine an API that accepts user-submitted email addresses for a newsletter signup feature. Pretty standard stuff. The validation logic lives in a small utility function that looks like this:
const emailRegex = /^([a-zA-Z0-9]+\.?)*@[a-zA-Z0-9]+\.[a-zA-Z]{2,}$/;
If you've been doing this long enough, your stomach might have just dropped. This is exactly the kind of pattern that gets written during a late-night refactor — half-copied from Stack Overflow, half improvised. It looks reasonable. It matches valid emails. It passes the handful of test cases anyone is likely to throw at it. It gets committed and forgotten. What goes unnoticed is the nested quantifier — (something+)* — which is the classic setup for catastrophic backtracking.
What Catastrophic Backtracking Actually Means
Many developers have a vague awareness that "bad regexes can be slow." Far fewer appreciate just how exponentially slow they can get. Here's what happens with a pattern like ([a-zA-Z0-9]+\.?)* when the input doesn't match. The regex engine tries every possible way to split the input across the repeating group. Each character position becomes a branching point. For a 30-character string, the engine might attempt millions of combinations before giving up and reporting no match. A 50-character string? You're looking at effectively infinite time. The process hangs.
This class of vulnerability has an actual name: ReDoS — Regular Expression Denial of Service. It appears in OWASP guidance for a reason, and it's remarkably easy to introduce into production while thinking you're doing something completely mundane.
The trigger in a scenario like this is typically an attacker — or possibly a fuzzer — submitting email strings such as:
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa@
The domain part after the @ is missing, so the regex will never match. But before giving up, the engine exhausts every possible interpretation of the local part. In a Node.js process — single-threaded, remember — that one request locks up the event loop, which then can't process anything else. Timeouts cascade. The API appears completely down.
The Debugging Process (The Part Nobody Talks About)
It's worth being honest about how long this kind of bug takes to find, because the tidy "aha moment" narrative does a disservice to how the investigation actually goes. The first assumption is usually a database problem. You check slow query logs. Nothing unusual. You check connection pool exhaustion. Fine. You restart the DB replica. No change.
The second assumption is often a memory leak. You pull heap snapshots. Another hour gone. Red herring.
What actually breaks the case is looking at the Node.js CPU metrics more carefully. Normally an API process like this sits at maybe 5–8% CPU. During the incident: 99%, sustained, for minutes at a time. That's not I/O-bound behavior. That's the CPU spinning on compute.
From there, adding console.time() markers around different sections of the request handler is crude but effective. The validation step — which should take microseconds — turns out to be taking 40+ seconds. It's the kind of result you re-run twice because you assume you made a mistake. Drop the same regex into regex101.com, switch to the "step" debugger, and feed it a bad input string, and the step count climbs into the hundreds of thousands. The debugger even warns you directly: "This may indicate catastrophic backtracking."
The Fix (Which Is Not Just "Write a Better Regex")
The immediate fix is obvious enough: replace the broken regex with something that doesn't have nested quantifiers. For email validation specifically, the simplest thing that actually works is:
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/;
It's not perfect — it would accept technically malformed emails — but for a use case like newsletter signup (not cryptographic identity verification), it's more than sufficient. And critically: it's immune to backtracking because there's no ambiguity in how the engine can interpret each character. Every position has exactly one possible match path.
For the longer term, a few other measures matter:
- Add a timeout wrapper around regex operations. Node.js doesn't give you this natively, but you can run validation in a worker thread and terminate it if it exceeds a threshold. Overkill for most cases; not overkill when user-controlled strings touch your regex engine.
- Implement request body size limits at the proxy layer, not just in the application. An attacker can't send a 10,000-character "email" if your reverse proxy (for example, nginx) rejects anything over 500 bytes in that field.
- Use a linter rule for ReDoS patterns. There's an ESLint plugin called
eslint-plugin-regexpthat catches a bunch of these statically. It would flag a pattern like the original one immediately. Run it across the entire codebase and it often surfaces several other suspicious patterns worth fixing. - Add the bad string to your test suite as a "must not hang" case. The test is simple — if validation takes more than 10ms, it fails. That catches this class of issue before it ships.
What's Worth Knowing Earlier
The pattern that kills you is almost always (a+)+ or ([ab]+)* or any variant where a quantifier wraps a group that itself has a quantifier. The reason is that there are exponentially many ways to partition a repeated sequence across the group boundaries, and the regex engine will try all of them on a failed match.
Safe patterns tend to have clear, unambiguous character-class partitions. If A and B are mutually exclusive character classes, then (A+B)* is fine — the engine always knows exactly which class each character belongs to. The problem arises when a character could match in multiple parts of the pattern, creating ambiguity that forces backtracking exploration.
Tools worth keeping open during any regex work:
- regex101.com — the step debugger and backtracking warning are invaluable. Spend the extra 30 seconds clicking through it.
- regexper.com — generates a railroad diagram of your pattern. Nested loops jumping back into each other on the diagram are a visual warning sign.
- vuln-regex-detector — a command-line tool from Virginia Tech researchers that statically analyzes regexes for ReDoS vulnerability. Run it in CI if you're paranoid (which you should be if users control the input).
The Bigger Lesson
The most striking thing about an incident like this isn't the technical detail — that's findable in OWASP docs and any good security blog. What stands out is how invisible the vulnerability is. The code looks correct. It tests correctly on valid and obviously-invalid inputs. It can sit in production for months without issue. It only becomes a problem when someone deliberately (or accidentally) provides an input designed to exploit the engine's backtracking behavior.
Security properties of code are not always visible in the happy path. A function can behave perfectly for 999 out of 1000 inputs and be catastrophically exploitable on the thousandth. That's not a new insight — it's the basis of almost every injection attack — but it's easy to forget for something as seemingly innocent as a validation regex.
The safer habit is to treat any regex that accepts user-controlled strings as a potential attack surface, the same way you treat SQL queries and file paths. Test it on adversarial input. Lint it statically. Apply length limits before it ever reaches the regex engine. And when in doubt, reach for a purpose-built parsing library instead of rolling your own pattern. The regex that brings down an API is often just eleven characters of local-part validation logic — which is exactly what should keep us humble about "simple" code.