Introduction

Every web application faces two enemies that have been around since the earliest days of the modern web.

Cross-Site Request Forgery (CSRF) exploits the fact that browsers automatically attach cookies — including session cookies — to every request they send to a site. If you are logged in to your bank and you visit a malicious page, that page can silently make your browser fire a transfer request. The bank sees a request signed with your real session cookie and obeys it.

Cross-Site Scripting (XSS) exploits the fact that browsers execute whatever JavaScript a page contains. If an attacker can inject a <script> tag into a page — through a comment field, a URL parameter, or any other unsanitised input — that script runs in the victim's browser with full access to the page's cookies and DOM.

Both attacks share a root cause: unverified trust. CSRF trusts that any request carrying a valid cookie must have been intentional. XSS trusts that any content in the page is safe to render. Two defences match those two failures: a CSRF token (a secret the forged request cannot know) and output escaping (treating every user-supplied string as text, never as markup).

These are not exotic vulnerabilities. CSRF and XSS consistently appear in the OWASP Top 10 list of the most critical web application security risks.

Try It

The demo below simulates two scenarios side by side. On the left, a legitimate user submits a form that carries the secret CSRF token the server issued when the page loaded. On the right, an attacker's forged request arrives without that token — or with a wrong one.

<!-- {{c_layout_comment}} -->
<div class="panel-row">
  <!-- {{c_left_panel_comment}} -->
  <div class="panel" id="legit-panel">
    <h3>{{title_legit}}</h3>
    <p class="desc">{{desc_legit}}</p>
    <div class="token-display">
      <span class="label">{{label_token}}</span>
      <code id="legit-token" class="token-val"></code>
    </div>
    <form id="legit-form" class="form">
      <input type="hidden" id="legit-csrf" />
      <label>{{label_to}} <input id="legit-to" type="text" value="Alice" /></label>
      <label>{{label_amount}} <input id="legit-amount" type="number" value="50" min="1" max="500" /></label>
      <button type="submit">{{btn_submit_legit}}</button>
    </form>
    <div id="legit-result" class="result"></div>
  </div>

  <!-- {{c_right_panel_comment}} -->
  <div class="panel" id="attacker-panel">
    <h3>{{title_attacker}}</h3>
    <p class="desc">{{desc_attacker}}</p>
    <div class="token-display">
      <span class="label">{{label_token_attacker}}</span>
      <code id="attacker-token-shown" class="token-val danger"></code>
    </div>
    <form id="attacker-form" class="form">
      <label>{{label_to}} <input id="attacker-to" type="text" value="Eve" /></label>
      <label>{{label_amount}} <input id="attacker-amount" type="number" value="500" min="1" max="500" /></label>
      <button type="submit">{{btn_submit_attacker}}</button>
    </form>
    <div id="attacker-result" class="result"></div>
  </div>
</div>

<div class="log-area">
  <div class="log-header">
    <span>{{label_server_log}}</span>
    <button id="reset-btn" type="button" class="ghost">{{btn_reset}}</button>
  </div>
  <ul id="log"></ul>
</div>
/* {{c_base_styles}} */
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #222; margin: 0; font-size: 14px; }
h3 { margin: 0 0 .4rem; font-size: 1rem; }
.panel-row { display: flex; gap: .8rem; flex-wrap: wrap; }
.panel { flex: 1 1 220px; border: 1px solid #cdd9e3; border-radius: 10px; padding: .8rem; background: #f4f8fb; }
#attacker-panel { border-color: #e63946; background: #fff5f5; }

/* {{c_token_display_styles}} */
.token-display { margin: .4rem 0 .6rem; display: flex; align-items: center; gap: .4rem; flex-wrap: wrap; }
.label { font-size: .78rem; color: #555; font-weight: 600; text-transform: uppercase; letter-spacing: .03em; }
.token-val { font-size: .72rem; background: #e3edf5; border-radius: 4px; padding: 2px 6px; color: #1d3557; word-break: break-all; }
.token-val.danger { background: #fde8ea; color: #c92f3c; }

/* {{c_form_styles}} */
.form { display: flex; flex-direction: column; gap: .45rem; }
.form label { display: flex; flex-direction: column; font-size: .82rem; color: #333; gap: .15rem; }
.form input[type="text"], .form input[type="number"] {
  padding: .3rem .5rem; border: 1px solid #b0bec5; border-radius: 6px; font-size: .9rem;
}
.desc { font-size: .82rem; color: #555; margin: 0 0 .5rem; line-height: 1.4; }
button { margin-top: .4rem; font: 600 13px system-ui; padding: .4rem .8rem;
         border: 1px solid #1d3557; background: #1d3557; color: #fff; border-radius: 7px; cursor: pointer; }
#attacker-panel button[type="submit"] { border-color: #c92f3c; background: #c92f3c; }
button.ghost { background: #fff; color: #1d3557; font-size: .8rem; padding: .25rem .6rem; }

/* {{c_result_styles}} */
.result { margin-top: .5rem; min-height: 1.3em; font-weight: 600; font-size: .85rem; }
.result.ok { color: #0a7d33; }
.result.bad { color: #c92f3c; }

/* {{c_log_styles}} */
.log-area { margin-top: .8rem; border: 1px solid #cdd9e3; border-radius: 8px; background: #fff; }
.log-header { display: flex; align-items: center; justify-content: space-between; padding: .4rem .7rem; border-bottom: 1px solid #e3edf5; font-size: .8rem; font-weight: 700; color: #1d3557; text-transform: uppercase; letter-spacing: .04em; }
#log { list-style: none; margin: 0; padding: .3rem .7rem .5rem; max-height: 150px; overflow-y: auto; }
#log li { font-size: .78rem; font-family: ui-monospace, monospace; padding: .18rem 0; border-bottom: 1px solid #f0f4f7; }
#log li.ok { color: #0a7d33; }
#log li.bad { color: #c92f3c; }
// Code not found

Notice the asymmetry: the server does not need to store every token individually. It signs the token with a secret key (here simulated with a simple hash), so verification is instant and stateless — the server just checks whether HMAC(session,secret)=token\text{HMAC}(\text{session}, \text{secret}) = \text{token}. A forged request from another origin cannot read the token from the page (the same-origin policy blocks it), so the attacker is stuck.

How the Attacks Really Work

Understanding why these defences work requires understanding exactly what the attacks exploit.

CSRF in depth

HTTP cookies are attached by the browser automatically, based on domain and path — not based on which page triggered the request. So a form on evil.example that POSTs to bank.example/transfer will carry the victim's bank.example session cookie. The server cannot tell the difference from a legitimate form submission.

The fix is a CSRF token: a random secret embedded in the legitimate page (as a hidden form field or a request header). The server issues it and later verifies it. A cross-origin page cannot read the token — the browser's same-origin policy blocks the script — so the forged form cannot include it.

Modern browsers also support the SameSite cookie attribute (Strict or Lax), which tells the browser not to send the cookie on cross-site requests at all. This is the most robust defence, but CSRF tokens remain best practice for broad compatibility.

XSS in depth

Browsers parse HTML and execute any <script> tag they find — including tags injected by untrusted data. Suppose a comment field stores the raw string <script>alert(document.cookie)</script> and the page later renders it unescaped. The browser sees valid HTML and runs the script with the same permissions as the page itself.

The fix is output escaping: replace < with &lt;, > with &gt;, " with &quot;, etc., before inserting any user-supplied text into HTML. The browser then displays the literal characters instead of interpreting them as markup. A Content Security Policy (CSP) header adds a second layer by telling the browser which script sources are allowed, blocking inline scripts even if escaping was accidentally omitted.

The common thread

Both attacks inject something (a request, a script) into a trusted context (an authenticated session, a trusted page). Both defences work by adding a piece of information the attacker cannot forge or read from a different origin. The same-origin policy is the foundation — tokens and escaping are the walls built on top of it.

Where It Matters

CSRF and XSS are not theoretical footnotes — they have caused real breaches in banks, social networks, and government portals. The defences are now baked into almost every serious web stack:

  • Web frameworks: Rails, Django, Laravel, Spring Security, and ASP.NET all ship CSRF middleware that issues and validates tokens automatically on every state-changing request.
  • Template engines: Jinja2, Blade, Thymeleaf, and Angular's default template binding all escape output by default, so XSS requires explicit opt-out (| safe, [innerHTML], etc.).
  • Authentication flows: OAuth 2.0 uses a state parameter that acts exactly like a CSRF token, preventing forged authorisation-code exchanges.
  • APIs and SPAs: REST APIs that use Authorization: Bearer headers instead of cookies are naturally immune to CSRF (cross-origin scripts cannot set that header), but they still need XSS protection because a stolen token in localStorage can be exfiltrated by an injected script.
  • Browser evolution: the SameSite=Lax default (Chrome 80+, 2020) quietly reduced CSRF exposure across the web even for sites that had not explicitly adopted tokens.

Grasping these two attacks means understanding the same-origin policy deeply — the foundation of web security and the reason why browsers compartmentalise origins at all.

Conclusion

CSRF and XSS look superficially different — one forges a request, the other injects a script — but they share a single lesson: trust must be earned, not assumed.

A CSRF token is a small secret that the legitimate page knows and an attacker cannot read across origins. Output escaping is the discipline of treating every byte of user input as text rather than as code. Neither defence requires exotic cryptography or complex infrastructure. Both work because they target the exact mechanism the attack depends on.

If you have ever wondered why your framework insists on a {% csrf_token %} in every form, or why Angular refuses to bind raw HTML by default, now you know: they are defending against decades of hard-learned lessons, and the price of forgetting them is an attacker silently controlling your users' sessions. See also factoring for another example of security built on a problem that is hard for the attacker but easy to verify for the defender.

Share this article

Pick a channel — or use your device's native share sheet.

Comments

Loading comments...

https://www.kipuhub.com/en/article/web-csrf-xss/Content licensed under CC BY-NC 4.0.