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("Владимир")); // trueLookaround, 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 SQLimport { 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 commentPrisma'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.