What ReDoS looks like in practice

ReDoS happens when an attacker supplies input that causes a regex engine to do disproportionate work. In backtracking engines, patterns with nested repetition or ambiguous alternation can explode in runtime. Even in Rust, where the default regex crate uses a linear-time engine, you can still create expensive behavior through repeated scans, large haystacks, or by relying on features that force slower matching strategies.

A typical example is user-facing validation:

  • checking usernames, emails, or identifiers
  • extracting fields from logs or HTTP headers
  • filtering search queries or route parameters

If these checks run on every request, a single pathological input can tie up CPU and reduce service capacity.

Why Rust helps, and where the risk remains

The standard regex crate avoids catastrophic backtracking by design. It compiles patterns into automata that guarantee linear-time matching with respect to the input size. That is a major security advantage.

However, there are still practical risks:

  • Unbounded input size: even linear-time matching can be expensive on very large strings.
  • Repeated matching: calling the same regex many times in a loop can amplify cost.
  • Fallback features: some advanced constructs are unsupported by the default engine and may push you toward slower alternatives.
  • Poor pattern design: while not catastrophic in the classic sense, some patterns still cause unnecessary work or surprising behavior.

The goal is not just “avoid catastrophic backtracking,” but “make regex use predictable and bounded.”

Prefer the default regex crate

For most application code, use the regex crate from crates.io and avoid engines that support backreferences or look-around unless you have a strong reason. Those features are often what make regexes expressive, but they also make matching harder to reason about.

Safe default behavior

use regex::Regex;

fn is_valid_username(input: &str) -> bool {
    // Anchored, simple, and linear-time under the default engine.
    let re = Regex::new(r"^[a-zA-Z0-9_]{3,32}$").unwrap();
    re.is_match(input)
}

fn main() {
    println!("{}", is_valid_username("alice_42"));
}

This pattern is easy to audit:

  • ^ and $ anchor the full string
  • the character class is explicit
  • the quantifier has a clear upper bound

That combination is ideal for security-sensitive validation.

Avoid ambiguous patterns

The most important habit is to keep patterns unambiguous. Ambiguity often appears when multiple alternatives can match the same prefix, or when nested repetition can match the same text in many ways.

Risky pattern shapes

Pattern shapeWhy it is riskySafer alternative
(a+)+Nested repetition can be hard to reason abouta+ with a length limit
`(foofo)+`Overlapping alternatives create ambiguityReorder or simplify the tokenization
.* in unanchored searchesCan scan more than intendedAnchor the pattern or use explicit classes
(.+)?Optional greedy groups can be unclearUse a precise character class and bounds

In Rust’s default engine, these patterns do not usually produce exponential blowups, but they still make code harder to maintain and can increase work on large inputs. Security-oriented regexes should be boring.

Bound the input before matching

Even a linear-time regex can become a problem if you feed it arbitrarily large data. Always consider a maximum input size before matching.

use regex::Regex;

const MAX_HEADER_LEN: usize = 256;

fn parse_token_header(header: &str) -> Option<&str> {
    if header.len() > MAX_HEADER_LEN {
        return None;
    }

    let re = Regex::new(r"^Bearer ([A-Za-z0-9._~-]+)$").unwrap();
    re.captures(header)
        .and_then(|caps| caps.get(1))
        .map(|m| m.as_str())
}

This approach protects you in two ways:

  1. it rejects oversized input early
  2. it limits the amount of work the regex engine must do

For network-facing services, size limits should be enforced as close to the boundary as possible, ideally before parsing or allocation.

Compile regexes once

Creating a regex repeatedly is wasteful. Compilation can be much more expensive than matching, and doing it per request can become a self-inflicted performance issue.

Use once_cell or lazy_static to compile once and reuse the pattern.

use once_cell::sync::Lazy;
use regex::Regex;

static EMAIL_RE: Lazy<Regex> = Lazy::new(|| {
    Regex::new(r"^[^\s@]+@[^\s@]+\.[^\s@]+$").unwrap()
});

fn is_email(input: &str) -> bool {
    EMAIL_RE.is_match(input)
}

This is not only faster, but also easier to test and audit. A single shared compiled regex reduces the chance of accidental pattern drift across the codebase.

Prefer exact matching over extraction when possible

If you only need to validate whether a string fits a format, use is_match with anchors rather than extracting captures you do not need. Captures add complexity and can obscure the intent of the check.

Example: validating an API key format

use once_cell::sync::Lazy;
use regex::Regex;

static API_KEY_RE: Lazy<Regex> = Lazy::new(|| {
    Regex::new(r"^sk_[A-Za-z0-9]{32,64}$").unwrap()
});

fn is_valid_api_key(candidate: &str) -> bool {
    candidate.len() <= 70 && API_KEY_RE.is_match(candidate)
}

This pattern is explicit about:

  • prefix
  • allowed characters
  • length range
  • maximum input size

That combination is much safer than a loose “anything goes” regex followed by ad hoc checks.

Be careful with repeated searches in loops

A common performance bug is scanning the same text multiple times with different regexes. If the input is attacker-controlled, that can multiply the cost of a request.

For example, do not write code like this unless the input is small and trusted:

for re in &regexes {
    if re.is_match(large_text) {
        // ...
    }
}

If you need to classify text, consider:

  • combining patterns where practical
  • parsing once and reusing the result
  • using a tokenizer or state machine instead of many regexes

Regex is a good tool for matching, but not always the best tool for full-text classification pipelines.

Use regex only for the part it is good at

Security bugs often appear when developers ask regex to do too much. A regex can validate structure, but it should not replace proper parsing for nested or context-sensitive formats.

Good uses

  • checking a short identifier format
  • extracting a version string
  • validating a simple token prefix
  • splitting a line into fixed fields

Poor uses

  • parsing nested parentheses
  • validating complex programming languages
  • handling recursive grammars
  • interpreting structured documents with many edge cases

If your pattern starts to look like a mini parser, switch to a parser. That is usually safer and easier to maintain.

Test against adversarial input

Security-oriented regexes should be tested with both valid and invalid examples. Include inputs that are long, repetitive, and near-miss cases that almost match.

A useful test strategy is to create a corpus of:

  • valid short inputs
  • invalid short inputs
  • very long inputs
  • repeated prefixes
  • strings that differ only at the last character
#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn rejects_oversized_input() {
        let input = "a".repeat(10_000);
        assert!(!is_valid_username(&input));
    }

    #[test]
    fn accepts_normal_username() {
        assert!(is_valid_username("dev_user_1"));
    }
}

You should also benchmark critical regexes under realistic load. A pattern that is fine in unit tests may still be too costly when applied to large logs or request bodies.

Know when to use a different crate or approach

Not every regex use case fits the same engine. The table below summarizes common choices.

ApproachBest forSecurity note
regex crateGeneral validation and extractionLinear-time matching, good default
Manual parsingFixed formats and strict protocolsOften more predictable than regex
Tokenization + state machineStructured text with multiple rulesEasier to bound and audit
Advanced regex enginesSpecialized features like backreferencesCan reintroduce performance risk

If you need features not supported by Rust’s default regex engine, evaluate whether the feature is truly necessary. Often the safer design is to simplify the format instead of expanding the matcher.

Practical checklist for secure regex use

Before shipping a regex into production, review it against this checklist:

  • Is the input size bounded before matching?
  • Is the pattern anchored when validating a full string?
  • Are the character classes and quantifiers explicit?
  • Are repeated matches avoided in hot paths?
  • Is the regex compiled once and reused?
  • Are tests included for long and near-miss inputs?
  • Could a parser or manual check be simpler?

If you can answer “yes” to the first six and “no” to the last one, you are probably in good shape.

A secure validation pattern in context

Here is a realistic example for a web service that accepts a short slug in a URL path segment.

use once_cell::sync::Lazy;
use regex::Regex;

static SLUG_RE: Lazy<Regex> = Lazy::new(|| {
    Regex::new(r"^[a-z0-9]+(?:-[a-z0-9]+)*$").unwrap()
});

pub fn validate_slug(slug: &str) -> bool {
    if slug.len() > 64 {
        return false;
    }

    SLUG_RE.is_match(slug)
}

Why this is a good security pattern:

  • the length is capped
  • the regex is anchored
  • the allowed alphabet is narrow
  • the hyphen rule is explicit
  • the regex is reused rather than recompiled

This is the kind of code that remains understandable months later, which is a security feature in itself.

Conclusion

ReDoS prevention in Rust is less about escaping catastrophic backtracking and more about writing regexes that are simple, bounded, and easy to reason about. The default regex crate gives you a strong foundation, but you still need to control input size, avoid ambiguous patterns, and keep regex usage focused on the right problem.

When in doubt, choose the simplest pattern that expresses the rule, compile it once, and test it with adversarial inputs. Predictability is the real defense.

Learn more with useful resources