Skip to content
← Journal
12 min readAta Mohammadi

The Senior Frontend Engineering Blueprint, Part 6: Regex Engines, ReDoS and Defending Untrusted Input

A validation regex that passes code review can still be an O(2^n) time bomb. Here is how V8's regex engine actually backtracks, the exact shape of pattern that wedges it, and the rest of the perimeter — Zod pipelines, parameterised SQL, DOMPurify, CSP — that a Next.js app needs around untrusted input.

A validation regex is the kind of code nobody re-reviews. It passes once, it matches the test cases in the PR description, and it sits in lib/validations/ for two years. Then someone submits a username with thirty nested repeated characters, the event loop stops responding, and the on-call engineer spends an hour looking at database locks and outbound HTTP calls before anyone thinks to check the regex.

Node runs regular expressions synchronously on the main thread, with no execution timeout. A pattern that looks perfectly ordinary — /^([a-zA-Z0-9_.-]+)+@.../ is a real example people copy-paste into email validators — can force V8's engine into exponential backtracking on a specific shape of input. This is Regular Expression Denial of Service, or ReDoS, and it is one of the few vulnerability classes where the "attack" is just typing the wrong string into a form.

This part of the series steps back from the app itself to look at the machinery underneath a big piece of it: the regex engine that Next.js's proxy matcher, your Zod schemas, and half your string validation all sit on top of — and the rest of the input-defense layer around it.

How V8 actually backtracks

JavaScript's RegExp is a traditional NFA (nondeterministic finite automaton) engine, implemented in V8 as Irregexp. When an NFA engine evaluates nested quantifiers against text that does not match, it doesn't fail fast — it explores every way the quantifiers could have divided up the string before giving up.

Engine evaluation, pattern /^(a+)+$/ against "aaaaaaaaaaaaab":

  Step 1: outer (a+)+ hands the inner (a+) all 13 'a's
  Step 2: the trailing $ fails to match 'b'
  Step 3: backtrack — inner (a+) gives back one 'a', outer takes another pass
  Step 4: repeat, branching at every possible split point
  Result: ~2^13 partition attempts before the engine can say "no match"

Every extra character in that run of as roughly doubles the work. At thirteen characters it's a few milliseconds. At forty characters it's minutes. That's the whole vulnerability: two nested quantifiers over the same characters, with no way to tell from the outside which one consumed what.

The flag matrix

A RegExp operates over UTF-16 code units by default, which is a real correctness bug waiting to happen the moment non-Latin text or emoji shows up in your input:

Flag Name What it changes
u Unicode Treats the pattern as Unicode code points, not UTF-16 units — enables \p{...} escapes and stops surrogate pairs from being sliced in half
v Unicode Sets (ES2024) Extends u with set operations (-- subtraction, && intersection) inside character classes
y Sticky Matches only at regex.lastIndex, with no forward scan — essential for tokenizers, covered below
d Has Indices Populates match.indices with [start, end] for every capture group
const char = "𝒳"; // U+1D4B3, outside the Basic Multilingual Plane

console.log(/^.$/.test(char)); // false — without `u`, this is 2 UTF-16 units
console.log(/^.$/u.test(char)); // true

const namePattern = /^[\p{Letter}\p{Mark}\s'-]+$/u;
console.log(namePattern.test("René Descartes")); // true
console.log(namePattern.test("Владимир")); // true

Lookaround, without consuming characters

Lookarounds are zero-width assertions — they gate whether a match is valid without adding their own text to the match output. A positive lookahead (?=...) asserts what follows; a negative lookahead (?!...) asserts what doesn't. A single-pass password-strength check is the classic place to reach for several of them at once, because the requirements don't have a fixed order:

/**
 * - 12 to 64 characters:            (?=.{12,64}$)
 * - at least one uppercase letter:  (?=.*[\p{Lu}])
 * - at least one lowercase letter:  (?=.*[\p{Ll}])
 * - at least one digit:             (?=.*\d)
 * - at least one symbol:            (?=.*[^\p{L}\d\s])
 * - no whitespace anywhere:         (?!.*\s)
 */
export const SECURE_PASSWORD_REGEX =
  /^(?=.{12,64}$)(?!.*\s)(?=.*[\p{Lu}])(?=.*[\p{Ll}])(?=.*\d)(?=.*[^\p{L}\d\s]).*$/u;

Named capture groups do the same job for readability that they do for object destructuring — match[1] tells you nothing six months later, match.groups.year does:

const ISO_DATE_REGEX =
  /^(?<year>\d{4})-(?<month>0[1-9]|1[0-2])-(?<day>0[1-9]|[12]\d|3[01])$/;

const match = ISO_DATE_REGEX.exec("2026-09-16");
if (match?.groups) {
  const { year, month, day } = match.groups;
  console.log(`Formatted: ${day}/${month}/${year}`);
}

// Named backreferences work in replacement strings too
"2026-09-16".replace(ISO_DATE_REGEX, "$<day>.$<month>.$<year>"); // "16.09.2026"

Vulnerable vs. safe: the actual difference

The fix for catastrophic backtracking is almost always the same shape: don't let two quantified groups compete for the same characters.

// VULNERABLE — the engine can't tell which group "owns" a run of 'a's
const vulnerableRegex = /^(a+)+$/;

// SAFE — one quantifier, nothing to backtrack between
const safeRegex = /^a+$/;

// VULNERABLE — this exact pattern shows up in real codebases
const badEmail = /^([a-zA-Z0-9_.-]+)+@([\da-zA-Z.-]+)\.([a-zA-Z.]{2,6})$/;

// SAFE — mutually exclusive character sets around each separator, O(N)
const safeEmail = /^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$/;

The rule for interviews and for code review alike: avoid nesting quantifiers like (a+)* or (a|b+)+, and make sure the character set inside a repeated group can't overlap with whatever comes right after it.

Next.js proxy matchers are regexes too

config.matcher in proxy.ts (see Part 5 of this series for the file itself) isn't a special mini-language — Next.js compiles it through an extended path-to-regexp dialect into an ordinary RegExp, which means every request on a matched path pays for whatever that pattern costs to evaluate.

import { NextRequest, NextResponse } from "next/server";

export const config = {
  matcher: [
    /*
     * Run on everything except:
     * - api/health (internal checks)
     * - _next/static, _next/image (immutable/optimised assets)
     * - known static file extensions
     */
    "/((?!api/health|_next/static|_next/image|favicon\\.ico|sitemap\\.xml|robots\\.txt|.*\\.(?:svg|png|jpg|jpeg|gif|webp|ico|woff2?|css|js)).*)",

    // Belt and suspenders: this already matches the pattern above, but list
    // it explicitly so protected routes stay covered even if that exclusion
    // list changes later.
    "/api/protected/:path*",
  ],
};

export function proxy(req: NextRequest): NextResponse {
  const requestHeaders = new Headers(req.headers);
  requestHeaders.set("x-forwarded-pathname", req.nextUrl.pathname);
  return NextResponse.next({ request: { headers: requestHeaders } });
}

Matcher entries are OR'd — a request matches if it matches any entry — so that second line isn't doing anything mysterious, it's just an explicit safety net. The actual edge-performance tip is the negative-lookahead pattern itself: if the proxy only ever needs to guard /dashboard and /settings, an explicit matcher: ["/dashboard/:path*", "/settings/:path*"] skips the interceptor entirely for public routes, rather than running a broad exclusion pattern against every request.

The Zod pipeline

Everything above buys you nothing if the string never gets checked. Validation belongs on both sides of the network boundary — client-side for feedback, server-side because the client can't be trusted at all:

Incoming form data / JSON payload


  Structural validation (.object, .string, .number)

  Canonicalisation (.trim, .toLowerCase)

  Logical verification (.refine, .superRefine)

  Strip unexpected keys (.strict)

   valid? ──no──► typed field errors back to the client

       yes

  Data Access Layer / parameterised SQL
import { z } from "zod";

export const SignUpSchema = z
  .object({
    username: z
      .string()
      .trim()
      .min(3)
      .max(30)
      .regex(/^[a-zA-Z0-9_-]+$/, "Alphanumeric, underscores and dashes only"),

    email: z.email().trim().toLowerCase().max(254),

    websiteUrl: z
      .url()
      .trim()
      .refine((url) => {
        try {
          const protocol = new URL(url).protocol;
          return protocol === "http:" || protocol === "https:";
        } catch {
          return false;
        }
      }, "Only HTTP and HTTPS URLs are permitted")
      .optional()
      .or(z.literal("")),

    password: z.string().min(12).max(72), // 72: bcrypt's own input limit

    confirmPassword: z.string(),
  })
  .strict() // reject the payload outright if it carries unexpected keys
  .superRefine(({ password, confirmPassword }, ctx) => {
    if (password !== confirmPassword) {
      ctx.addIssue({
        code: "custom",
        message: "Passwords do not match",
        path: ["confirmPassword"],
      });
    }
  });

export type SignUpInput = z.infer<typeof SignUpSchema>;

Polymorphic payloads — a payment form that changes shape depending on the chosen method — are exactly what discriminated unions are for, and they narrow without a single runtime type cast:

import { z } from "zod";

const CreditCardPayment = z.object({
  method: z.literal("CREDIT_CARD"),
  cardNumber: z.string().regex(/^\d{16}$/),
  cvv: z.string().regex(/^\d{3,4}$/),
});

const CryptoPayment = z.object({
  method: z.literal("CRYPTO"),
  walletAddress: z.string().regex(/^0x[a-fA-F0-9]{40}$/),
  chain: z.enum(["ETHEREUM", "POLYGON"]),
});

export const PaymentFormSchema = z.discriminatedUnion("method", [
  CreditCardPayment,
  CryptoPayment,
]);

Defence in depth: the three classics

SQL injection happens when untrusted input concatenates directly into a query string, changing its structure rather than its data:

Input:    admin' --
Query:    SELECT * FROM users WHERE email = 'admin' --' AND pass = '...'
                                                    └── everything after this is now a comment

Prisma's ordinary API is parameterised by construction. The place people still get hurt is $queryRawUnsafe, and dynamic identifiers (table/column names), which parameters can't cover at all — those need an allowlist, never string interpolation:

import { prisma } from "@/lib/prisma";
import { Prisma } from "@prisma/client";

// Safe: Prisma's query builder parameterises automatically
export async function getUserByEmail(email: string) {
  return prisma.user.findUnique({ where: { email } });
}

// Safe: tagged template literals bind parameters positionally
export async function getActiveUsersByDomain(domain: string) {
  return prisma.$queryRaw<Array<{ id: string; email: string }>>`
    SELECT id, email FROM "User"
    WHERE email LIKE ${`%@${domain}`}
    ORDER BY "createdAt" DESC
    LIMIT 50
  `;
}

// HIGH RISK — never do this: string interpolation into raw SQL
export async function vulnerableSearch(maliciousInput: string) {
  return prisma.$queryRawUnsafe(
    `SELECT * FROM "User" WHERE name = '${maliciousInput}'`,
  );
}

// Dynamic identifiers can't be parameters at all — allowlist first
const ALLOWED_SORT_COLUMNS = new Set(["createdAt", "email", "name"]);

export async function getSortedUsers(sortBy: string) {
  if (!ALLOWED_SORT_COLUMNS.has(sortBy)) {
    throw new Error("Invalid sort column");
  }
  return prisma.$queryRaw`
    SELECT id, email FROM "User" ORDER BY ${Prisma.raw(`"${sortBy}"`)} DESC
  `;
}

Cross-site scripting comes in three shapes — stored (persisted, served to every viewer), reflected (bounced straight off a URL parameter), and DOM-based (client JS writes untrusted data into a sink like innerHTML). React's JSX escaping handles the common case for free, which is exactly why the two places it doesn't apply are worth calling out on purpose:

// Safe by default: React escapes this, it never becomes markup
function SafeByDefault({ untrustedInput }: { untrustedInput: string }) {
  return <div>{untrustedInput}</div>;
}

// Rich text needs an explicit allowlist sanitiser, not raw trust
import DOMPurify from "isomorphic-dompurify";

function RichTextRenderer({ untrustedHtml }: { untrustedHtml: string }) {
  const cleanHtml = DOMPurify.sanitize(untrustedHtml, {
    ALLOWED_TAGS: ["b", "i", "em", "strong", "a", "p", "ul", "ol", "li", "code", "pre"],
    ALLOWED_ATTR: ["href", "target", "rel"],
  });
  return <div dangerouslySetInnerHTML={{ __html: cleanHtml }} />;
}

// A bare href is an injection point React's escaping doesn't cover:
// userProfile.website = "javascript:alert(document.cookie)" would run.
function sanitizeHref(url: string): string {
  try {
    const protocol = new URL(url).protocol;
    return protocol === "http:" || protocol === "https:" ? url : "#";
  } catch {
    return "#";
  }
}

A Content Security Policy header is the backstop for everything you missed — it tells the browser which sources may execute at all, regardless of what slipped past sanitisation:

import { NextResponse } from "next/server";

export function withSecurityHeaders(response: NextResponse): NextResponse {
  const csp = `
    default-src 'self';
    script-src 'self';
    style-src 'self' 'unsafe-inline';
    img-src 'self' blob: data:;
    object-src 'none';
    base-uri 'self';
    form-action 'self';
    frame-ancestors 'none';
    upgrade-insecure-requests;
  `.replace(/\s{2,}/g, " ").trim();

  response.headers.set("Content-Security-Policy", csp);
  response.headers.set("X-Content-Type-Options", "nosniff");
  response.headers.set("X-Frame-Options", "DENY");
  response.headers.set("Referrer-Policy", "strict-origin-when-cross-origin");
  return response;
}

Prototype pollution is the one people forget because it doesn't look like an injection attack at all — it's a recursive merge that writes to __proto__ and quietly changes Object.prototype for the entire running process:

// VULNERABLE — if key is "__proto__", this pollutes every object's prototype
function dangerousMerge(target: any, source: any) {
  for (const key in source) {
    if (typeof source[key] === "object" && source[key] !== null) {
      if (!target[key]) target[key] = {};
      dangerousMerge(target[key], source[key]);
    } else {
      target[key] = source[key];
    }
  }
  return target;
}

// SAFE — block the dangerous keys explicitly
const BLOCKED_KEYS = new Set(["__proto__", "constructor", "prototype"]);

export function safeDeepMerge<T extends Record<string, unknown>>(
  target: T,
  source: Record<string, unknown>,
): T {
  const output: Record<string, unknown> = { ...target };
  for (const [key, value] of Object.entries(source)) {
    if (BLOCKED_KEYS.has(key)) continue;
    output[key] =
      value !== null && typeof value === "object" && !Array.isArray(value)
        ? safeDeepMerge((output[key] as Record<string, unknown>) ?? {}, value as Record<string, unknown>)
        : value;
  }
  return output as T;
}

Putting it together: a secure Server Action

Validation, sanitisation, and a transactional write, in the order they need to happen:

"use server";

import { z } from "zod";
import crypto from "node:crypto";
import { prisma } from "@/lib/prisma";
import { verifySession } from "@/lib/dal/auth";
import DOMPurify from "isomorphic-dompurify";
import { revalidateTag } from "next/cache";

const CreateArticleSchema = z.object({
  title: z
    .string()
    .trim()
    .min(5)
    .max(120)
    .regex(/^[\p{L}\p{N}\s.,!?'"-]+$/u, "Title contains invalid characters"),
  rawContent: z.string().min(20).max(20000),
  tags: z
    .array(z.string().trim().toLowerCase().regex(/^[a-z0-9-]+$/))
    .min(1)
    .max(5),
});

export type ActionResponse = {
  success: boolean;
  message?: string;
  fieldErrors?: Record<string, string[]>;
  postId?: string;
};

export async function createSecureArticleAction(
  _prevState: ActionResponse,
  formData: FormData,
): Promise<ActionResponse> {
  try {
    const session = await verifySession(); // authoritative check — see Part 5

    const validated = CreateArticleSchema.safeParse({
      title: formData.get("title"),
      rawContent: formData.get("rawContent"),
      tags: formData.getAll("tags"),
    });
    if (!validated.success) {
      return {
        success: false,
        message: "Invalid article submission.",
        fieldErrors: validated.error.flatten().fieldErrors,
      };
    }

    const { title, rawContent, tags } = validated.data;

    const sanitizedHtml = DOMPurify.sanitize(rawContent, {
      ALLOWED_TAGS: ["p", "b", "i", "strong", "em", "h2", "h3", "code", "pre", "ul", "li"],
      ALLOWED_ATTR: [],
    });

    const baseSlug = title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/(^-|-$)+/g, "");
    const slug = `${baseSlug}-${crypto.randomBytes(4).toString("hex")}`;

    const article = await prisma.$transaction(async (tx) => {
      return tx.post.create({
        data: {
          title,
          slug,
          content: sanitizedHtml,
          published: true,
          authorId: session.userId,
          categories: {
            connectOrCreate: tags.map((name) => ({ where: { name }, create: { name } })),
          },
        },
        select: { id: true },
      });
    });

    // Next.js 16 requires a second argument here — see this series' Part 2
    // and "Next.js in 2026: Flight Wire Format, Cache Components and Tag
    // Invalidation" (https://altixcode.com/journal/nextjs-flight-protocol-cache-components-tag-invalidation)
    // for what the single-argument form used to hide.
    revalidateTag("articles-feed", "max");

    return { success: true, message: "Article published.", postId: article.id };
  } catch (error) {
    return {
      success: false,
      message: error instanceof Error ? error.message : "Internal error.",
    };
  }
}

Cheat sheet

Threat Mechanism Framework default Resolution
ReDoS Nested quantifiers force O(2^n) backtracking on the main thread V8 runs regex synchronously with no timeout Never nest quantifiers over overlapping character sets; keep tokenizers on sticky (/y) regex
SQL injection Untrusted input changes query structure Prisma's query builder parameterises by default Avoid $queryRawUnsafe; allowlist any dynamic identifier
XSS Script executes in the victim's session React escapes JSX string interpolation Sanitise anything going through dangerouslySetInnerHTML; validate href protocols; set a CSP
Prototype pollution A recursive merge writes to __proto__ JS prototypes are mutable at runtime Block __proto__/constructor/prototype keys explicitly in any merge utility
Slow proxy matchers An overly broad regex runs on every request Proxy executes for every route unless matched out Use precise negative-lookahead exclusions or explicit path lists

None of these are exotic. They're all the same shape of mistake: trusting a string to be shorter, simpler, or better-behaved than an attacker is willing to make it. The fix is never "write a cleverer regex" — it's bounding what the regex, the query, the DOM sink, and the merge function are each allowed to do with what comes back.

Part 7 of this series takes the tokenizer half of this article and builds it out properly — a full, ReDoS-safe recursive-descent parser for a search query language.

Next step

Tell us what is broken or what should exist.

Send the shape of the problem and any constraints you already know — budget, deadline, the stack you are stuck with. You will get a written reply from the engineer who would do the work, not a sales sequence.