,

I Broke My Own App to Finally Understand How Input Sanitization Works

Posted by

Sometimes the best way to learn security isn’t by reading about it, it’s by watching your own code break.

Sometimes the best way to learn security isn’t by reading about it, it’s by watching your own code break.

Introduction: The Day I “Hacked” Myself

I’ve been building web apps for years clean UI, structured APIs, and modern frameworks.
I thought I understood security pretty well.

Then one day, a QA tester sent me a message:

“Hey, why does your comments page pop up an alert box when I type <script>alert('test')</script>?”

I laughed it off at first. Then I opened the page and saw it myself.
Every user who visited that page saw a random JavaScript alert running directly in their browser.

That’s when I realized: I didn’t really understand input sanitization.

So I decided to do something uncomfortable. I intentionally broke my own app to learn exactly why sanitization matters, how it fails, and how to fix it properly.


1. The “Innocent” Code That Caused It All

I had a simple Node.js app with a comments feature:

// POST /comments
app.post('/comments', (req, res) => {
const { name, message } = req.body;
comments.push({ name, message });
res.redirect('/');
});

And the frontend looked like this:

<div id="comments">
<% comments.forEach(c => { %>
<p><strong><%= c.name %>:</strong> <%= c.message %></p>
<% }) %>
</div>

Looks harmless, right?

But when I tried posting this as a “message”:

<script>alert('You got hacked!')</script>

Every visitor who loaded that page saw an alert box.
The script executed immediately inside the browser, not as text, but as code.

I hadn’t been hacked by anyone else.
I’d hacked myself.


2. What Actually Happened and Why

What I had done, unknowingly, was a textbook stored XSS (Cross-Site Scripting) vulnerability.

Here’s the process:

  1. My form took input from users (message).
  2. It stored that data directly.
  3. When rendering, the data was inserted into HTML without escaping or sanitizing.

The browser can’t tell if that data came from a user or a developer; it just executes it.

So my “harmless” template line:

<%= c.message %>

Was actually telling the browser,

“Here’s some HTML, please interpret it.”

That’s all an attacker needs.


3. Why It’s Not Just About Script Tags

I thought, “Okay, I’ll just block <script> tags.”
That’s the beginner’s mistake.

Attackers can bypass that easily:

<img src="x" onerror="alert('XSS!')" />

Or encoded versions:

&lt;script&gt;alert('encoded')&lt;/script&gt;

Or nested events, inline styles, malformed tags, the list goes on.

The point is: you’ll never be able to blacklist every malicious pattern.
That’s why you sanitize and escape instead of filtering manually.


4. The Right Way: Escape Output

The key insight I learned was this:

Sanitization isn’t about cleaning input it’s about controlling how it’s used.

When you display user content in a web page, you must escape special characters so they’re shown as text, not executed as code.

Example:

function escapeHtml(str) {
return str
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}

Then update the rendering logic:

<p><strong><%= escapeHtml(c.name) %>:</strong> <%= escapeHtml(c.message) %></p>

Now, when a user enters <script>, it shows up literally as text, not as a running script.


5. Using a Proven Sanitization Library

Of course, manually escaping every field is easy to forget.
That’s where libraries like DOMPurify (for client-side sanitization) or sanitize-html (for Node) come in.

Example using sanitize-html:

import sanitizeHtml from "sanitize-html";

app.post('/comments', (req, res) => {
const cleanName = sanitizeHtml(req.body.name);
const cleanMessage = sanitizeHtml(req.body.message);
comments.push({ name: cleanName, message: cleanMessage });
res.redirect('/');
});

By default, it removes unsafe tags (script, iframe, onerror, style, etc.)
You can configure what’s allowed, for instance, if you want bold or links but nothing else:

sanitizeHtml(req.body.message, {
allowedTags: ['b', 'i', 'a'],
allowedAttributes: { a: ['href'] }
});

Now users can safely format text without introducing danger.


6. Realizing It’s Not Just XSS, It’s Everywhere

Once I saw how easily user input could turn into code, I started looking elsewhere.

  • SQL queries: SELECT * FROM users WHERE email = '${input}'
  • Command-line tools: exec("rm -rf " + userInput)
  • File uploads: trusting file names and extensions blindly.

Every one of those follows the same pattern:
Untrusted input, mixed with trusted commands.

The fix is always the same principle:

  • Validate the shape and type of input.
  • Sanitize or escape before using it in a different context.
  • Use safe APIs (parameterized queries, libraries, and framework helpers).

7. Breaking It on Purpose: The Experiment That Taught Me

To really understand, I started adding controlled “vulnerabilities” into test routes of my app:

Example 1: Dangerous innerHTML

div.innerHTML = req.body.comment;

Then tried different payloads to see which executes.

Example 2: Dangerous SQL Concatenation

await db.query(`SELECT * FROM users WHERE name = '${req.query.name}'`);

Then tested ' OR '1'='1 and watched how it returned to every user.

Each time I fixed one, I learned something new.
Seeing it break in real time burned the lessons into my brain far better than any tutorial could.


8. The Real Takeaway: Sanitization Is Contextual

What finally clicked for me is that sanitization depends on where the data is going.

  • To HTML? Escape <, >, quotes, and ampersands.
  • To SQL? Use parameterized queries.
  • To JSON or APIs? Encode properly and limit structure.
  • To file system or shell? Validate and restrict paths and characters.

There’s no universal “sanitize()” that works everywhere.
You sanitize based on the destination, not just the data.


9. How I Changed My Workflow

After that experience, I made a few permanent changes:

  1. Validation first: Every API endpoint uses a schema validator like Zod or Joi.
  2. Sanitization second: Escape or clean data before storing or rendering.
  3. Safe defaults: I banned innerHTML, raw SQL strings and direct file execution from our codebase.
  4. Automation: Added ESLint and Semgrep rules to flag unsafe patterns automatically.
  5. Testing: I now include negative tests, trying to break my own forms with <script> or ' OR '1'='1 before shipping anything.

This isn’t paranoia, it’s muscle memory.


10. The Moment It Finally Clicked

When I refreshed my “fixed” version of the app, typed <script>alert('hello')</script> again, and saw it rendered as plain text instead of executing, it was weirdly satisfying.

That tiny piece of text had no power anymore.
It couldn’t trick the browser, couldn’t hijack the page, couldn’t break anything.

That’s when I truly understood input sanitization.
It wasn’t about blocking hackers. It was about controlling boundaries, deciding what data is, and what it’s allowed to become.


Conclusion: Break Your App Before Someone Else Does

Breaking my own app wasn’t fun at first, but it was the most effective security lesson I’ve ever learned.

I stopped thinking of “input sanitization” as a checklist item.
Now I see it as a philosophy: never let untrusted data cross a boundary unchecked.

If you want to really understand it too, build a small demo, break it intentionally, and then fix it.
Once you see how easily unsafe input turns into code, you’ll never skip sanitization again.

You don’t need to fear hackers. You just need to stop writing code that trusts them.


Call to Action

Try this experiment yourself.
Take a copy of one of your forms or routes, remove all sanitization, and watch what happens when you input HTML or SQL fragments.

Then fix it, test it again, and feel how much safer the code becomes.
That experience will teach you more about security than any blog post ever could.

Leave a Reply

Your email address will not be published. Required fields are marked *