Here is a pattern I keep finding in slug and username validators: /^([a-z0-9]+-?)+$/. It looks harmless and passes every unit test. Now send it thirty lowercase letters followed by an underscore. On Node 24, that single test() call ran for about 6.5 seconds on my machine. Node runs your JavaScript on one event-loop thread, so every other request on that process waits for it. A handful of those strings in parallel is a denial of service.
I wrote this with help from AI tools and checked every command and claim myself; every timing in this post comes from running the code on Node 24. I build the PixelValut Regex Tester, so I spend a lot of time looking at patterns people paste in from production. This post is the review routine I use for regexes in JavaScript and PHP code: how to spot catastrophic backtracking, how to prove it, and how to fix it without breaking validation.
📚 Series Roadmap — Browser-Side Security & Zero-Trust Forensics
- Part 1: Deobfuscating Malicious npm & Browser Payloads: Base64, Hex, Unicode Escapes & Homoglyphs
- Part 2: Webhook Security in Production: HMAC-SHA256 Signatures, Timestamp Drift & Replay Protection
- Part 3: OAuth 2.1, OIDC ID Tokens & PKCE Forensics: Auditing Claims, Nonces & Audience Confusion in SPAs
- Part 4 (This Post): Regular Expression Denial of Service (ReDoS) in JS & PHP: Spotting Catastrophic Backtracking
- Part 5: Subdomain Takeover & Dangling DNS Reconnaissance: Auditing CNAME, NS, TXT & TLS Certificates
- Part 6: Zero-Trust Reverse Proxy Hardening in Nginx: Rate Limiting, Request Smuggling Guards & mTLS
What is ReDoS and how do you spot catastrophic backtracking in JavaScript and PHP?
Direct answer: ReDoS happens when a backtracking regex engine, like V8's Irregexp in Node.js or PCRE2 behind PHP's preg_* functions, tries exponentially or polynomially many ways to match a crafted input that ultimately fails. Spot it by looking for nested or overlapping quantifiers, then prove it with a growing near-miss input and a timer.
Why the engine explodes
Both V8 and PCRE2 are backtracking engines. When a match fails, they go back and try every other way the pattern could have consumed the input. That is fine when there is only one way. It is a disaster when the pattern is ambiguous.
In ^([a-z0-9]+-?)+$, the hyphen is optional, so the string aaaa can be split as aaaa, aaa|a, aa|aa, a|a|a|a and so on. That is 2^(n-1) splits. When the final $ fails on the trailing _, the engine tries all of them. Here is what I measured on Node 24:
Letters before _
|
Time for one test()
|
|---|---|
| 24 | 98 ms |
| 26 | 385 ms |
| 28 | 1,572 ms |
| 30 | 6,502 ms |
Every two extra characters multiply the time by roughly four. At 40 characters you are looking at hours. Note that a matching input is fast; ReDoS only shows up on near-misses, which is why normal tests never catch it.
The five pattern smells I look for
| Smell | Vulnerable example | Why it blows up | Safe rewrite | Growth |
|---|---|---|---|---|
| Nested quantifiers | ^([a-z0-9]+-?)+$ |
Inner and outer + can split the same run many ways |
^[a-z0-9]+(?:-[a-z0-9]+)*$ |
Exponential |
| Overlapping alternation | A group alternating \w and \d, repeated with +
|
Both branches match the same character |
^\w+$ (one branch) |
Exponential |
| Adjacent overlapping repeats |
^\w+\w*@ or \d+\.?\d*
|
Two quantifiers compete for the same characters | Make the boundary mandatory or merge them | Polynomial |
| Unanchored greedy tail |
/\s+$/ on long whitespace |
Engine retries from every start position | Trim in code, or use trimEnd()
|
Quadratic |
.* chains |
.*=.*;.* |
Each .* re-scans what the previous one gave back |
Use negated classes: [^=]*=[^;]*;
|
Polynomial |
The last two are not academic. The Stack Overflow outage in July 2016 was traced to a whitespace-trimming regex, and Cloudflare's global outage in July 2019 was traced to a WAF rule containing .*(?:.*=.*). Both were super-linear, not exponential, and both were enough.
Proving it in JavaScript
I keep a tiny harness and grow the near-miss input until the time curve is obvious:
// redos-probe.js — run with: node redos-probe.js
const pattern = /^([a-z0-9]+-?)+$/;
for (const n of [20, 22, 24, 26, 28]) {
const input = 'a'.repeat(n) + '_'; // near-miss: valid prefix, invalid last char
const t0 = process.hrtime.bigint();
pattern.test(input);
const ms = Number(process.hrtime.bigint() - t0) / 1e6;
console.log(n, ms.toFixed(1) + ' ms');
}
If the time roughly doubles (or quadruples every two steps), you have exponential backtracking. If it grows with the square of the length, polynomial.
For static detection I run two tools:
npm i -D eslint eslint-plugin-regexp recheck
eslint-plugin-regexp's recommended config includes regexp/no-super-linear-backtracking, which flagged both the slug pattern and a typical hand-rolled email pattern with "exponential backtracking" messages. recheck goes further and generates an attack string. On \s+$ it reported vulnerable polynomial, which the recommended lint config did not flag. Use both.
const { check } = require('recheck');
check('^([a-z0-9]+-?)+$', '').then((r) =>
console.log(r.status, r.complexity.type)); // vulnerable exponential
Fixing it in JavaScript
1. Rewrite the pattern to be unambiguous. This is the real fix. Each character should have exactly one way to be consumed:
// BEFORE: ambiguous, exponential
const slugBad = /^([a-z0-9]+-?)+$/;
// AFTER: hyphen is mandatory between groups, so there is one parse
const slugGood = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
It accepts the same slugs, and the 30-character near-miss now takes well under a millisecond.
2. Cap input length before the regex runs. A slug longer than 100 characters is invalid anyway. if (input.length > 100) return false; turns any exponential pattern into a bounded one, and it costs nothing.
3. Use a linear-time engine where you can. V8 ships an experimental non-backtracking engine. In Node 24, starting the process with --enable-experimental-regexp-engine enables the l flag:
node --enable-experimental-regexp-engine -e \
"console.log(/^([a-z0-9]+-?)+$/l.test('a'.repeat(100000) + '_'))"
# false, in about 7 ms
Without the flag, new RegExp('a', 'l') throws "Invalid flags". With it, patterns that need backtracking features are rejected at compile time: I got "Cannot be executed in linear time" for both a backreference (a)\1 and a lookahead (?=a)b. There is also --enable-experimental-regexp-engine-on-excessive-backtracks, which falls back to the linear engine after a backtrack threshold (50,000 by default in node --v8-options). Both are experimental V8 flags, so I treat them as defense in depth, not as the fix. For untrusted patterns (search features, user-defined filters), use the re2 npm package, which binds Google's RE2.
4. Never run user-supplied patterns on the main thread. If users can submit regexes, run them in a worker with a timeout, or in RE2.
PHP: PCRE fails differently, and that is its own bug
PHP's preg_* functions use PCRE2. They do not hang forever, because pcre.backtrack_limit (default 1,000,000) caps the work. PCRE2's documentation says this match limit is also applied when the pattern is JIT-compiled (pcre.jit=1 is the default), though it is counted differently. When the limit trips, the call fails instead of answering:
-
preg_match()returnsfalse, not0. -
preg_replace()returnsnull. -
preg_last_error()returnsPREG_BACKTRACK_LIMIT_ERROR, andpreg_last_error_msg()(PHP 8+) returns "Backtrack limit exhausted". With JIT you may also seePREG_JIT_STACKLIMIT_ERROR.
That is where the security bug lives. Code like if (!preg_match($re, $input)) treats false as "no match", and $body = preg_replace($re, '', $body) silently replaces the content with null. Depending on the branch, you get a validation bypass, a sanitizer that does nothing, or data loss. Each call still burns CPU up to the limit, so an attacker can keep PHP-FPM workers busy.
I wrap every preg_* call on user input:
function safe_preg_match(string $pattern, string $subject, ?array &$m = null): bool
{
$result = preg_match($pattern, $subject, $m);
if ($result === false) {
throw new RuntimeException('Regex failed: ' . preg_last_error_msg());
}
return $result === 1;
}
PCRE also gives you tools JavaScript does not have. Possessive quantifiers (++, *+) and atomic groups ((?>...)) refuse to give characters back:
// Ambiguous: exponential, will hit the backtrack limit
$bad = '/^([a-z0-9]+-?)+$/';
// Atomic group: each iteration commits, no re-splitting
$atom = '/^(?>[a-z0-9]+-?)+$/';
// Unambiguous rewrite: my preferred fix, same in JS and PHP
$good = '/^[a-z0-9]+(?:-[a-z0-9]+)*$/';
Do not "fix" ReDoS by raising pcre.backtrack_limit. That only makes each malicious request burn more CPU before it fails.
ReDoS review checklist
- Grep for
+)+,*)*,+)*,(.*)*and alternations where branches overlap. - Run
eslint-plugin-regexpandrecheckin CI on JS; copy PHP patterns into a JS test file to scan them too, where the syntax is compatible. - Probe each user-facing pattern with a growing near-miss input and time it.
- Cap input length before matching.
- In PHP, treat
false/nullfrompreg_*as an error, never as "no match". - Run user-supplied patterns only in RE2 or an isolated worker with a timeout.
- Paste suspicious patterns into a regex tester with near-miss strings before shipping.
FAQ
Is ReDoS a risk in the browser too?
Yes, but the impact is smaller: a slow regex freezes the user's own tab. It becomes serious when the same validation code is shared with a Node.js backend, which is common with Nuxt, Next.js and shared validation schemas.
Do Zod or Joi protect me from ReDoS?
No. Their string validators run whatever regex you pass to .regex() on V8. Some built-in validators have also had ReDoS fixes over the years, so keep those libraries updated.
Does PHP's JIT make ReDoS impossible?
No. JIT makes matching faster, but a pattern with exponential paths is still exponential. The match limit still applies, so the call fails with an error rather than hanging.
What about Laravel's regex: validation rule?
It calls preg_match() under the hood, so the same backtracking limits and failure modes apply. Keep patterns unambiguous and add a max: rule before regex: so long inputs are rejected first.
Are lookaheads dangerous?
Not by themselves. A lookahead containing a nested quantifier can be, and lookaheads rule out V8's linear engine. In JavaScript you can emulate an atomic group with (?=(pattern))\1, but I prefer rewriting the pattern.
How do I find the attacking input for a pattern?
recheck prints one. Otherwise, take a long valid prefix the ambiguous part can split, and end with a character that makes the overall match fail.
Next in this series, Part 5 covers subdomain takeover and dangling DNS reconnaissance.
Follow & Connect
- Website: PixelValut Tools
- YouTube: PixelValut (@pixel-valut)
- TikTok: PixelValut (@pixelvalut007)
- GitHub: Muhammad Ali Mughal (@ali111887)
- LinkedIn: Muhammad Ali Mughal

Top comments (0)