XSS Prevention for Backend Engineers: Output Encoding, CSP, and Trust Boundaries
XSS Prevention for Backend Engineers covers encoding, CSP, and trust lines. Learn where output must be escaped and which headers close the remaining gaps.
XSS Prevention for Backend Engineers is the work of keeping untrusted text from running as script in a browser. It matters because a stored name or comment can become code for every user who views the page. The backend often writes that HTML or that JSON. You decide whether the text stays data.
If you filter on the way in and skip the way out, a new render path will forget the filter. Then old rows become dangerous again. Also, a policy header will not save a page that uses unsafe inline script on purpose.
What the bug looks like in a service
The browser treats some characters as markup. If you place user text into HTML without encoding, the browser may treat it as a tag. The same text is safe inside a JSON string that a careful client inserts as text. Because context decides the fix, a single global replace is not a design.
In my experience, stored cases hurt more than reflected ones. However, both come from the same miss. As a result, one comment can run for every later viewer. You should assume every field you did not type yourself is untrusted, including fields from partner APIs.
Backend engineers own this even when a front end team renders the page. You pick the template engine, the response content type, and the headers. If you return HTML, you own encoding. If you return JSON, you own the content type and you must not reflect untrusted text into an HTML error page.
Trust boundaries to draw
Draw a line around data you will place into a page. First, user profiles, tickets, and search queries sit outside the line. Next, your own templates and static files sit inside it.
When data crosses the line, encode for the spot where it lands. If the spot is HTML body text, encode markup characters. If the spot is a URL, encode for a URL and check the scheme.
Still, rich text is a special case. A product that must show bold or links needs a sanitizer with a small tag list, not a hope that encoding will be skipped safely. Therefore prefer plain text unless product has a real need for markup.
Architecture that keeps output safe
Use a template engine that escapes by default. Turn the unsafe raw output into a loud method that review can search for. Set the response content type so the browser does not guess. For JSON APIs, send the JSON content type and a header that stops the browser from sniffing the body as HTML.
A common mistake I have seen is to build HTML with string format in a handler. Then each field needs a manual encode, and one field will skip it. Specifically, return data and let one template render it. For example, an error page should use the same escaped template as the rest of the site.
Add a content security policy that limits where script can load from. Start by reporting violations, then enforce. A policy is a backstop for a missed encode.
It is not a reason to skip encoding. You should still remove inline event handlers from pages you control.
Roll encoding and policy together
- Turn on auto escape in every HTML template.
- Search for raw HTML inserts and rewrite them.
- Set content type and nosniff on every response.
- Ship a report only content policy and read the violations.
- Enforce the policy after false reports are fixed.
When a page must include a third party script, name that host in the policy. After the report week, blocking will be predictable. Although a strict policy takes tuning, a missing policy leaves every missed encode as full script access.
Trade-offs by output context
HTML body text, HTML attributes, URLs, and JavaScript strings need different encoding. One helper used in the wrong spot can still break. A strict policy is safer and will break old pages that rely on inline script. You should encode for context first and use the policy as the second line.
| Context. | Use it when. | Main risk. | What you configure. |
|---|---|---|---|
| HTML text. | You show a name or comment. | Raw insert skips the encoder. | Auto escape in the template. |
| Attribute value. | You fill a label or value. | Quotes break out of the attribute. | Encode for attributes. |
| URL. | You build a link or redirect. | A script scheme can run. | Allow only http and https. |
| JSON API. | A client renders the text. | An HTML error page reflects input. | Set JSON type and nosniff. |
| CSP enforce. | You want a backstop. | Inline script and old widgets break. | Allow list script hosts. |
If you control the client, return JSON and insert text with safe DOM methods. If you must return HTML, escape in the template and avoid raw sinks. Instead of stripping characters on input, store the original text and encode on output. Users may need to see the characters they typed.
A sanitizer for rich text is harder to tune than auto escape. It can drop real content or keep a bad attribute. Therefore use it only on the few fields that need markup.
Keep a versioned allow list of tags and attributes. Review it when you add a feature.
Pitfalls and failure modes
Double encoding shows up when you escape on write and again on read. The user sees odd text, and someone turns escaping off to fix the display. Then the original bug returns.
Escape once, on the way out, in the layer that knows the context. Also, do not store pre escaped HTML unless that field is defined as HTML and sanitized.
Redirects and links are a quiet sink. A URL parameter that becomes a redirect can send a user to a hostile site, and a bad scheme can run script in old clients. Allow only relative paths or hosts you own.
Reject other schemes. Also, encode the URL when you place it into HTML.
File downloads and content type sniffing can turn a text upload into HTML. Set nosniff. Serve user uploads from a separate host that does not share your session cookie. Mark download responses with a content disposition that forces a download when you do not intend to render them.
Cookies and script
Script that runs on your origin can read the page and call your API. It can also read tokens that are not in an HttpOnly cookie. Set HttpOnly on the session cookie so script cannot copy it.
That does not stop the script from using the browser session. You still need encoding and CSP. Pair this with CSRF protection so a foreign site cannot ride the cookie either.
- Template raw HTML helper used for user fields.
- Error page that echoes the request path into HTML.
- Redirect target taken from a query parameter.
- User uploads served from the app host.
- Content policy left in report mode forever.
A web application firewall may catch a few public patterns and will not fix a stored row you render later. Do not depend on it for this bug. We once hit a bottleneck when a policy blocked a legitimate admin widget and the team disabled the whole header.
The right move was to allow that one script host and keep enforce on. A week of report logs would have shown the host before we enforced.
JSON inside a script tag is a classic foot gun. If you embed data in HTML, encode it for that context. Better, load JSON from a URL with a JSON content type. Do not drop raw JSON into a script block.
Headers and a template habit
The snippet sets a strict content policy and nosniff. It assumes your scripts come from your own host. Change the script source before you enforce. Also, keep a report URI or report endpoint while you tune, then remove hosts you do not want.
# Illustrative response headers for an HTML app.
# Encoding still happens in the template. This is the backstop.
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Content-Type "text/html; charset=utf-8" always;
# In templates, auto escape stays on.
# Do not mark user fields as raw HTML.
# Redirect only to relative paths you generate.
Test an HTML page with a name that includes markup characters. The page should show those characters as text, not as a new element. Test a JSON route and confirm the content type is JSON even on errors. Then watch policy violation reports for a week before you add more script hosts.
The MDN CSP guide explains directives in plain language. The CSP Level 3 spec is the detailed reference when a directive does not behave as you guessed. Read the guide first, then the spec for the one directive you are changing.
Performance, scale, and cost
Auto escape is cheap compared with a database call. Do not cache a rendered page that includes one user private fields and then serve it to someone else. Vary the cache on the right key, or render private fragments per user. Therefore public caches should hold only pages with no personal data.
A sanitizer for rich text costs CPU. Run it once on write if the stored form is already safe HTML, and still treat that field as HTML only. Do not run three different sanitizers in a row.
Pin the library version. A silent upgrade can change what you keep.
At scale, policy reports can flood logs. Sample them, group by directive and blocked host, and alert on a new host. A huge report stream after a release usually means a missed asset domain. Fix the asset, do not turn the policy off.
Related controls
Output encoding does not fix SQL injection prevention. Bind queries even when you also escape HTML. They are different layers.
CORS rules decide which sites may read an API response. They do not encode HTML.
The OWASP XSS Prevention Cheat Sheet lists context encoding rules you can adopt as a team standard. Use it when someone asks for a one line filter. There is no one line filter that fits every context.
Cost is mostly discipline in review and a policy you maintain. Budget time each quarter to prune script hosts. A long allow list is a slow return to open script.
Key Takeaways
- Encode on output for the context you are writing into.
- Use templates that escape by default and search for raw inserts.
- Set a content type and nosniff so browsers do not guess.
- Add a content security policy as a backstop, then enforce it.
- Allow list redirect targets and URL schemes.
- Serve user uploads on a separate host from the session cookie.
- Do not rely on an edge filter or on input stripping alone.
FAQ
Should you strip dangerous characters on input?
Prefer to store the text and encode when you render. Stripping loses user data and still misses a new output path. If a field is rich text, sanitize with a small allow list and store that result as HTML on purpose.
Is a content security policy enough?
No. It reduces harm when a bug slips through. It does not fix a raw template.
Encode first. Use the policy so a missed encode has less room to run.
Do JSON APIs need this work?
Yes, in a smaller way. Set the JSON content type and nosniff. Make sure error pages do not reflect input as HTML. Tell client owners to insert untrusted strings as text, not as HTML.
What is a good first test?
Save a display name that includes markup characters. Load the profile page and the admin page. Both should show the characters as text. If either page creates an element, that path skipped the encoder.
XSS Prevention for Backend Engineers is auto escape, strict content types, and a policy you are willing to enforce. Find every raw HTML insert in your service this week. Move user fields back to the escaped path.
Next, set nosniff and a report only content policy. Then fix the violations and switch to enforce. After that, move user uploads off the app host so a sniffed file cannot share your cookie.
Last updated on 17 September 2026.