HTML sanitization: the double-encoding trap

I went through a security audit on a project that wasn’t old, but had passed through several teams. After the first pentest round, the auditors found missing backend sanitization.

With proof. Quite disappointing.

data processing pipe

Warning: This article is NOT a recommendation to abandon standard HTML entity encoding. This method should only be considered by those who fully understand the security implications and are facing the exact double-encoding problem described here.

# Constraint: sanitize on ingest and output

The security audit gave us two demands:

  1. Sanitize all data coming from client to server on server side;

    • Sanitize on Ingest: A “defense in depth” strategy. Never trust user input and clean it before it touches the database. Classic security requirement.
  2. Sanitize all data coming from server to client on client side;

    • Sanitize on Output: The modern web development approach. Sanitize data for the specific context it’s being used in (e.g., HTML, CSS, a URL) at the last possible moment. This is considered the most effective way to prevent XSS.

Our stack was standard: a few NestJS microservices and a Next.js frontend. Sanitize on Output is standard React practice. React already protects against HTML injection by default by escaping rendered output. Disabling that is a major anti-pattern.

# The double-encoding issue

Server-side sanitization was easy to add. I started storing strings with & instead of the literal character & in the database. That created a classic double-escaping problem. Because React also escapes output, the & I was carefully storing in the database was rendered literally on the screen as &. The browser never turned it back into an &.

Every online guide and AI assistant suggested the same thing: “Only sanitize on output”! But I couldn’t do that; the security team’s requirement to sanitize on ingest was non-negotiable.

Then the useful question landed: Why, in 2025, am I still using character entities instead of raw Unicode characters?

Comparison list. Not all of these have to be used:

Character entityUnicode symbolSecurity critical
…
—
–
««
“
‘
»»
”
’
&
<
>

The goal was reached. The database contained only safe symbols, and the UI rendered them nicely. But at what price?

Fun fact: one UTF symbol can take less space than a 3+ character ASCII entity.

# Trade-offs of Unicode substitution

  • Legacy systems: If a system still uses KOI8-R and similar, this isn’t for it (legacy email clients, for example). Legacy fate.
  • This approach has to work consistently across the entire backend.
    • Any tech decision has to work consistently.
  • Database content isn’t pure user input, but is it a real problem?
    • You’ll need to migrate old data, ✅
    • I have to write SELECT * FROM somewhere WHERE data = "some data&another data", which could be an issue only for analytical purposes 🤔
      • Anyway, I’ll set up a custom tokenizer in Typesense/Elasticsearch.
    • But it’s already prepared to be exported into CSV without additional escaping (as a joke).
  • I’m not a mobile dev, and I don’t have mobile apps yet. I’ll see (use proper encoding and modern fonts, not from the dinosaur era 🤷).
  • The underrated point: this process is closer to data transformation than sanitization.

# Verdict: elegant under real constraints

I count it as an elegant trade-off under a real constraint. Perfect-world advice says sanitize on output only. My world ships with audit findings, old data, and production deadlines.