javascript: URL Sanitizer Bypass: How Tab and Newline Characters Slip Past Your XSS Filter

Published 26 Sep 2026 · 9 min read

If you've ever written your own HTML sanitizer instead of pulling in a library, there's a good chance it blocks javascript: links with something that looks like this: lowercase the attribute value, then check whether it startsWith('javascript:'). It looks airtight. It isn't. This exact pattern was sitting in this site's own HTML Sanitizer until this post — reproduced for real against the URL parsing algorithm every browser and Node.js actually implement, not assumed from an old writeup, and fixed live in the tool linked below.

Quick answer: A plain string comparison like value.startsWith('javascript:') checks the raw attribute text. But when a browser turns that attribute into a URL — which is exactly what happens the instant a link is clicked — the URL Living Standard's basic URL parser strips every ASCII tab, newline, and carriage return from the string before it reads the scheme. Slip a tab into the middle of the word "javascript" — jav ascript:alert(1) — and the raw string no longer starts with javascript:, so the filter waves it through. The browser still parses it as javascript:alert(1) and runs it.

Quick reference

Every claim below was checked directly: the URL-normalization behavior against Node's own WHATWG-URL-compliant URL parser (the same algorithm browsers use to resolve an <a> tag's href on click), and the filter-bypass logic by running the exact comparison this site's sanitizer used.

PayloadNaive startsWith('javascript:') checkWhat the browser actually navigates to
javascript:alert(1)Blockedjavascript:alert(1)
JavaScript:alert(1)Blocked (after .toLowerCase())javascript:alert(1)
jav ascript:alert(1) (tab)Bypassedjavascript:alert(1)
jav ascript:alert(1) (newline)Bypassedjavascript:alert(1)
jav ascript:alert(1) (carriage return)Bypassedjavascript:alert(1)
  javascript:alert(1) (leading space)Bypassedjavascript:alert(1)

1. What the naive check looks like — and why it seems fine

Plenty of hand-rolled sanitizers do something like this after parsing user-supplied HTML with DOMParser:

// A pattern used by more homegrown sanitizers than you'd think
Array.from(node.attributes).forEach((attr) => {
  const name = attr.name.toLowerCase();
  const value = attr.value.toLowerCase();
  if (['href', 'src'].includes(name) && value.startsWith('javascript:')) {
    node.removeAttribute(attr.name); // looks safe...
  }
});

Throw the obvious attack at it and it works exactly as expected:

const value = 'javascript:alert(document.cookie)'.toLowerCase();
value.startsWith('javascript:'); // true — attribute gets removed. Good.

The bug isn't in the logic — it's in the assumption that the string you're comparing is the string the browser will eventually treat as a URL. It isn't. startsWith() only ever sees literal characters; a browser resolving an href runs a full URL-parsing algorithm first.

2. The bypass: a tab, newline, or carriage return inside the scheme

The WHATWG URL Standard — implemented identically by every major browser and by Node's own URL class — opens its basic URL parser with two normalization steps that run before anything else: strip leading/trailing C0 control characters and spaces, then remove every ASCII tab and newline from the input. That second step is what breaks the naive check. Reproduced directly, with no browser needed, since Node's URL implements the same standard:

// Verified directly in Node — node -e "..."
const raw = 'jav\tascript:alert(1)';
console.log(JSON.stringify(raw));
// "jav\tascript:alert(1)"

new URL(raw).protocol;
// 'javascript:'   <-- the tab is gone; this is what a browser navigates to

raw.toLowerCase().startsWith('javascript:');
// false           <-- this is what the naive sanitizer check sees

That single test is the whole bug: the sanitizer and the browser disagree about what the string is, and the sanitizer is the one that's wrong. A real HTML payload delivers the tab character without needing to type it literally — a normal HTML entity does the job, and DOMParser decodes it into an actual tab before your sanitizer ever inspects attr.value:

<a href="jav&#9;ascript:alert(document.cookie)">Click me</a>

// After DOMParser.parseFromString(html, 'text/html'):
anchor.getAttribute('href') === 'jav\tascript:alert(document.cookie)'; // true — real tab, decoded from 	

You can see the same normalization happen with zero risk in any browser console — reading the .href IDL property (not getAttribute) forces the browser to run it through the URL parser, without navigating anywhere:

const a = document.createElement('a');
a.setAttribute('href', 'jav\tascript:alert(1)');
a.getAttribute('href'); // 'jav\tascript:alert(1)' — the literal attribute text
a.href;                 // 'javascript:alert(1)'   — the parsed URL, tab stripped

Try the exact bypass live

This link prefills this site's HTML Sanitizer with a payload containing a real, tab-obfuscated javascript: href, plus a plain <p> tag so you can see the sanitizer's normal, correct behavior side by side. Before this post, the "Strict" and "Markdown/Blog" policies both let the tab-obfuscated link through untouched; the fix below now strips it like any other javascript: href.

Open HTML Sanitizer with the bypass payload

3. This isn't specific to startsWith() — most naive scheme checks share it

Swapping the check for a regular expression doesn't fix it on its own. /^javascript:/i.test(value) fails the exact same way, because a regex anchored at the start of the string is just as literal as startsWith() — it never normalizes the string first:

/^javascript:/i.test('jav\tascript:alert(1)'); // false — same bypass, different syntax

This is also why the OWASP XSS Filter Evasion Cheat Sheet has listed tab, newline, and carriage-return variants of the javascript: payload for years, and why PortSwigger's XSS cheat sheet documents the same class of bypass against sanitizers that pattern-match a scheme instead of parsing it. It's not an obscure edge case — it's one of the first things a security review throws at a homegrown sanitizer.

The fix: normalize before you compare — or allowlist instead of blocklist

The minimal fix mirrors what the URL spec itself does: strip leading/trailing C0 control characters and spaces, then remove any embedded tab, newline, or carriage return, and only then check the scheme:

const normalized = attr.value
  .toLowerCase()
  .replace(/^[\x00-\x20]+|[\x00-\x20]+$/g, '') // strip leading/trailing C0 control or space
  .replace(/[\t\n\r]/g, '');                    // strip embedded tab, newline, CR

if (['href', 'src'].includes(name) && normalized.startsWith('javascript:')) {
  node.removeAttribute(attr.name);
}

Verified against every payload from the reference table above:

function normalize(v) {
  return v.replace(/^[\x00-\x20]+|[\x00-\x20]+$/g, '').replace(/[\t\n\r]/g, '');
}
normalize('jav\tascript:alert(1)'.toLowerCase()).startsWith('javascript:');   // true — now blocked
normalize('jav\nascript:alert(1)'.toLowerCase()).startsWith('javascript:');   // true — now blocked
normalize('jav\rascript:alert(1)'.toLowerCase()).startsWith('javascript:');   // true — now blocked
normalize('  javascript:alert(1)'.toLowerCase()).startsWith('javascript:');  // true — now blocked
normalize('https://example.com'.toLowerCase()).startsWith('javascript:');    // false — legitimate links untouched

This is exactly the fix now shipped in this site's HTML Sanitizer — it previously compared the raw, un-normalized attribute value, which meant a tab- or newline-obfuscated javascript: href passed through every policy, including "Strict." A defense-in-depth improvement worth making at the same time: allowlist the schemes you accept (http:, https:, mailto:) rather than blocklisting javascript: specifically, since a blocklist only ever catches the bypass someone already thought to write down. This is the approach DOMPurify and the sanitize-html package both take internally, rather than a bare startsWith() check.

Debugging checklist

  1. If your sanitizer's javascript: check is a plain startsWith(), includes(), or an anchored regex on the raw attribute value, assume it's bypassable until you've normalized the string first.
  2. Reproduce it yourself: set a.setAttribute('href', 'jav\tascript:alert(1)') in a console and read a.href (not getAttribute) — if it comes back as javascript:alert(1), your parser normalizes; your string filter needs to as well.
  3. Strip leading/trailing C0 control characters and spaces, then strip embedded tabs, newlines, and carriage returns, before comparing against javascript: — or better, allowlist the schemes you accept instead of blocklisting the ones you don't.
  4. Don't stop at href/src — the same normalize-then-compare rule applies to any attribute your policy might resolve as a URL (formaction, action, xlink:href in SVG) if a custom allowlist ever permits it.

Related to this: the same "compare the raw string, not what the browser will parse it as" mistake shows up whenever code checks a URL's origin or protocol with string methods instead of the URL constructor — see this site's guide to URL encoding and decoding for the parsing-vs-string-matching distinction in a non-security context.

🛡️ Sanitize HTML without the javascript: URL blind spot

HTML Sanitizer now normalizes href/src values before checking for javascript: URLs — tab, newline, and carriage-return obfuscation included — across the Strict, Markdown/Blog, and Custom allowlist policies. Everything runs locally in your browser; nothing you paste is uploaded.

Open HTML Sanitizer →

← Back to Blog · See also: HTML Sanitization for Markdown and CMS Workflows →