Skip to content
OneKitly

Stripping HTML Safely: What a Tag Remover Can and Cannot Do

Published 7/14/2026 · 14 min read · Text & language tools

Daniel Okonkwo

Daniel OkonkwoFront-end developer and tech writer at OneKitly

Web performance · File formats

Checked against 5 sources

View profile
In short

Removing tags and sanitising HTML are different jobs. A tag remover produces plain text for display, storage or counting; a sanitiser produces HTML that is safe to insert into a page. Confusing the two is how injection bugs get written, and a regex can never do the second job because it does not model the parser: give the pattern that matches a less-than sign, any run of non-greater-than characters and a greater-than sign the fragment <a title="fast > cheap" href="/x">our guide</a>, and it stops at the first greater-than it meets, emitting cheap" href="/x">our guide. Wherever the regex and the browser's tokeniser disagree, the regex is wrong, and the disagreements cannot be enumerated — which is why deny-lists fail as a class and the real defences are contextual output escaping at the point of insertion, or a parser-based allow-list sanitiser. As a formatting tool, though, a stripper has genuine work to do. On a 390-character fragment the naive pattern returns 183 characters containing the stylesheet body, a mangled line of JavaScript, the tail of a comment, undecoded entities, and two list items fused into StandardExpress. Drop raw-text element contents, turn block ends into blank lines and br into one newline, decode entities once: 118 clean characters.

Removing tags and sanitising HTML are two different jobs. One real fragment run through a naive regex and through a formatting-aware stripper, with script and style contents, block breaks, comments, CDATA and entity order all shown as output.

Two jobs that look like one

Stripping tags means producing plain text: something to show in a search result, count the words of, put in a plain-text email, feed to a summariser or store in a column that is never rendered as markup. Sanitising means producing HTML: markup that will be inserted into a live page and must therefore be safe there. The two look similar because both take HTML in and return something shorter. They are not the same job, they do not have the same success criterion, and only one of them is a security control.

This guide is about the first job, done properly. The security question is answered in the last section, briefly and without ambiguity, because the honest answer is short: a tag remover is not a security boundary, and no amount of extra patterns turns it into one. Everything in between is the formatting work that a stripper genuinely has to do and that most implementations get wrong — dropping the contents of script and style, turning structure into line breaks, handling comments and CDATA, and decoding entities in the right place.

What a naive tag regex does to a real fragment

The test fragment is 390 characters of ordinary page markup: an article wrapper, an inline stylesheet, a heading with an encoded ampersand, a paragraph split by a br and containing an en-dash entity, an HTML comment whose text happens to contain a greater-than sign, a two-item list, a link whose title attribute contains an encoded greater-than sign, and an inline script. Nothing exotic, nothing adversarial — this is what a CMS export looks like.

Run the standard one-liner — match a less-than sign, any run of characters that are not a greater-than sign, then a greater-than sign, globally — and 183 characters come back. Among them: the literal text .lead{font-weight:700}, which is the stylesheet body now reading as prose. The string var ok = a d;, which is the script with its middle removed because the regex read the comparison operators as a tag. The fragment 0 --> , which is the tail of the comment that survived because the regex stopped at the greater-than sign inside it. Two undecoded entities. And the words Standard and Express fused into StandardExpress, along with a sentence that ends Monday.Delivery because the br contributed nothing.

The same fragment through a stripper that knows what the elements mean returns 118 characters, arranged as five blocks: the heading, the two-line paragraph with the br break honoured, the two list items on their own lines, and the closing sentence — with the ampersand and the en dash decoded, and no trace of the stylesheet, the script or the comment. The difference between 183 and 118 characters is not compression. It is the removal of things that were never text.

Script and style contents are not tags

The single biggest formatting defect in naive strippers is that they remove the opening and closing tags of script and style and leave everything between them. That is not a small cosmetic problem: the CSS rules of a page or the source of an inline script land in the middle of what is supposed to be readable prose, and they will be indexed, counted, summarised and shown to somebody. In the test fragment, the CSS declaration block appears as the second line of output, immediately above the heading.

The HTML standard calls these raw text elements: inside script and style, the parser stops looking for tags and reads until it finds the matching end tag. That is why the naive regex also mangles what it leaks. In the test script, the expression containing a less-than and a greater-than sign is read by the regex as a tag and deleted, so the leaked JavaScript is not even the original JavaScript — it is a shortened version that happens to look like an English sentence. Two failure modes in one construct.

The fix is one rule placed before everything else: match the opening tag, its contents and its closing tag as a single unit for script, style, template and noscript, and delete the lot. Put this first in the pipeline, because once the tags are gone you can no longer tell which text used to be inside them.

Blocks need breaks, and br and p are not the same break

Deleting a tag deletes the whitespace it implied. Two paragraphs, one after the other with no whitespace between them in the source, become OneTwo. Two list items become AB. A heading followed by a paragraph becomes TB. This is the defect that makes stripped text unreadable and word counts wrong, and it is invisible in testing whenever the source HTML happens to be pretty-printed with newlines between the tags — which is exactly why it ships.

The mapping that produces readable output is short. The end tag of a block-level element — p, div, li, tr, h1 through h6, blockquote, section, article and the rest — becomes a blank line. A br becomes exactly one newline, because a br is a line break inside a block, not a new block. An hr becomes a blank line. Every other tag becomes nothing. Then collapse runs of three or more newlines down to one blank line, because a nested block emits two closing tags and therefore two blank lines.

One honest caveat: this mapping puts a blank line between list items, because li is a block. That reads oddly for a tight list, and the more polished version treats li as a single newline and only the surrounding ul or ol as a blank line. Whether you want that is a formatting preference rather than a correctness question, but it is a preference you have to make explicitly — a stripper that has never thought about lists will simply run them together, and that one is not a preference, it is a bug.

Comments, CDATA and entities: three special grammars

An HTML comment is not a tag and does not follow the tag grammar: it runs from a specific four-character opening to a specific three-character closing, and anything at all may appear in between, including greater-than signs. A tag regex that seems to remove comments is getting away with it only because most comments contain no greater-than sign. Put one in — a note like keep if stock is greater than zero — and the regex stops there, leaving the rest of the comment text in your output. In the test fragment, the visible residue is the string 0 followed by the comment terminator.

CDATA sections are a related case that survives in the wild inside SVG and inside XHTML-flavoured exports. Their content is by definition not markup, and their terminator is not a greater-than sign either. Run a CDATA section holding some markup-looking text through the naive regex and you get the inner text with the inner tags removed and the closing bracket sequence left dangling — a result that is wrong in three ways at once. Match and delete CDATA sections as a unit, before the general tag pass, exactly as you do for comments.

Entities come last, and exactly once. Decode after the tags are gone, never before: an escaped less-than sign in the source is text the author wanted visible, and decoding it first promotes it to a tag that the stripper then deletes. On a fragment saying use an em element for emphasis, with the element name escaped, stripping then decoding preserves the visible element name while decoding then stripping deletes it and leaves a gap. Decode twice and you manufacture live markup out of something that was deliberately escaped, which is a formatting bug on its way to becoming a worse one.

Where the security boundary actually is

A tag remover is a formatting tool. It is not a security boundary, and it cannot be made into one by adding patterns. The reason is structural and is already visible in the table above: the naive pattern and the browser's tokeniser disagree about where a tag ends as soon as an attribute value contains a greater-than sign, and the formatting-aware version disagrees in the same place. Any filter built out of patterns is a deny-list of the shapes its author thought of, and a deny-list is only as good as the author's imagination, while the parser is a fixed, published, adversary-independent specification. You cannot enumerate the ways two grammars differ.

The correct defences are two, and neither of them is a strip. The first and most important is contextual output escaping: escape the value at the point where you insert it, using the escaping rule for that context, because the rules differ for HTML text, attribute values, URLs, script and style. A templating engine that escapes by default is doing this for you, and the bugs happen where somebody opted out of it. The second, needed only when users are genuinely allowed to submit formatted markup that must render as markup, is a parser-based sanitiser that parses the input into a tree and rebuilds it from an allow-list of elements and attributes — the model of the HTML Sanitizer specification and of the established sanitiser libraries. Allow-list, parser, not regex.

There is one clean simplification worth stating. If the stripped text is plain text and stays plain text — displayed in a text node, written to a text column, counted, emailed as plain text — then the stripper's output has no security role at all, because the text is never parsed as markup again. The danger appears only when somebody takes the stripped text and puts it back into HTML. At that moment the relevant control is the escaping at that insertion point, not anything the stripper did earlier. Keeping those two moments separate in your head is most of what this section is trying to teach.

Eight HTML constructs through a naive tag regex and through a formatting-aware stripper. Every output column is the literal string the code returned in Node 22.
InputNaive tag regex returnsFormatting-aware stripper returns
<p>One</p><p>Two</p>OneTwoOne, blank line, Two
<p>One<br>Two</p>OneTwoOne, single newline, Two
<li>A</li><li>B</li>ABA and B on separate lines
<h2>T</h2><p>B</p>TBT, blank line, B
A style element containing .lead{font-weight:700}the CSS text .lead{font-weight:700} as visible outputnothing — the element and its contents are dropped together
A script element containing var ok = a < b && c > d;var ok = a d; — the middle eaten as if it were a tagnothing
<p>A<!-- keep if stock > 0 -->B</p>A 0 -->BAB
<a title="fast > cheap" href="/x">our guide</a>cheap" href="/x">our guidealso wrong — no regex fixes this; only a parser does
Strip HTML tagsRemove all HTML tags from a snippet and keep just the plain text.Try the tool

Frequently asked questions

Can I just use the browser and read textContent?
Parsing with the platform's own parser is the right instinct, and it solves the attribute and comment problems for free. But note what the two properties give you. textContent returns the concatenated text of every descendant with no formatting at all, including the contents of script and style, and with no line breaks for blocks — so it reproduces two of the defects in this article. innerText approximates rendered text, which does insert line breaks and does skip hidden elements, but it depends on layout and therefore on CSS. In a browser, parse the input into an inert document, remove script, style and template nodes explicitly, then walk the tree emitting your own breaks. On a server without a DOM, a real HTML parser library gives you the same tree.
Should I truncate before or after stripping the tags?
After, always. Truncating HTML cuts a tag in half or leaves an element unclosed, and the resulting fragment is neither valid markup nor sensible text — an excerpt that ends in the middle of an attribute value is a classic source of broken layouts when it later gets inserted somewhere. Strip first, then truncate the plain text at a word boundary, then add the ellipsis. The same argument applies to counting: a character or word count taken on the markup counts tag names and attribute values as content, so it is wrong by whatever proportion of the source was markup, which for a typical CMS export is a large fraction.
If the stripped text goes back into a page, do I decode the entities?
Decode it once for storage, then let the output layer escape it again when it inserts it. That sounds like extra work and is in fact the only arrangement that stays correct: your stored value is the actual text, and every consumer escapes it for its own context — HTML text, an attribute, a URL, a CSV cell, a plain-text email. If instead you store the entity-encoded form and skip escaping on output because it "is already escaped", the first consumer that is not HTML gets literal ampersand sequences in its output, and the first one that escapes anyway double-encodes it. One canonical decoded value in storage, escaping at every boundary.
Is there a way to make a regex stripper safe by adding more patterns?
No, and the reason is worth internalising because it generalises. A pattern list encodes the shapes its author anticipated; the parser accepts every shape the specification defines, including recovery behaviour for malformed input that no one writes down as a rule. Security only follows if the filter's model of the language is at least as complete as the consumer's, and a pattern list is by construction less complete. That is the whole argument for allow-lists over deny-lists and for parsers over patterns, and it is why this article gives you a stripper for formatting and points you at a sanitiser for safety rather than trying to sell one tool as both.
What should the stripper do with images, links and tables?
Decide deliberately and document the decision, because all three carry information that lives outside the text. An image contributes its alt text, which is the accessible description and usually the only sentence worth keeping; dropping the element silently loses it. A link contributes its anchor text by default, and whether the destination should be appended in brackets depends on whether your output will ever be read without a way to follow links. A table needs a cell separator and a row separator or every row becomes an unreadable run of concatenated values — a tab between cells and a newline between rows is the usual minimum. None of this is decided for you by the tag names; it is an editorial choice about what plain text is supposed to preserve.

Articles you may find interesting

All guides
GuideStripping Markdown: What Plain Text Loses, and What a Regex Gets WrongA link becomes text with its destination deleted, a nested list loses its hierarchy, a table becomes a row of words. Then the technical half: markdown has no single spec, and a regex stripper mangles a filename, a multiplication sign and the inside of a code block — all shown against a real parser.How-toCleaning Messy Text: The Order of Operations That Actually MattersStrip tags before decoding entities, trim before deduplicating, collapse whitespace last. Three orderings run in Node, a nine-step pipeline in the right sequence, and the invisible characters — U+00A0, U+200B, U+FEFF — that survive every naive cleanup.GuideBuilding a URL With Parameters That Survives a Copy-PasteThree encodings, one visible difference: %20 or +. The builder's form mode matches URLSearchParams byte for byte on seventeen values — but give it a base URL with a fragment and every parameter lands inside the hash, where no server sees it.ExplainerSentence Case and Title Case: The Rules Differ by LanguageEnglish title case has three different cutoffs depending on the style guide. French, Spanish, Portuguese and Italian have none at all. German capitalises every noun. The tool knows about none of this — here is exactly what it does.GuideCharacter Limits That Actually Bite: Code Units, Code Points and GraphemesA character is three different things at once. One emoji with a skin tone is 1 grapheme, 2 code points and 4 UTF-16 units. Every count in this guide was measured in Node, plus why an SMS drops from 160 to 70 and why VARCHAR(255) is not 255 of anything in particular.ExplainerCounting Words Is Ambiguous, and Every Tool Answers DifferentlyA word count is a definition, not a measurement. We counted the same paragraph four ways and got 25, 28, 33 and 38 — then counted 50,000 characters of ordinary prose and got agreement to within 4.5%. The gap is entirely driven by compounds, figures and URLs.

Related tools

Sources

Spotted a mistake in this article?