Server-side rendering feels safer than shipping a giant client-side app. A lot of HTML is generated on the server, templates usually escape output by default, and there is less obvious DOM manipulation in the browser.

That safety is real, but people overestimate it.

I’ve seen teams say “we use SSR, so XSS isn’t really a concern.” Then you look at the code and find raw HTML helpers, unquoted attributes, JSON blobs jammed into <script> tags, and a CSP that exists only in a slide deck. SSR reduces some attack surface. It does not remove the core problem: if untrusted data lands in the wrong output context without the right encoding, you still have XSS.

Why SSR apps still get XSS

The browser does not care whether HTML came from React hydration, Rails ERB, Django templates, Go html/template, or a hand-built string concatenation disaster. It only cares about the final bytes it parses.

That means SSR apps are vulnerable anywhere user-controlled input reaches:

  • HTML element content
  • HTML attributes
  • JavaScript inside <script>
  • Inline event handlers like onclick
  • CSS contexts
  • URL contexts like href and src

The biggest mistake I see is assuming “escaping” is one universal thing. It isn’t. Escaping for HTML text is not the same as escaping for JavaScript strings or URLs.

The safe baseline: context-aware output encoding

If you remember one rule, make it this:

Escape for the context you are rendering into, and avoid dangerous contexts entirely when possible.

For example, rendering a username into normal HTML text is straightforward.

Safe HTML text output

<h1>Welcome, <%= username %></h1>

In EJS, <%= %> escapes HTML by default. So if username is:

<script>alert(1)</script>

it becomes harmless text in the page.

Equivalent safe patterns exist in many frameworks:

<h1>Welcome, {{ username }}</h1>
<h1>Welcome, <%= @username %></h1>
<h1>Welcome, {{.Username}}</h1>

Modern SSR template engines usually do this part well.

Where people break it: raw HTML rendering

The moment someone wants rich text, marketing content, or “just a little formatting,” they reach for the bypass.

Dangerous raw output

<div class="bio"><%- user.bio %></div>

In EJS, <%- %> outputs unescaped content. If user.bio contains attacker-controlled HTML, you’ve handed them script execution.

Same story elsewhere:

{{ bio|safe }}
<%= raw @bio %>
{{{bio}}}
```text

These are not “render nicely” helpers. They are “I trust this content completely” helpers.

If you truly need rich HTML, sanitize it on the server with a well-maintained HTML sanitizer and a tight allowlist. Don’t invent your own regex sanitizer. That path ends in pain.

## Attribute context is different

HTML-escaping text does not magically make every attribute safe.

### Usually safe when quoted and escaped
<%= profile.name %> ```text

Quoting attributes matters. A lot.

Bad pattern: unquoted attributes

< ` I d ` f i ` v t ` e u d x s a t e t r a n - a u m s e e ` r = c < o % n = t a u i s n e s r n s a p m a e c e % s > > o < r / d q i u v o > t e - l i k e p a y l o a d s , p a r s i n g g e t s w e i r d f a s t . A l w a y s q u o t e a t t r i b u t e s :
```text

Even then, some attributes are inherently dangerous.

Dangerous attributes

< ` I F a ` f o ` r h t ` r e u U e x s R f t e L = r - " . b < w e % e a = b r s i u i n s t g e e r ` a . t w i t e s r b i s ` b i j u t a t e v e a s % s , > c " r v > i a W p l e t i b : d s a a i l t t e e e r < t a / ( l a 1 l > ) o ` w , e d t h s e c h b e r m o e w s s e s r e r m v a e y r - e s x i e d c e u : t e i t w h e n c l i c k e d .

function safeProfileUrl(input) { try { const url = new URL(input, ‘https://example.com’); if (url.protocol === ‘http:’ || url.protocol === ‘https:’) { return url.href; } } catch {} return ‘/’; }


Then render the validated value:

Website


## The classic SSR footgun: injecting data into `<script>`

This one gets teams constantly. They render initial page state into a script block so client-side code can hydrate.

### Vulnerable example

Looks fine. It isn’t.

If `username` is:

“; alert(1); //


your script becomes executable attacker code.

And even if you JSON stringify, you can still get burned by `</script>` ending the tag.

### Better pattern: serialize safely

Escaping `<` prevents `</script>` from breaking out. Some teams also escape `>`, `&`, and Unicode line separators for extra safety.

An even cleaner pattern is to avoid executable script context for data entirely.

### Safer pattern: JSON script tag

Then on the client:

const raw = document.getElementById(‘initial-state’).textContent; const state = JSON.parse(raw);


That reduces risk because the browser does not execute `application/json`.

## Inline JavaScript is a bad deal

If your SSR templates contain event handlers, you are making XSS easier and CSP harder.

### Don’t do this


Now you need JavaScript-string escaping inside an HTML attribute inside a template. That is exactly the kind of nested context that causes bugs.

Use data attributes plus external JS:

document.addEventListener(‘click’, (e) => { const btn = e.target.closest(’.buy-btn’); if (!btn) return; trackClick(btn.dataset.productId); });


Cleaner code, safer output, and much easier to secure with CSP.

## A practical Node/Express SSR example

Here’s a tiny Express app using EJS. First, the wrong version.

### Vulnerable server

const express = require(’express’); const app = express();

app.set(‘view engine’, ’ejs’);

app.get(’/profile’, (req, res) => { const user = { name: req.query.name || ‘Guest’, bio: req.query.bio || ‘Hello there’, website: req.query.website || ‘/’ };

res.render(‘profile’, { user }); });

app.listen(3000);


### Vulnerable template

<%= user.name %>

<%- user.bio %>

Website


Problems:

- `bio` is rendered raw
- `website` is not scheme-validated
- `name` is injected into JavaScript unsafely

Now the fixed version.

### Safer server

const express = require(’express’); const app = express();

app.set(‘view engine’, ’ejs’);

function safeUrl(input) { try { const url = new URL(input, ‘https://example.com’); if ([‘http:’, ‘https:’].includes(url.protocol)) return url.href; } catch {} return ‘/’; }

app.get(’/profile’, (req, res) => { const user = { name: req.query.name || ‘Guest’, bio: req.query.bio || ‘Hello there’, website: safeUrl(req.query.website || ‘/’) };

const state = { name: user.name };

res.render(‘profile-safe’, { user, state }); });

app.listen(3000);


### Safer template

<%= user.name %>

<%= user.bio %>

Website


If you genuinely need HTML in `bio`, sanitize it before rendering. Don’t just switch back to raw output because the product manager wants bold text.

## CSP helps, but it won’t fix sloppy templates

A good Content Security Policy can block a lot of script execution paths and make XSS less catastrophic. For SSR apps, CSP is especially effective when you remove inline scripts and event handlers.

A baseline policy might look like:

Content-Security-Policy: default-src ‘self’; script-src ‘self’ ’nonce-r4nd0m’; object-src ’none’; base-uri ‘self’; frame-ancestors ’none’;


If you’re building this out, [csp-guide.com](https://csp-guide.com) has solid implementation details.

A few blunt opinions from experience:

- If your app depends on `unsafe-inline`, your CSP is probably doing less than you think.
- If templates are full of inline handlers, fixing CSP later becomes expensive.
- CSP is defense in depth, not an excuse to skip output encoding.

You should also verify your response headers regularly. Tools like [HeaderTest](https://headertest.com?utm_source=xss-prevention&utm_medium=blog&utm_campaign=article-link) make it easy to spot missing or weak security headers in an SSR deployment.

## Framework defaults are helpful, not magical

Some server-side frameworks are better than others here.

- Go’s `html/template` is strong because it does context-aware escaping.
- React SSR escapes text content by default, but `dangerouslySetInnerHTML` is exactly as dangerous as the name suggests.
- Django and Rails are pretty safe until developers start marking strings as trusted.

The common failure mode is not the framework. It’s the “one exception” added during a deadline crunch.

## What I would enforce on every SSR project

If I’m reviewing an SSR codebase, I want these rules:

1. **No raw HTML output unless sanitized first**
2. **No inline event handlers**
3. **No unquoted attributes**
4. **No user input directly inside `<script>`**
5. **Validate URL schemes for `href` and `src`**
6. **Use CSP with nonces or hashes**
7. **Review every template escape bypass in code review**

That last one matters. Every `raw`, `safe`, triple-stash, or unescaped output helper should feel like opening a production firewall rule. Maybe necessary. Never casual.

SSR absolutely can reduce XSS risk compared to messy client-side rendering. But only if you stay disciplined about contexts. The browser parses HTML the same way regardless of how it was produced. Your job is to make sure untrusted input arrives encoded, validated, or sanitized for that exact spot in the document. If you get lazy there, SSR won’t save you.