Preventing ReDoS in Validation Regex

Can a single form submission take your server down? With the wrong regular expression, yes. Regular-expression denial of service (ReDoS) happens when a backtracking engine — which is what JavaScript’s RegExp uses — meets a pattern with nested or overlapping quantifiers and an input crafted to almost match. Evaluation time grows exponentially with input length: 20 characters take milliseconds, 30 take seconds, 40 take hours, and the Node.js event loop is blocked the entire time. This recipe shows how to recognise vulnerable validation patterns, rewrite them to run in linear time, cap input length before matching, move untrusted patterns to a linear-time engine, and test for regressions — for patterns used on the server and in the browser’s pattern attribute alike, where the Constraint Validation API evaluates them on every input.

When to Audit Validation Patterns for ReDoS

Audit every regex that runs on user-controlled input, which for validation means all of them. Prioritise:

  • Server-side patterns — email, URL, username, name and free-text checks — where one slow match blocks every other request on the process.
  • Patterns copied from the internet, particularly long email and URL regexes, which are a well-documented source of ReDoS vulnerabilities.
  • Patterns in pattern attributes and client validators, which cannot take down your server but can freeze a user’s tab — and which are often copied to the server later.
  • User-supplied patterns, such as admin-configurable validation rules, which should never run on a backtracking engine at all.

Most validation needs no complex regex in the first place. Email syntax is better left to type="email", URLs to the URL constructor, as in URL input validation with the URL constructor, and names to Unicode property checks. Every regex you delete is one you never have to audit.

Matching time as input length grows A bar chart of the time a vulnerable nested-quantifier pattern takes to reject crafted inputs of increasing length, compared with a linear rewrite. Vulnerable, 20 chars 5 ms Vulnerable, 25 chars 160 ms Vulnerable, 30 chars 5100 ms Vulnerable, 33 chars 40000 ms Linear rewrite, 10 000 chars 1 ms
Each extra character roughly doubles the vulnerable pattern's work; the linear rewrite stays flat. Timings are illustrative orders of magnitude on a typical laptop.

Recognising Vulnerable Patterns

Catastrophic backtracking needs two ingredients: a way for the engine to match the same characters in many different ways, and a failure at the end that forces it to try all of them. The shapes that provide the first ingredient are few and recognisable.

Shape Example Why it explodes
Nested quantifier (a+)+, ([a-z0-9]+\s?)* Each character can belong to the inner or outer repetition
Overlapping alternation under a quantifier (a|a)*, (\w|\d)+ Several branches match the same character
Adjacent overlapping quantifiers \d+\d+$, .*.*= The split point between them can be anywhere
Optional separator in a repeated group (\w+[-.]?)+@ The separator’s absence merges groups ambiguously
// Vulnerable: username "words separated by single spaces"
const vulnerable = /^([a-zA-Z0-9]+\s?)*$/;
// Crafted input: many valid characters, then one that forces failure
const attack = "a".repeat(32) + "!";
// vulnerable.test(attack)  → takes seconds to minutes; blocks the thread

// Linear: each character has exactly one way to match
const linear = /^[a-zA-Z0-9]+(?: [a-zA-Z0-9]+)*$/;
linear.test(attack);        // → false, immediately

The rewrite works because the space is now required between repetitions rather than optional, so there is exactly one way to divide any string into groups. That is the general technique: make each character’s role unambiguous.

Minimal Working Defences

// 1. Cap length BEFORE matching — the cheapest and most effective defence.
export function safeTest(re: RegExp, value: string, maxLength: number): boolean {
  if (value.length > maxLength) return false;
  return re.test(value);
}

// 2. Prefer unambiguous patterns. Common validation patterns, rewritten:
export const PATTERNS = {
  // words separated by single spaces or hyphens
  displayName: /^[\p{L}\p{M}'’]+(?:[ -][\p{L}\p{M}'’]+)*$/u,
  // slug: lowercase words joined by single hyphens
  slug: /^[a-z0-9]+(?:-[a-z0-9]+)*$/,
  // UK postcode (shape only), no nested quantifiers
  ukPostcode: /^[A-Z]{1,2}\d[A-Z\d]? \d[A-Z]{2}$/,
  // decimal amount with optional 2dp
  amount: /^\d{1,9}(?:\.\d{1,2})?$/,
} as const;

// 3. For patterns you don't control (admin-configured rules), use a linear-time engine.
import RE2 from "re2";   // server-side binding to Google's RE2; no backreferences or lookaround
export function compileUntrusted(source: string): { test(s: string): boolean } {
  return new RE2(source, "u");       // throws on unsupported syntax instead of risking backtracking
}

// 4. Wrap server validation so a regression cannot block the event loop indefinitely.
export function validateField(re: RegExp, value: string, maxLength = 256): boolean {
  return safeTest(re, value.normalize("NFC"), maxLength);
}

Length caps turn an exponential problem into a bounded one: the vulnerable pattern above is harmless at 20 characters. But caps are a mitigation, not a fix — a cap of 256 still allows inputs far beyond the danger point for a truly vulnerable pattern. Always rewrite the pattern and cap the length.

Defence in depth for validation regex Input is length-capped, normalised, and matched with an unambiguous pattern or a linear-time engine for untrusted patterns, with tests guarding against regressions. Length cap reject before matching Normalise NFC, trim Unambiguous pattern no nested / overlapping quantifiers Untrusted patterns RE2, linear time Regression tests timed attack strings
The cap bounds the damage, the rewrite removes the cause, and the tests keep it removed.

Regex Safety Option Reference

Option Type Default Purpose
maxLength number per field (e.g. 256) Bounds worst-case work; enforced before matching
Anchors ^…$ pattern always Prevents scanning every start position
Non-capturing groups (?:…) pattern preferred Clearer intent, slightly faster
RE2 engine library for untrusted patterns Guaranteed linear time; no backreferences
Static analysis lint rule in CI Flags super-linear patterns before merge
Timed tests unit test per pattern Fails if an attack string takes over a threshold

The v flag’s set operations make character classes easier to write correctly, but no flag changes JavaScript’s backtracking behaviour; safety comes from the pattern’s structure.

Verification Steps

import { describe, it, expect } from "vitest";
import { PATTERNS } from "./patterns";

const timed = (fn: () => void) => { const t = performance.now(); fn(); return performance.now() - t; };

describe("validation patterns run in linear time", () => {
  for (const [name, re] of Object.entries(PATTERNS)) {
    it(`${name} rejects a crafted near-match quickly`, () => {
      const attack = "a".repeat(5000) + "!";
      expect(timed(() => re.test(attack))).toBeLessThan(20);
    });
  }
});

Edge Cases and Failure Modes

Lookaheads that re-scan. Password rules written as stacked lookaheads — ^(?=.*[A-Z])(?=.*\d)(?=.*\W).{12,}$ — each scan the whole string. They are linear per lookahead, not catastrophic, but combined with a nested quantifier inside a lookahead they can become quadratic or worse. Better still, replace them with separate simple checks, which also produce specific messages, as in live password requirements checklist.

Global flag state. A RegExp with the g or y flag keeps lastIndex between test() calls, so validating two values in a row with the same object can give a false negative. Validation patterns should not use g.

Client-side patterns reaching the server. The pattern attribute is compiled with the v flag in modern browsers, and teams often reuse the string on the server with new RegExp(pattern) — different flags, different behaviour. Keep one pattern definition with explicit flags in a shared module.

Worker offloading is not a fix. Moving validation into a worker thread keeps the main event loop responsive but still burns a CPU core per attack request. It is useful as a last line of defence with a timeout; it does not replace rewriting the pattern.

Replacing Regex With Parsers Where You Can

Many ReDoS incidents come from regexes asked to do a parser’s job: validating emails, URLs, dates, JSON-ish strings or nested structures. Parsers are built for this — they read input once, left to right, and fail fast. Use type="email" (and the server-side equivalent of the HTML specification’s simple grammar) for email syntax; use new URL() for URLs; use Temporal.PlainDate.from() or a strict split-and-check for dates; use JSON.parse inside try for JSON. Reserve regular expressions for short, flat shapes such as postcodes, slugs and codes, where the pattern is easy to read and easy to prove linear. The shape-only postcode rules in postal code validation by country are good examples of regexes that are safe precisely because they are simple.

Regex or something else? A decision tree for choosing between a built-in parser, a simple anchored regex, or a linear-time engine for a validation rule. Is there a built-in parser for the format? yes Use it: type=email, URL, Temporal no Is the pattern written by you? no Linear-time engine (RE2) yes Simple anchored regex + length cap
Use parsers for structured formats, simple anchored patterns for flat shapes, and a linear-time engine for patterns you do not write yourself.

Frequently Asked Questions

What is ReDoS?

Regular expression denial of service: a backtracking regex engine takes exponential time on a crafted input because a pattern allows the same characters to be matched in many ways. In Node.js this blocks the event loop and stalls every request.

How do I tell if a validation regex is vulnerable?

Look for nested quantifiers such as (a+)+, overlapping alternatives under a quantifier, and adjacent quantifiers that can split input in many ways. Confirm with a static analyser and a timed test using a long near-matching input.

Does limiting input length prevent ReDoS?

It bounds the damage and is essential, but it is not a fix on its own: a vulnerable pattern can still take seconds within a few dozen characters. Rewrite the pattern to be unambiguous and cap the length as well.

Can I safely run user-supplied regex patterns?

Only with a linear-time engine such as RE2, which rejects features like backreferences that require backtracking. Never run untrusted patterns with the built-in RegExp on a server.

← Back to Validation Security and Abuse Prevention