September 27, 2026
I Thought My Rails App Was Secure โ Until I Tested It for XSS
When building a Rails application, it is easy to assume that XSS is already taken care of.

By Saniya Chaudhari
6 min read
Rails automatically escapes HTML in views and provides helpers for handling potentially unsafe content. So when I first started investigating XSS in a Rails application, I had a simple question:
"If Rails escapes HTML automatically, how can XSS still happen?"
The answer is simple: Rails can protect you by default, but that protection depends on how the data is rendered.
A value can travel through several layers:
User Input
โ
API
โ
Database
โ
Frontend
โ
BrowserUser Input
โ
API
โ
Database
โ
Frontend
โ
BrowserStoring a potentially dangerous value in the database is not, by itself, an XSS vulnerability.
The real problem occurs when untrusted data reaches the browser and is interpreted as HTML instead of plain text.
In this post, I'll walk through how XSS can happen in a Rails application, what html_safe, raw, sanitize, and CGI.escapeHTML actually do, and how to trace an XSS issue from user input all the way to the browser.
What is XSS?
Cross-Site Scripting (XSS) is a vulnerability where attacker-controlled data is interpreted by the browser as executable content instead of normal text.
For example, imagine a project creation form:
Project Name:
My New ProjectProject Name:
My New ProjectEverything looks normal.
But someone enters:
<script>alert('XSS')</script><script>alert('XSS')</script>If the application later renders this value as HTML without appropriate escaping or sanitization, the browser may interpret the injected markup and execute the JavaScript.
The important part is not simply that the value contains <script>.
The real question is:
What happens when that value reaches the browser?
This is why XSS is best understood as a data-flow and rendering problem, not just a malicious-input problem.
But Isn't Rails Supposed to Prevent XSS?
Yes. Rails provides automatic HTML escaping when values are rendered using normal ERB output.
For example:
<%= project.name %><%= project.name %>If project.name contains:
<script>alert('XSS')</script><script>alert('XSS')</script>Rails escapes the HTML-sensitive characters so the value is displayed as text rather than being interpreted as HTML.
This is one of Rails' important security protections.
But Rails cannot protect you if you explicitly tell it:
"This content is safe HTML."
That's where methods such as html_safe and helpers such as raw become important.
Let's Test It
Suppose our application allows users to create projects.
A user enters:
<script>alert('XSS')</script><script>alert('XSS')</script>The value may be stored in the database.
At this point, simply storing the string does not mean XSS has occurred.
The vulnerability appears when the application later renders that value in a context where it is interpreted as HTML.
For example:
<%= project.name.html_safe %><%= project.name.html_safe %>Here, Rails is being told to treat the string as trusted HTML.
If the value is untrusted, the browser may interpret the injected markup as HTML and potentially execute the JavaScript.
This is why an XSS investigation should not stop at:
"The value is stored in the database."
Instead, trace where the value goes:
Input
โ
Controller
โ
Model / Database
โ
API Response
โ
Frontend
โ
DOM
โ
BrowserInput
โ
Controller
โ
Model / Database
โ
API Response
โ
Frontend
โ
DOM
โ
BrowserThe important point is to identify where untrusted data becomes HTML.
Stored XSS vs Reflected XSS
There are different types of XSS. Two common ones are stored XSS and reflected XSS.
Stored XSS
The malicious value is persisted somewhere, such as a database.
For example:
Project Name
<script>alert('XSS')</script>Project Name
<script>alert('XSS')</script>Later, another user opens the project page.
If the application renders the stored value as unsafe HTML, the payload can execute in that user's browser.
The typical flow is:
Attacker Input
โ
Database
โ
Application renders it
โ
BrowserAttacker Input
โ
Database
โ
Application renders it
โ
BrowserReflected XSS
The malicious value comes from the current request and is immediately reflected back into the response.
For example:
/search?query=<script>alert('XSS')</script>/search?query=<script>alert('XSS')</script>If the application takes the query parameter and renders it into HTML without appropriate escaping, the browser may interpret it as executable content.
The main difference is whether the malicious input is persisted before being rendered.
Rails Escaping: Your First Line of Defense
Rails automatically escapes values rendered using normal ERB output:
<%= project.name %><%= project.name %>If the value is:
<script>alert('XSS')</script><script>alert('XSS')</script>Rails treats it as text rather than executable HTML.
This is the behavior you normally want for values such as:
- Project names
- User names
- Titles
- Comments
- Other normal text fields
You generally don't need to manually escape every value in a Rails view.
For example:
<%= project.name %><%= project.name %>is the normal approach rather than unnecessarily doing:
<%= CGI.escapeHTML(project.name) %><%= CGI.escapeHTML(project.name) %>Rails already handles HTML escaping for normal ERB output.
html_safe โ What Could Go Wrong?
html_safe does not sanitize content or make unsafe content safe.
It tells Rails to treat the string as already-safe HTML.
For example:
"<strong>Hello</strong>".html_safe"<strong>Hello</strong>".html_safeallows the <strong> tag to be rendered as HTML.
The problem occurs when the content is not actually trusted:
project.name.html_safeproject.name.html_safeIf project.name comes from a user or another untrusted source, you have bypassed Rails' normal escaping.
The same concern applies to:
<%= raw project.name %><%= raw project.name %>These should not be used blindly with user-controlled content.
A simple rule is:
Do not mark untrusted content as safe HTML.
Before using html_safe, ask:
Where did this value come from?
If it came from a user, API request, imported file, external service, or another untrusted source, it should not automatically be treated as trusted HTML.
How to Handle XSS in Rails
The right solution depends on how the application is expected to use the data.
If the value should be plain text
Let Rails escape it automatically:
<%= project.name %><%= project.name %>Avoid doing this with untrusted content:
<%= project.name.html_safe %><%= project.name.html_safe %>or:
<%= raw project.name %><%= raw project.name %>If HTML is intentionally allowed
Some applications need to support HTML, such as rich-text content.
In that case, sanitize the content before rendering it:
<%= sanitize(user_content) %><%= sanitize(user_content) %>This allows permitted formatting while restricting unsafe HTML.
The distinction is simple:
Plain text
โ
Escape
HTML is intentionally allowed
โ
SanitizePlain text
โ
Escape
HTML is intentionally allowed
โ
SanitizeFor example, a project name containing:
<script>alert('XSS')</script><script>alert('XSS')</script>should normally be displayed as text.
A rich-text field containing:
<strong>Hello</strong>
<p>Welcome</p><strong>Hello</strong>
<p>Welcome</p>may need sanitization because HTML formatting is intentionally supported.
What About CGI.escapeHTML?
CGI.escapeHTML can be useful when explicit HTML escaping is needed in Ruby code:
CGI.escapeHTML(value)CGI.escapeHTML(value)But in a normal Rails view, you generally don't need to manually call it because:
<%= value %><%= value %>already performs HTML escaping.
The goal is not to escape or sanitize everything blindly.
The goal is to make sure untrusted data is never treated as trusted HTML.
What About APIs?
This is where XSS investigations can become confusing.
Imagine the backend returns:
{
"name": "<script>alert('XSS')</script>"
}{
"name": "<script>alert('XSS')</script>"
}The API has returned data. That does not automatically mean the API itself has an XSS vulnerability.
What matters is how the frontend uses that data.
For example, React normally treats this as text:
<div>{project.name}</div><div>{project.name}</div>But this tells React to interpret the value as HTML:
<div dangerouslySetInnerHTML={{ __html: project.name }} /><div dangerouslySetInnerHTML={{ __html: project.name }} />If project.name is untrusted, this can create an XSS vulnerability.
So when an API returns potentially dangerous content, don't stop the investigation at the API response.
Ask:
How is this value eventually rendered?
The security issue may exist at the point where data is converted into HTML.
The XSS Investigation Flow
When investigating an XSS issue, trace the value from its source to the browser.
1. Find the Input
Identify where the value comes from.
For example:
Project Name
Description
Comment
Lead NameProject Name
Description
Comment
Lead NameUse a harmless test value first, followed by a controlled XSS payload in a development or test environment.
2. Check the Backend
Find where the value is received and stored:
Controller
โ
Model
โ
DatabaseController
โ
Model
โ
DatabaseDon't assume that storing < or > in the database is itself a vulnerability.
The database can safely contain such characters. The important question is what happens when the value is rendered.
3. Check the API
If the value is returned through an API, inspect the response:
{
"name": "<script>alert('XSS')</script>"
}{
"name": "<script>alert('XSS')</script>"
}At this stage, determine whether the API is simply returning data or transforming it into HTML.
4. Check the Frontend
Find where the value is rendered.
In React, for example:
{project.name}{project.name}and:
dangerouslySetInnerHTMLdangerouslySetInnerHTMLhave very different security implications.
Also check other frontend code that may insert strings into the DOM as HTML.
5. Check the Browser
Finally, inspect what the browser actually receives and interprets.
The important transition is:
Untrusted Data
โ
HTML
โ
Executable ContentUntrusted Data
โ
HTML
โ
Executable ContentThat's where an XSS vulnerability becomes visible.
Testing the Fix
After applying the fix, test the complete flow again with a controlled payload:
<script>alert('XSS')</script><script>alert('XSS')</script>Verify that:
- The value can still be submitted and stored if required.
- The API returns the expected data.
- The frontend displays the value as text when HTML is not intended.
- No JavaScript is executed.
- Existing legitimate data continues to work.
Also test every place where the same value is rendered.
For example:
Project List
Lead Details
Activity
Reports
Edit Project
TooltipProject List
Lead Details
Activity
Reports
Edit Project
TooltipFixing one rendering location does not necessarily fix the same issue elsewhere.
Conclusion
XSS is not simply about finding <script> in a database.
The important question is what happens to untrusted data when it reaches the browser.
Rails provides strong protection through automatic HTML escaping, but that protection can be bypassed when developers explicitly treat untrusted content as safe HTML.
The key things to remember are:
- Let Rails automatically escape normal text.
- Be careful with
html_safeandraw. - Use sanitization when HTML is intentionally allowed.
CGI.escapeHTMLis useful for explicit escaping, but it is not a replacement for Rails' normal view escaping.- Don't assume the database or API is the security boundary.
- Trace the value from input to the browser.
- Always consider the final rendering context.
Once you start looking at XSS as a data-flow problem rather than just a payload problem, investigating and fixing these issues becomes much easier.