String.replaceAll Replacement Trap: The Dollar Sign That Detonates Your URL Builder2026-09-02
This helper builds a password-reset link by substituting the user's email into a URL template. It's been running quietly in production for years. Then one Tuesday, support tickets start piling up: a handful of users can't reset their passwords, and the stack traces make no sense.
public class LinkBuilder {
private static final String TEMPLATE =
"https://example.com/reset?email=EMAIL_HERE&token=abc123";
public static String buildResetLink(String userEmail) {
// Substitute the user's email into the template.
return TEMPLATE.replaceAll("EMAIL_HERE", userEmail);
}
public static void main(String[] args) {
System.out.println(buildResetLink("[email protected]"));
// https://example.com/[email protected]&token=abc123 ✅
System.out.println(buildResetLink("[email protected]"));
// java.lang.IndexOutOfBoundsException: No group 1 💥
System.out.println(buildResetLink("weird\\[email protected]"));
// java.lang.StringIndexOutOfBoundsException 💥
}
}
String.replaceAll(regex, replacement) has two layers of special-character interpretation, and most developers only remember the first one.
Matcher.appendReplacement. Inside it, $1, $2, … refer to capture groups from the regex, and \ is an escape character.So when userEmail is [email protected], the replacement engine sees $1 and tries to substitute capture group 1 — but the regex "EMAIL_HERE" has no groups, so it throws IndexOutOfBoundsException: No group 1. A trailing backslash throws StringIndexOutOfBoundsException. And, more insidiously, if the regex did have groups, a $1 in user input would silently splice unrelated text into the URL.
This is the same class of bug as SQL injection or format-string injection: user data is being interpreted as code. The values $ and \ are perfectly legal in email local-parts (RFC 5321), so this will reach production.
Three options, in ascending order of "how much you actually need regex":
// 1. Best: don't use regex at all. String.replace() is a plain substring replace.
return TEMPLATE.replace("EMAIL_HERE", userEmail);
// 2. If you truly need a regex pattern, quote the replacement:
return TEMPLATE.replaceAll("EMAIL_HERE", Matcher.quoteReplacement(userEmail));
// 3. Or use replaceFirst — same trap, but at least explicit about intent:
return TEMPLATE.replaceFirst(Pattern.quote("EMAIL_HERE"),
Matcher.quoteReplacement(userEmail));
String.replace(CharSequence, CharSequence) — note the CharSequence overload, not the char one — does a literal substring replacement with no pattern interpretation on either side. It's what almost everyone actually wants.
The naming is the trap: replaceAll sounds like "replace all occurrences" (as opposed to just the first), when it really means "regex replace all." replace sounds like it only replaces once. It's the reverse of the intuition.
A useful rule of thumb: if either argument to replaceAll comes from outside your code, one of them needs quoting. Pattern.quote for the regex, Matcher.quoteReplacement for the replacement. Or just switch to replace and sleep better.
String.replaceAll's replacement argument is a pattern, not a literal — user input containing $ or \ will crash or silently corrupt output unless wrapped in Matcher.quoteReplacement, so prefer plain String.replace whenever you don't actually need a regex.
