In short

ReDoS happens when a backtracking regex engine, as in JavaScript, Python, Java, .NET or PHP, tries exponentially or quadratically many ways to match a near-miss input; 29 characters stalled a Node.js server for over a second. Rewrite patterns so each input can match only one way, cap length before matching, and use a linear-time engine such as RE2 or .NET NonBacktracking, or a match timeout. Validate decoded, normalized values against a strict schema that rejects arrays and coerced types.

  • Nested or overlapping quantifiers such as (a+)+ and ([a-z0-9]+-?)* double the work with every extra character of a near-miss input.
  • Unanchored patterns such as \s+$ are quadratic: 80,000 spaces took 2.4 seconds in Node.js 26, and 40,000 took 4.1 seconds in Python 3.14.
  • A length cap bounds quadratic patterns but not exponential ones, which need under 30 characters; rewrite the pattern or change engine.
  • Never compile user input as a pattern: escape literal text, or compile real user patterns with RE2 under a length cap.
  • In Node.js one slow match blocks every request on the event loop; in Python a thread can't stop a running re match.
  • Validate the decoded, normalized value with a strict schema that rejects unknown keys, arrays where one value belongs and loose coercions such as "0x10" becoming 16.

Verify it

  1. Run eslint-plugin-regexp (no-super-linear-backtracking and no-super-linear-move) or the CodeQL queries js/redos, js/polynomial-redos, py/redos and py/polynomial-redos on every pull request.
  2. For each validation regex, time a near-miss input at the maximum allowed length plus one character, and fail the test above a few milliseconds.
  3. Send ?id=1&id=2, ?id[]=1 and ?user[role]=admin to each endpoint and expect a 400, not a 500 or a silently chosen value.
  4. Submit fullwidth, percent-encoded and double-encoded forms of a forbidden value and confirm the validator sees the decoded, normalized value exactly once.
  5. Send a backreference and a lookahead to any feature that accepts user patterns and confirm a 400 from the RE2 compile step.
  6. Load-test one instance with near-miss inputs while measuring the latency of normal requests to the same instance.
Applies to
  • Node.js / V8 26.3.0 / 14.6 tested; RegExp.escape since 24.0
  • Python 3.14.7 tested; possessive quantifiers and atomic groups since 3.11
  • .NET 10.0.7 tested; NonBacktracking since .NET 7
  • Java 21.0.12 tested; RE2/J 1.8
  • re2 (npm) / google-re2 (PyPI) 1.27.0 / 1.1.20251105 tested
  • Express / Zod / Ajv / Pydantic 5.3.0 / 4.6.5 / 8.20.0 / 2.14.0 tested

Input validation and regular expressions in Node.js, Python, Java and .NET. Backtracking behavior depends on the engine, so test patterns on the engine that runs them.

Part of
Workflow: Fix a finding

Fix it in your stack

Tested fixes: the vulnerable code, the fix, a plausible fix that still fails, and the regression tests that tell them apart.

Learning goals and prerequisitesAbout 37 minutes: 10 to read, 5 on the code and 22 for the exercise

What you will learn

  • Explain exponential and quadratic backtracking and spot the pattern shapes that cause them.
  • Fix a vulnerable regex by rewriting it, switching to a linear-time engine or adding a timeout, and prove the fix with a timing test.
  • Build allowlist validation that decodes and normalizes once, matches the whole value and rejects arrays and loose coercion.
  • Choose schema-library settings in Zod, Ajv and Pydantic that keep validation strict and linear.

Before you start

Developers and AppSec engineers who write validation code, review regular expressions or triage ReDoS findings

  • Read regular expressions with groups, quantifiers and anchors.
  • Know how your framework parses query strings and request bodies.
  • Read JavaScript and Python.
Go to the guided practice

See why a short string can stall a server

The regex engines in JavaScript (V8), Python's re, Java, .NET by default, PHP's PCRE and Ruby backtrack: when a path fails, they return to the last choice and try another. A pattern is dangerous when one input can match in many ways and the input almost matches, so the engine tries every way before giving up. In ^(a+)+$ a run of n letters can be split between the two loops in 2^(n-1) ways, and a final "!" makes every split fail.

Quadratic cases need longer input but are easier to write by accident. Searching for \s+$ in a long run of spaces followed by "x" restarts at each space and rescans to the end. A trimming regex of that shape took Stack Overflow down for 34 minutes in July 2016, when a post contained about 20,000 consecutive whitespace characters. In Cloudflare's 27-minute outage in July 2019, a WAF rule containing .*(?:.*=.*) pushed CPUs serving HTTP traffic to nearly 100% worldwide.

The table shows the fastest of three runs on one Apple-silicon laptop. Absolute times vary by machine; the growth rate doesn't.

See why a short string can stall a server
Pattern and inputNode.js 26 (V8)Python 3.14 re.NET 10 defaultJava 21
\^(a+)+$\ on 24 "a" + "!"77 ms344 ms722 ms<1 ms
\^(a+)+$\ on 26 "a" + "!"298 ms1.4 s2.8 s<1 ms
\^(a+)+$\ on 28 "a" + "!"1.2 s5.6 s11.4 s<1 ms
\\s+$\ searched in 40,000 spaces + "x"562 ms4.1 s<1 ms3.2 s
Both patterns on 100,000 characters, linear engine0.15 ms (re2)0.16 ms (google-re2)0.24 ms (NonBacktracking)3.8 ms (RE2/J)

Don't rely on one engine's optimizations

Each engine special-cases different patterns. Java 21 matched ^(a+)+$ instantly but stayed quadratic on \s+$, and (x+x+)+y took 23 seconds on 2,000 characters. .NET skipped the \s+$ work but not the nested loop. No backtracking engine is safe across the board, so fix the pattern or change engine.

Your own validation regexes are only part of the exposure. path-to-regexp generated a backtracking regex for Express routes with two parameters in one segment, such as /:a-:b (CVE-2024-45296); its 0.1.x line needed a second fix in 0.1.12 (CVE-2024-52798). The generated regex took 40 ms on a path with 8,000 repetitions and 162 ms on 16,000. semver (CVE-2022-25883) and cross-spawn (CVE-2024-21538) had the same class of bug, so keep dependencies current.

Find the risky shapes before attackers do

Look for any place where the engine can split the same characters more than one way, then ask what happens when the input almost matches. These shapes cause most cases:

  • A quantified group whose body can match a variable amount of the same text: (a+)+, (\w+\s?)*, ([a-z0-9]+-?)*.
  • Alternatives that overlap inside a loop: (\w|\d)+ took 422 ms on 27 characters in Node.js 26, though Python's re handled it instantly.
  • Adjacent quantifiers that compete for the same characters, such as (x+x+)+y or .*(?:.*=.*).
  • A quantifier followed by $ in an unanchored search, such as \s+$, which reruns from every start position.
  • Any pattern assembled from user input.

Spot the ambiguous pattern

This slug check looks harmless. Because the hyphen is optional, "aaaa" can be split into groups in many ways, and Node.js runs the match on the event loop. In a test, a 29-character request held the server for 1.1 seconds, and a normal request sent 20 ms later waited just as long.

Vulnerableapp.mjsJavaScript
import express from "express";

const app = express();

// Vulnerable: "-?" lets a run of letters split into groups in 2^(n-1) ways,
// and a near-miss such as "aaaa…a!" makes the engine try every one of them.
const SLUG = /^([a-z0-9]+-?)*$/;

app.get("/posts/:slug", (req, res) => {
  if (!SLUG.test(req.params.slug)) return res.status(400).send("invalid slug");
  res.send(`post ${req.params.slug}`);
});

export default app;

Rewrite the pattern so each input matches one way

Make every repetition start with a character the previous one can't consume: here each loop iteration must begin with a hyphen, so there is only one way to match. Then check the type and length before the regex runs. The rewrite also rejects leading, trailing and doubled hyphens, which the original accepted. It rejected a 1,000,000-character near miss in under 10 ms. Fixes for other stacks are at /fix/regex-dos/.

Fixedslug.mjsJavaScript
// One way to match any input: each repetition must start with "-".
const SLUG = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;

export function isSlug(value) {
  // typeof first: ?slug=a&slug=b arrives as an array in Express.
  return typeof value === "string" && value.length <= 64 && SLUG.test(value);
}

Keep a near-miss input in the test suite

A timing assertion with a long near-miss input catches the next person who makes the pattern ambiguous again.

Static checks find most cases before review. eslint-plugin-regexp's recommended config includes no-super-linear-backtracking and no-dupe-disjunctions; add no-super-linear-move for quadratic cases. Together they flagged every vulnerable pattern in this guide and passed the rewrites. CodeQL's js/redos and py/redos queries flag exponential patterns, and js/polynomial-redos and py/polynomial-redos flag quadratic ones that user input reaches; Java, C# and Ruby have equivalents.

Testslug.test.mjsJavaScript
import { test } from "node:test";
import assert from "node:assert/strict";
import { isSlug } from "./slug.mjs";

test("accepts real slugs and rejects everything else", () => {
  for (const ok of ["a", "post-1", "2026-10-10-release"]) assert.ok(isSlug(ok), ok);
  for (const bad of ["", "-a", "a-", "a--b", "Post", "a_b", "a\n", "a".repeat(65), ["a", "b"], 42]) {
    assert.ok(!isSlug(bad), JSON.stringify(bad));
  }
});

test("near-miss inputs fail fast", () => {
  for (const attack of ["a".repeat(64) + "!", "a-".repeat(31) + "!", "a".repeat(1_000_000) + "!"]) {
    const start = performance.now();
    assert.equal(isSlug(attack), false);
    assert.ok(performance.now() - start < 10, `${attack.length} chars took too long`);
  }
});

Match the whole value in Python

Use re.fullmatch. With re.match and ^...$, the $ also matches before a trailing newline, so "abc\n" passes; .NET's $ behaves the same way, which is why the C# example below ends in \z. Since Python 3.11, possessive quantifiers and atomic groups stop the engine from re-splitting earlier repetitions. The possessive LEGACY pattern accepted exactly what the nested pattern accepted on every string of up to eight characters drawn from "a", "-" and "!", yet rejected a 100,000-character near miss in under 1 ms.

Fixedslug.pyPython
import re

SLUG = re.compile(r"[a-z0-9]+(?:-[a-z0-9]+)*")  # one way to match any input


def is_slug(value: object) -> bool:
    # fullmatch, not ^...$ with match(): "$" also matches before a trailing "\n".
    return isinstance(value, str) and len(value) <= 64 and SLUG.fullmatch(value) is not None


# Python 3.11+: if you must keep a nested pattern, a possessive loop stops the
# engine from re-splitting earlier repetitions after a later failure.
LEGACY = re.compile(r"(?:[a-z0-9]+-?)*+")

Use a linear-time engine where the pattern allows it

RE2 and engines built on its design (Go's regexp, Rust's regex crate, RE2/J) run in time linear in the input. The trade-off is syntax: they reject backreferences and lookarounds. In Node.js 26, V8's own linear engine is still reachable only through experimental flags: --enable-experimental-regexp-engine enables an l flag, and --enable-experimental-regexp-engine-on-excessive-backtracks switches engines after 50,000 backtracks by default. Use the re2 package in production instead.

Use a linear-time engine where the pattern allows it
RuntimeLinear-time optionLimits
Node.jsre2 package (RE2 bindings)No backreferences or lookarounds; RE2 objects are not RegExp instances
Pythongoogle-re2; Pydantic patterns use Rust regex by defaultNo backreferences or lookarounds
.NET 7+RegexOptions.NonBacktrackingNo backreferences, lookarounds, atomic groups or conditionals
JavaRE2/J (com.google.re2j)No backreferences or lookarounds
Go, RustStandard regexp and regex crateLinear by design; no backreferences

Set NonBacktracking or a timeout in .NET

A .NET Regex has no timeout by default. [RegularExpression] validation attributes default to 2,000 ms, and the REGEX_DEFAULT_MATCH_TIMEOUT AppContext setting changes the process-wide default. Prefer NonBacktracking; keep a short timeout for patterns that need lookarounds, and treat RegexMatchTimeoutException as invalid input. In a test, a 50 ms timeout fired at 51 ms on an input that would otherwise run for hours.

FixedValidators.csC#
using System.Text.RegularExpressions;

public static partial class Validators
{
    // NonBacktracking runs in time linear in the input; \z, because $ also matches before a final \n.
    [GeneratedRegex(@"^[a-z0-9]+(?:-[a-z0-9]+)*\z", RegexOptions.NonBacktracking)]
    private static partial Regex Slug();

    public static bool IsSlug(string? value) => value is { Length: > 0 and <= 64 } && Slug().IsMatch(value);

    // A pattern that needs backtracking features gets a per-match time budget instead.
    private static readonly Regex Legacy =
        new(@"^(?!.*--)[a-z0-9-]+\z", RegexOptions.None, TimeSpan.FromMilliseconds(50));

    public static bool IsLegacySlug(string value)
    {
        try { return Legacy.IsMatch(value); }
        catch (RegexMatchTimeoutException) { return false; } // a timeout is a validation failure
    }
}

Know what can interrupt a running match

In Node.js, terminating a worker or running the match through vm with a timeout does interrupt it: on Node.js 26 both stopped, within about 100 ms, a match that would have run for over a minute. Python's re can't be stopped from another thread. A thread pool's result(timeout=0.1) didn't return until a 2.8-second match finished, because the matching thread holds the GIL. SIGALRM interrupts it in the main thread, and a process pool can terminate it. The third-party regex module takes a timeout argument. PHP's PCRE stops after 1,000,000 backtracks by default and preg_match returns false, which code must treat as a rejection. Ruby 3.2 added match memoization and Regexp.timeout.

Never compile user input as a pattern

Building a regex from user text hands the attacker the pattern. For literal search, escape the text: RegExp.escape (Node.js 24 and current browsers), re.escape, Regex.Escape or Pattern.quote; ASVS 5.0 (1.2.9) requires this. When users really write patterns, compile them with RE2 under a length cap. Microsoft warns that NonBacktracking protects against expensive input, not against malicious patterns.

Fixedsearch.mjsJavaScript
import RE2 from "re2";

// Literal search text: escape it instead of compiling it (RegExp.escape, Node 24+).
export function literalMatcher(text) {
  return new RegExp(RegExp.escape(text), "iu");
}

// Patterns users write themselves: RE2 never backtracks, and it rejects
// backreferences, lookarounds and huge repetition counts with a SyntaxError.
export function userPattern(source) {
  if (typeof source !== "string" || source.length > 200) throw new RangeError("pattern too long");
  return new RE2(source, "iu");
}

Validate against an allowlist, in the right order

Decide what each field may contain and reject everything else: an enum for fixed choices, type and range for numbers, length and structure for strings, required and permitted keys for objects. Denylists that hunt for dangerous strings miss encodings and break legitimate input. Validate on the server even when the browser checks too (ASVS 5.0 2.2.1, 2.2.2). Check what each character class means in your engine: in Python 3, \d and \w match any Unicode digit or letter, so "١٢٣" passes \d+; write [0-9] or pass re.ASCII.

Order matters. Decode input once, normalize it, then validate the exact value you will use (ASVS 5.0 1.1.1; CWE-180). A check for "../" on the raw value passes the fullwidth "../", which NFKC normalization later turns into "../". A value decoded once from %252e%252e%252f reads %2e%2e%2f; decoding it again after validation produces "../". Choose the normalization form deliberately, because NFKC folds characters that differ in meaning. /fix/path-traversal/ covers the path check that follows.

Decide what a repeated parameter means

No standard says which copy of a repeated parameter wins, so frameworks disagree. A proxy or WAF that checks one copy while the application uses another lets a forbidden value through (ASVS 5.0 15.3.7). Reject repeated parameters you don't expect. The table shows how each framework tested read ?id=1&id=2 as a single value.

Decide what a repeated parameter means
Framework (tested version)Read as one valueResult
Express 5.3req.query.id["1", "2"], an array
Flask 3.1request.args.get("id")"1", the first
Django 6.1request.GET["id"]"2", the last
Spring Boot 4.1@RequestParam String id / int id"1,2" / 1
ASP.NET Core 10[FromQuery] string id / int id"1,2" / 400 error
PHP 8.4$_GET["id"]"2", the last
Go 1.25r.URL.Query().Get("id")"1", the first

Reject arrays, objects and loose type coercion

Type checks belong in validation too, because a value of the wrong type can slip past a check written for strings or crash the code after it.

  • Check that each scalar really is a string before calling string methods; in Express 5 an array makes .toLowerCase() throw, and the request ends in a 500.
  • Keep Express on the simple query parser unless you need nested objects; the extended parser builds them from ?user[role]=admin.
  • Coercion hides bad input. Zod's z.coerce.number() turned "" into 0, "0x10" into 16 and ["7"] into 7, and z.coerce.boolean() turned "false" into true. Pydantic's default lax mode accepted "5" and True for an int.
  • Compare with strict equality (ASVS 5.0 15.3.5). In PHP 8, "0e123" == "0e456" is still true.

Parse requests with a strict schema

A schema library turns these rules into one declaration. With z.strictObject, unknown keys fail, a repeated status arrives as an array and fails the enum, and the digit regex runs before the conversion to a number. In Ajv, keep coerceTypes off, set additionalProperties to false and pass RE2 as code.regExp: a nested pattern took 12.6 seconds through Ajv's default RegExp on 29 characters and 0.1 ms through RE2. In Java, Jakarta Validation's @Pattern uses java.util.regex and treats null as valid, so pair it with @NotNull and @Size.

Fixedorders.mjsJavaScript
import express from "express";
import { z } from "zod";

const ListOrders = z.strictObject({
  status: z.enum(["open", "shipped", "cancelled"]).optional(),
  limit: z.string().regex(/^[1-9][0-9]{0,2}$/).transform(Number).pipe(z.number().max(100)).optional(),
});

const app = express();
app.get("/orders", (req, res) => {
  const query = ListOrders.safeParse(req.query); // repeated keys arrive as arrays and fail here
  if (!query.success) return res.status(400).json({ error: "invalid query" });
  res.json(query.data); // { status?: "open" | "shipped" | "cancelled", limit?: number }
});

export default app;

Turn off coercion in Pydantic

Pydantic v2 checks pattern constraints with the Rust regex crate by default, so the nested slug pattern that took 2.7 seconds with regex_engine set to python-re ran in 0.06 ms. Patterns that need lookarounds fail when the model is built. strict=True stops "5" and True from becoming integers, and extra="forbid" rejects unknown fields.

Fixedmodels.pyPython
from typing import Annotated, Literal

from pydantic import BaseModel, ConfigDict, Field, StringConstraints

# Pydantic's default regex engine is Rust's regex crate, which runs in linear time.
Slug = Annotated[str, StringConstraints(max_length=64, pattern=r"^[a-z0-9]+(?:-[a-z0-9]+)*$")]


class CreatePost(BaseModel):
    model_config = ConfigDict(strict=True, extra="forbid")  # no "5" -> 5, no unknown fields

    slug: Slug
    status: Literal["draft", "published"]
    priority: int = Field(ge=1, le=5)

Guided practice

A support portal team says its validation layer is solid. Review the snippets and observations below, work out which ones an attacker can exploit, and write the fixes.

Supplied scenario material
Plain text
V1  Email check (Node.js): /^([a-zA-Z0-9]+[._-]?)*@example\.com$/.test(req.body.email)
V2  Ticket search (Node.js): new RegExp(req.query.q, "i")
V3  Tag check (Python): re.match(r"^[a-z0-9-]{1,32}$", tag)
V4  Rejects "../" in file names, then calls urllib.parse.unquote(name) before opening the file
V5  Page size (Node.js): z.coerce.number().max(100).parse(req.query.size)
V6  A WAF checks the first ?role= value; Django reads request.GET["role"]
Observations
O1  An address of 30 "a" characters followed by "@example.co" took 4.0 s to reject; other requests waited.
O2  q=(x+x+)+y with a long message body pinned a CPU core.
O3  Tag "ops\n" was stored and broke a CSV export.
O4  ?size= returned 0 results instead of a validation error.
  1. 1. Fix the email pattern

    Explain O1 and replace V1.

    Expected result and reasoning

    The optional separator makes ([a-zA-Z0-9]+[._-]?)* ambiguous, so a near miss is exponential and blocks the event loop. Cap the length, use an unambiguous pattern such as ^[a-zA-Z0-9]+(?:[._-][a-zA-Z0-9]+)*@example\.com$, or use a maintained email validator, and add a near-miss timing test.

  2. 2. Fix the search

    Explain O2 and replace V2.

    Expected result and reasoning

    The user controls the pattern itself. For literal search use new RegExp(RegExp.escape(q), "i"); if regex search is a feature, compile with RE2 under a length cap and return 400 on SyntaxError.

  3. 3. Fix whole-value matching

    Explain O3 and correct V3.

    Expected result and reasoning

    Python's $ matches before a trailing newline, so "ops\n" passes re.match. Use re.fullmatch(r"[a-z0-9-]{1,32}", tag) or end with \Z.

  4. 4. Fix the order of checks

    Show how V4 can be bypassed and fix it.

    Expected result and reasoning

    %2e%2e%2f passes the raw check, and unquote turns it into "../" after validation. Decode once (the framework already did), normalize, validate the final value, then resolve the path and confirm it stays inside the base directory.

  5. 5. Fix coercion and pollution

    Explain O4 and V6, and correct both.

    Expected result and reasoning

    z.coerce.number() turns "" into 0, and also accepts "0x10" and arrays. Validate the string with ^[1-9][0-9]{0,2}$ before converting. The WAF checks the first role while Django uses the last; reject requests with repeated parameters (getlist length greater than 1) and enforce authorization in the application, not by parameter filtering.

Your deliverable: Corrected code for V1 to V6, plus a regression test for each near-miss input or parsing trick.

Paper exercise: the fixture is complete. You can reproduce O1 to O4 with Node.js 24+, Python 3.11+, Zod 4 and Django locally.

Save a draft on this device to resume later. Automatic saving begins after you choose Save. Drafts are never sent by this application; use synthetic data.

No draft saved on this device yet.

Sources & further reading

The standards, specifications and documentation behind the guidance above. Each is the evidence for a claim on this page; consult it for detail beyond the defaults and versions listed here.

  1. CWE-1333: Inefficient Regular Expression Complexity. CWE 4.20.
  2. OWASP Input Validation Cheat Sheet: regular expressions
  3. Cloudflare: details of the Cloudflare outage on July 2, 2019
  4. Stack Exchange: outage postmortem, July 20, 2016
  5. GitHub advisory GHSA-9wv6-86v2-598j: path-to-regexp outputs backtracking regular expressions (CVE-2024-45296)
  6. Microsoft Learn: .NET regular expression options, NonBacktracking mode
  7. Python 3.14 re module: possessive quantifiers, atomic groups and fullmatch. Python 3.14.8.
  8. RE2 README: linear-time matching and unsupported backtracking-only constructs
  9. OWASP ASVS 5.0.0, V1 Encoding and Sanitization (1.1.1, 1.2.9, 1.3.12). Version 5.0.0.
  10. Express 5 migration guide: upgrade to Express v5 (req.query)

Reference information

SDLC phase
Development

Download this guide as Markdown