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
patternattributes 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.
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.
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.
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.
Related Guides
- Validation Security and Abuse Prevention — ReDoS among the other server-side controls.
- HTML5 Pattern Attribute Regex Examples — client-side patterns, now with safety in mind.
- Memoizing Expensive Synchronous Validators — performance for legitimately costly rules.
- Property-Based Testing Validators with fast-check — generating adversarial inputs automatically.