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.hashor 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, oreval()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.

