When you build HTML as a string, some characters in it are instructions to the browser, not text: < starts a tag, and & starts a character reference. A character reference is a short code that stands for one character, such as < for < and & for &; they're often called HTML entities. The browser shows the character a reference stands for as text, so < appears on the page as < but never starts a tag. HTML escaping means replacing the special characters in a piece of text with references, so the browser shows the text exactly as it was written.
It matters most when the text comes from someone else: a user's name, a comment, a search query, data from an API. Put into HTML as it is, any markup in it becomes part of your page, and markup can run JavaScript. That's called cross-site scripting (XSS): an attacker's code runs in your page with the same powers as your own, so it can read what the page shows and send requests as the signed-in user. Say a user sets their name to <img src=x onerror=alert(1)>, and the page greets them with "<p>Hello, " + name + "</p>":
| The HTML the page builds | What the browser does |
|---|---|
<p>Hello, <img src=x onerror=alert(1)></p> | Creates an <img> element. x isn't a real image, so loading it fails, and the browser runs the code in onerror. An attacker would put something worse than alert(1) there. |
<p>Hello, <img src=x onerror=alert(1)></p> | The name was escaped. The page shows the text Hello, <img src=x onerror=alert(1)>, as the user typed it, and nothing runs. |
A <script> element in the name would run too, when the HTML is built on the server and sent as the page. Scripts added with innerHTML don't run, which is why attacks there use event attributes such as onerror instead.
Which characters matter depends on where the text goes:
| Character | Where it matters | What it does there |
|---|---|---|
< | In an element's content | Starts a tag, such as <img, or an end tag, such as </p>. |
& | In content and in attribute values | Starts a character reference, so a user who typed < would see < instead. |
" | In an attribute value wrapped in " | Ends the value early, and the rest becomes new attributes: a title of x" onmouseover="alert(1) adds an onmouseover handler. |
' | In an attribute value wrapped in ' | Ends the value early, in the same way. |
> | Nowhere in content or in quoted attribute values | It only ends a tag that's already open. Escaping it anyway is the convention, and it does no harm. |
Escaping protects text in an element's content and in a quoted attribute value. Other places, such as a URL in an href or code inside a <script>, need different protection. Day to day, you often don't escape by hand: setting textContent puts text into the page as text, and React and Vue treat values you put in with {name} and {{ name }} as text, unless you opt out with dangerouslySetInnerHTML or v-html. You need escaping when you build an HTML string yourself, for innerHTML or in a server-side template.
A URL starts with a scheme, the part before the first :, which says what kind of URL it is: https: loads a page over the web, and mailto: starts an email. The javascript: scheme is different: whatever follows the colon is JavaScript code, and following the link runs it. So <a href="javascript:alert(1)">Claim your prize</a> looks like any other link, but clicking it runs alert(1) inside your page, in its origin (its scheme, host, and port, such as https://shop.example). The code gets the same powers as your own scripts: it can read what the page shows and send requests as the signed-in user. It's cross-site scripting (XSS), arriving through a URL instead of a tag.
That's a risk wherever a URL comes from someone else: the website on a user's profile, a link in a comment written in Markdown, a page to go back to after signing in. HTML escaping doesn't help. javascript:alert(1) has no <, &, ", or ' in it, so it comes out of escaping exactly as it went in, and still runs. data: URLs are another way in: they carry a whole file inside the URL, so data:text/html,<script>alert(1)</script> is an HTML page with a script in it. Chrome, Firefox, and Safari no longer let a link open one in the tab, but no link to a website needs one either.
Spotting javascript: yourself is harder than it looks, because browsers are forgiving about how a URL is written. The scheme can be in any mix of upper and lower case. Spaces and invisible control characters before it are dropped, and tabs and line breaks are removed from anywhere in the URL. In an HTML attribute, character references are decoded first, so javascript: in an href is javascript: too. new URL parses a URL the way the browser does, so it shows what the browser sees:
new URL("javascript:alert(1)").protocol; // "javascript:"
new URL("JavaScript:alert(1)").protocol; // "javascript:": the scheme ignores case
new URL(" javascript:alert(1)").protocol; // "javascript:": leading spaces are dropped
new URL("java\tscript:alert(1)").protocol; // "javascript:": the tab is removed
new URL("java\nscript:alert(1)").protocol; // "javascript:": and so is a line break
So don't try to list what's dangerous. That's a blocklist, and it lets through every spelling and every scheme you didn't think of. Use an allowlist instead: accept a URL only when it starts with something you know is safe, such as https://, http://, mailto:, or a relative URL starting with / or #, ignoring case. Treat anything else as unsafe, for example by showing the link's text without a link. It's a door with a guest list rather than a list of known troublemakers: a stranger in a disguise gets past the second, never the first.
Here's a blocklist that rejects URLs starting with javascript:, ignoring case, next to that allowlist:
| URL | Blocklist | Allowlist | What a browser does with it |
|---|---|---|---|
https://example.com | Allows it | Allows it | Opens the page. |
mailto:ada@example.com | Allows it | Allows it | Starts an email to Ada. |
JavaScript:alert(1) | Rejects it | Rejects it | Runs alert(1). |
javascript:alert(1) with a space in front | Allows it | Rejects it | Drops the space and runs alert(1). |
javascript:alert(1) with a tab after java | Allows it | Rejects it | Removes the tab and runs alert(1). |
data:text/html,<script>alert(1)</script> | Allows it | Rejects it | Current browsers won't open it from a link. Older ones did, and ran its script. |
Passing the allowlist doesn't make a URL safe to put into HTML as it is. It still goes inside an attribute value, so escape it as you would any text there. /a" onmouseover="alert(1) starts with /, so it passes, but put into href="..." as it is, its first " ends the value early, and the rest becomes an onmouseover attribute. Escaped, it's /a" onmouseover="alert(1), which stays inside the href. The browser decodes references such as & and " before it uses the URL, so escaping never breaks a link that worked.
You meet this wherever links come from users: Markdown and rich-text editors, profile pages, chat messages. React 19 replaces a javascript: URL in an href with one that only throws an error, but code that builds HTML strings, as a Markdown renderer does, or that sets element.href or location.href from data gets no such help: location.href = "javascript:alert(1)" runs the code too.
Write markdownToHtml(text). It converts four of Markdown's inline styles, code, bold, italic, and links, into HTML, and returns the HTML as a string. Everything else is text, including line breaks and characters like # and -.
Text must stay text. In text, code, and URLs, replace &, <, >, ", and ' with &, <, >, ", and ', so the only tags in the result are the ones described here.
Read text from left to right. When you reach a marker, look for its closing marker: the first one after it. If there is one, the pair becomes an element, and reading continues after the closing marker. If there isn't, the marker is ordinary text.
Text between a backtick and the next backtick becomes <code>…</code>. Nothing inside code is styled: stars and brackets in it stay as they are.
** is closed by the next ** and becomes <strong>…</strong>. A * that isn't part of a ** is closed by the next such * and becomes <em>…</em>. Bold and italic can contain the other styles: **a *b* c** gives <strong>a <em>b</em> c</strong>.
[ starts a link when the first ] after it is followed right away by (, and a ) comes after that. The link text, between [ and ], can contain code, bold, and italic. The URL is everything between ( and the first ): [the *docs*](/docs) gives <a href="/docs">the <em>docs</em></a>.
A link is only made when its URL starts with https://, http://, mailto:, /, or #, ignoring case. For any other URL, such as javascript:alert(1), output the link text on its own, with no <a> and no URL.
markdownToHtml("Use **bold**, *italic*, and `code`.");
// "Use <strong>bold</strong>, <em>italic</em>, and <code>code</code>."
markdownToHtml("See [the *docs*](https://example.com) & <b>more</b>");
// 'See <a href="https://example.com">the <em>docs</em></a> & <b>more</b>'
markdownToHtml("Run `**x**` now"); // "Run <code>**x**</code> now"
markdownToHtml("[win a prize](javascript:steal)"); // "win a prize"
markdownToHtml("2 ** 3, and a lone `"); // "2 ** 3, and a lone `"
expect(markdownToHtml("Tom & Jerry <3")).toBe("Tom & Jerry <3");
expect(markdownToHtml('<img src=x onerror="alert(1)">')).toBe("<img src=x onerror="alert(1)">");
expect(markdownToHtml("It's plain # text - really")).toBe("It's plain # text - really");
expect(markdownToHtml("Run `npm test` now")).toBe("Run <code>npm test</code> now");
expect(markdownToHtml("`**not bold**` and `[a](/b)`")).toBe("<code>**not bold**</code> and <code>[a](/b)</code>");
expect(markdownToHtml('`<div class="x">`')).toBe("<code><div class="x"></code>");
expect(markdownToHtml("**bold** and *italic*")).toBe("<strong>bold</strong> and <em>italic</em>");
expect(markdownToHtml("**a** and **b**")).toBe("<strong>a</strong> and <strong>b</strong>");
expect(markdownToHtml("un*frigging*believable")).toBe("un<em>frigging</em>believable");
expect(markdownToHtml("**a *b* c**")).toBe("<strong>a <em>b</em> c</strong>");
expect(markdownToHtml("*a **b** c*")).toBe("<em>a <strong>b</strong> c</em>");
expect(markdownToHtml("**see `x` & [docs](/d)**")).toBe('<strong>see <code>x</code> & <a href="/d">docs</a></strong>');
expect(markdownToHtml("[**Docs** for `npm`](/docs)")).toBe('<a href="/docs"><strong>Docs</strong> for <code>npm</code></a>');
expect(markdownToHtml("See [the docs](https://example.com/docs).")).toBe('See <a href="https://example.com/docs">the docs</a>.');
expect(markdownToHtml("[a](/x) or [b](#top)")).toBe('<a href="/x">a</a> or <a href="#top">b</a>');
expect(markdownToHtml("[mail me](mailto:ada@example.com)")).toBe('<a href="mailto:ada@example.com">mail me</a>');
expect(markdownToHtml("[old](http://example.com)")).toBe('<a href="http://example.com">old</a>');
expect(markdownToHtml('[<b>](/search?q=a&b="c")')).toBe('<a href="/search?q=a&b="c""><b></a>');
expect(markdownToHtml('[x](/" onclick="alert(1))')).toBe('<a href="/" onclick="alert(1">x</a>)');
expect(markdownToHtml("[click](javascript:void 0)")).toBe("click");
expect(markdownToHtml("[click](JavaScript:void 0)")).toBe("click");
expect(markdownToHtml("[pic](data:text/html,<script>x</script>)")).toBe("pic");
expect(markdownToHtml("[**win**]( javascript:void 0)")).toBe("<strong>win</strong>");
expect(markdownToHtml("[ok](HTTPS://EXAMPLE.COM)")).toBe('<a href="HTTPS://EXAMPLE.COM">ok</a>');
expect(markdownToHtml("2 * 3 = 6")).toBe("2 * 3 = 6");
expect(markdownToHtml("a ** b *c*")).toBe("a ** b <em>c</em>");
expect(markdownToHtml("an open ` quote")).toBe("an open ` quote");
expect(markdownToHtml("[a] (b) [c](d")).toBe("[a] (b) [c](d");
expect(markdownToHtml("[a] or [b](/c)")).toBe('[a] or <a href="/c">b</a>');
expect(markdownToHtml("**a** b**")).toBe("<strong>a</strong> b**");
expect(markdownToHtml("*a* b*")).toBe("<em>a</em> b*");
expect(markdownToHtml("`a` b `c`")).toBe("<code>a</code> b <code>c</code>");
expect(markdownToHtml("[a](/x) b)")).toBe('<a href="/x">a</a> b)');
Run tests runs every test on your code, here in your browser. The problem is solved when they all pass.
Ctrl + Enter runs the tests (⌘ + Enter on a Mac).
I'm here to help with Inline Markdown to HTML. I give hints, never the solution, so you get to solve it yourself. Ask for a hint, select code to ask about it, or press Ask Tuto why on a failing test.
Your first 3 answers need no account. for free to keep asking.