How to Protect Web Apps from XSS and CSRF Vulnerabilities: A Production Retrospective

Security tutorial - IT technology blog
Security tutorial - IT technology blog

Six Months of Getting Burned (and Then Fixing It)

Last year, a staging environment I managed got flagged in a routine penetration test. At the top of the report: Cross-Site Scripting (XSS) and Cross-Site Request Forgery (CSRF). Neither was a mystery to me in theory. Seeing them exploited against an app I actually built, though — that hit differently.

What followed was six months of retrofitting defenses, rewriting CSP policies, rethinking form handling, and watching the vulnerability surface slowly shrink. This isn’t a copy-paste of OWASP docs. It’s a writeup of the specific changes that made a measurable difference in production — the ones I wish I’d had on day one.

Understanding the Two Threats

XSS — When Your App Speaks for the Attacker

Cross-Site Scripting happens when an attacker injects malicious JavaScript into a page that other users then load. The browser has no idea the script wasn’t written by you. It executes it with full trust, in the full context of your domain.

Three main flavors to know:

  • Reflected XSS: The payload lives in the URL or form input and is immediately echoed back in the response.
  • Stored XSS: The payload gets saved to the database — say, a comment field — and then rendered to every visitor who loads that page. Far more dangerous at scale.
  • DOM-based XSS: The attack happens entirely in the browser. JavaScript reads from location.hash or a similar source and writes it to the DOM without touching the server at all.

The damage range is wide. Session hijacking. Credential theft. Full page defacement. Phishing overlays that look pixel-perfect to the victim.

CSRF — Tricking the Browser Into Acting for the Attacker

CSRF exploits something browsers do automatically: attach cookies to every matching request. If a user is logged into bank.com and an attacker gets them to load a page with a hidden form that submits to bank.com/transfer, the browser sends the session cookie along. The server sees a valid-looking request. The money moves.

No code injection required. CSRF abuses the trust relationship between browser and server, turning the victim’s own credentials against them.

Hands-On: XSS Defense

1. Output Encoding — The Foundation

Every piece of user-supplied data rendered in HTML must be encoded before output. Non-negotiable, full stop. In Python with Jinja2 (Flask/Django), autoescaping covers most of this by default — but you can accidentally blow past it with | safe filters used carelessly.

# BAD — directly rendering user input
return f"<p>Welcome, {request.args.get('name')}</p>"

# GOOD — let the template engine escape it
# In Jinja2 (autoescape enabled):
return render_template("welcome.html", name=request.args.get("name"))
# welcome.html: <p>Welcome, {{ name }}</p>  ← auto-escaped

HTML escaping isn’t enough when you’re injecting data into JavaScript contexts. Use json.dumps() or a dedicated JS encoder instead:

import json

user_data = request.args.get("username", "")
# Safe injection into a JS context:
js_safe = json.dumps(user_data)  # adds quotes and escapes special chars

2. Content Security Policy (CSP) — Defense in Depth

Even when encoding slips somewhere — and eventually it will — a well-configured CSP limits what injected scripts can actually do. This was the single change with the most visible impact in my setup. One header, huge reduction in exploitability.

# Nginx config — add to your server block
add_header Content-Security-Policy \
  "default-src 'self'; \
   script-src 'self' 'nonce-RANDOM_NONCE_HERE'; \
   style-src 'self' 'unsafe-inline'; \
   img-src 'self' data: https:; \
   object-src 'none'; \
   frame-ancestors 'none';" always;

The nonce approach is what makes inline scripts manageable. Generate a fresh cryptographic nonce on every request, embed it in both the CSP header and your <script> tags. Any injected script without the correct nonce gets blocked cold.

import secrets

# In your request handler:
nonce = secrets.token_hex(16)  # 32-char hex, e.g. "a3f9c2...d1"
# Pass to template + set in CSP header
response.headers["Content-Security-Policy"] = (
    f"script-src 'self' 'nonce-{nonce}'"
)
# In template:
# <script nonce="{{ nonce }}">...</script>

3. DOM XSS — Watch Your JavaScript

Static analysis tools miss this entirely if they only scan server-side code. Audit your frontend for patterns like these:

// Dangerous — directly injecting URL fragment into DOM
document.getElementById("output").innerHTML = location.hash.slice(1);

// Safe alternative
document.getElementById("output").textContent = location.hash.slice(1);

Reach for textContent instead of innerHTML any time you don’t actually need to render HTML. When you genuinely do need HTML output, use a sanitizer. DOMPurify is the standard choice — actively maintained, well-tested, tiny footprint:

import DOMPurify from "dompurify";

const clean = DOMPurify.sanitize(untrustedHTML);
document.getElementById("output").innerHTML = clean;

Hands-On: CSRF Defense

1. Synchronizer Token Pattern

Classic defense, still essential. Generate a secret token server-side, embed it in every form as a hidden field, validate it on submission. An attacker’s third-party page can’t read the token due to the same-origin policy — so they can’t forge a valid request, even if they know the endpoint.

# Flask example with flask-wtf (handles CSRF tokens automatically)
from flask_wtf import FlaskForm
from wtforms import StringField

class TransferForm(FlaskForm):
    amount = StringField("Amount")

# In the template:
# <form method="POST">
#   {{ form.hidden_tag() }}  <!-- injects csrf_token -->
#   {{ form.amount() }}
# </form>

JSON APIs need a slightly different approach. Put the token in a custom request header — browsers block cross-origin JS from setting custom headers:

// Client-side: read the CSRF token from a cookie or meta tag
const csrfToken = document.cookie
  .split("; ")
  .find(row => row.startsWith("csrftoken="))
  ?.split("=")[1];

fetch("/api/transfer", {
  method: "POST",
  headers: {
    "Content-Type": "application/json",
    "X-CSRFToken": csrfToken,
  },
  body: JSON.stringify({ amount: 100 }),
});

2. SameSite Cookie Attribute

Modern browsers support SameSite on cookies, which controls when the cookie gets sent on cross-site requests. Set it to Strict or Lax and you break most CSRF scenarios with zero application-level code changes:

# In Flask:
app.config.update(
    SESSION_COOKIE_SAMESITE="Lax",    # or "Strict"
    SESSION_COOKIE_SECURE=True,        # HTTPS only
    SESSION_COOKIE_HTTPONLY=True,      # JS can't read it
)

Go with Lax over Strict for most apps. Strict blocks dangerous cross-site mutations, but it also breaks legitimate navigations — a user clicking a link in an email to your app will land logged out. Lax gives you the important protection (blocks cross-site POST) without that friction.

3. Verify the Origin Header

For state-changing requests, validate the Origin or Referer header server-side as a secondary layer. Lightweight and effective:

from urllib.parse import urlparse
from flask import request, abort

ALLOWED_ORIGINS = {"https://yourdomain.com"}

def verify_origin():
    origin = request.headers.get("Origin") or request.headers.get("Referer", "")
    parsed = urlparse(origin)
    origin_base = f"{parsed.scheme}://{parsed.netloc}"
    if origin_base not in ALLOWED_ORIGINS:
        abort(403)

Security Headers Checklist

Beyond CSP, a handful of HTTP headers add real protection with almost zero effort. These go in your Nginx config and take about five minutes to set up:

# Nginx — add to http {} or server {} block
add_header X-Frame-Options "DENY" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "geolocation=(), camera=(), microphone=()" always;
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always;

X-Frame-Options: DENY stops your app from being embedded in an iframe — the classic CSRF delivery vector via clickjacking. X-Content-Type-Options: nosniff prevents browsers from guessing a response’s content type away from what you declared, which cuts off a class of content-injection attacks. Both are one-liners with outsized impact.

Testing What You’ve Built

Defense without verification is just optimism. After wiring up all of the above, I ran:

  • OWASP ZAP (automated scanner) against the staging URL — catches most reflected XSS and missing security headers in minutes.
  • Burp Suite Community — intercept live form submissions, strip the CSRF token manually, and confirm the server returns a 403. If it doesn’t, something is broken.
  • securityheaders.com — paste your URL and get a graded breakdown of your response headers. Free, instant, no setup.
  • Manual DOM review for any innerHTML, document.write, or eval() calls in frontend JS — grep is your friend here.

One More Piece: Credential Hygiene

Hardening XSS and CSRF defenses forced me to revisit service account passwords too. A stolen session token from an XSS attack is bad. A weak admin password on top of that is catastrophic — two problems that multiply each other.

For server and database credentials, I use the generator at toolcraft.app/en/tools/security/password-generator. What keeps me coming back: it runs entirely in the browser. No password is ever transmitted over the network. For anything I need to remember, the pronounceability option is genuinely useful — it generates strings like truv-6Xep-malk rather than random symbol soup.

Where This Leaves You

Six months in, the combination of strict output encoding, nonce-based CSP, synchronizer tokens on all state-changing forms, and SameSite=Lax cookies reduced our attack surface to the point where follow-up pen tests came back clean on both categories. That’s a measurable outcome, not just a theoretical improvement.

None of these fixes are heroic. They’re configuration and discipline — encode your output, validate your tokens, set your headers. The hard part is applying them consistently: every input vector, every endpoint, as the codebase grows and new developers join. Automated testing in CI (ZAP scan on every deploy) is what stops coverage from eroding over time.

XSS and CSRF are old vulnerabilities. They keep showing up not because they’re hard to fix, but because they’re easy to miss when you’re shipping fast. Now you have the checklist — and the code to back it up.

Share: