Security

XSS and CSRF: Two Attacks, Two Completely Different Defenses

If you have a CSRF token but the attacker's script still executes, you've misplaced the problem. See the difference between XSS and CSRF and the correct defense for each.

Security

If you've seen something like Refused to execute inline script because it violates the following Content Security Policy directive in your WAF log or browser console, and at the same time users report "after logging in, it logs in by itself," you're probably dealing with two separate issues that you've confused with each other. Both XSS and CSRF exploit the victim's browser, but one executes code in your page and the other just sends a request. Until you separate this difference in your mind, you'll implement both defenses halfway.

What Is XSS and Why a CSRF Token Doesn't Stop It

In XSS, the attacker makes the victim's browser execute their JavaScript in your domain. From that moment, that script is inside your origin. That means it has access to document.cookie, it can make fetch calls, build forms, and most importantly, read the CSRF token from the page itself and include it in the same request.

Here's where the mistake happens: teams put the CSRF token on the form, pass the penetration test, and feel relieved. Then a comments field is left without escaping, and that same token is read and used by the attacker's script. The sign is this: in the log, requests have valid tokens, the Referer is correct, everything looks healthy, but the operation was performed from the real user's IP. The CSRF token was never built to stop code that executes inside your origin. That's the job of CSP and escaping output.

Three Types of XSS You See in Practice

  • Reflected: Input comes from the URL and is returned in HTML without escaping. Its most classic form is ?q=<script>alert(1)</script>.
  • Stored: Input stays in the database and is served to all visitors. The most dangerous type, because it doesn't require tricking the victim into clicking.
  • DOM-based: Neither the server is involved nor is the HTML response tainted. The client-side code itself dumps the value of location.hash into innerHTML.

For the third type, no server-side filter helps. You have to read the JavaScript code and see where innerHTML, document.write, or eval is used. The correct replacement is textContent, not a regex that strips out "script".

What Is CSRF and Why SameSite Alone Isn't Enough

In CSRF, the attacker doesn't execute code; they just make the victim's browser send a request to your domain, with its own session cookie. The attacker's page is on a different domain and can't see your cookie, but the browser automatically attaches it. A hidden form with method="POST" and action pointing to the email-change endpoint is submitted as soon as the page loads.

The SameSite=Lax cookie closes a large portion of these cases, but falls short in two places. First, older browsers that ignore this feature. Second, your own subdomains: if a subdomain is for any reason under the attacker's control or has XSS, the request is considered same-site from SameSite's perspective. So set SameSite, but as a second layer, not a replacement for the token.

The Correct CSRF Token Pattern

The token must be per-session and random, stored on the server, and checked on every state-changing request. Only accept the double-submit pattern when the token cookie is not HttpOnly and is signed; otherwise a subdomain can overwrite it.

// Express + csurf-style
app.use(session({ cookie: { httpOnly: true, sameSite: 'lax', secure: true } }));
app.use(csrf({ cookie: false })); // token in session, not a separate cookie
app.use((req, res, next) => {
  res.locals.csrfToken = req.csrfToken();
  next();
});

A practical note: don't put the token in a meta tag and then read it with JavaScript. The moment the token becomes readable by JS, it's also readable by XSS. Render it in the HTML form server-side.

What Exactly Does an HttpOnly Cookie Close

Send the session cookie with HttpOnly so that document.cookie can't see it. This closes direct session theft via XSS, but it doesn't stop CSRF, because the browser still attaches the cookie automatically. These two mechanisms work on two different axes and don't replace each other.

ActionStops XSSStops CSRF
Escaping outputYesNo
HttpOnly cookiePartially (session theft)No
CSRF tokenNoYes
SameSite=Lax/StrictNoLargely
CSP headerYes (defense layer)No

How to Actually Make Content-Security-Policy Work

A strict CSP, even if XSS exists, stops the attacker's script from executing. But a wrong CSP breaks the site, and the team removes it after two hours. The realistic path is this:

  1. First start with Content-Security-Policy-Report-Only and collect reports to an endpoint.
  2. After a week, put the real domains in script-src and remove 'unsafe-inline'.
  3. For inline scripts, use a nonce, not a hash, because the nonce changes with every response.
Content-Security-Policy:
  default-src 'self';
  script-src 'self' 'nonce-8f3a...' https://cdn.example.ir;
  object-src 'none';
  base-uri 'self';
  frame-ancestors 'self'

Don't underestimate the base-uri 'self' line. Without it, an attacker can inject a <base> tag and send all relative paths on the page to their own domain, stealing the CSRF token from your form. This is one of those things that gets missed in checklists.

The Real Trade-off: Why You Can't Be Strict Everywhere

A strict CSP with zero 'unsafe-inline' on legacy sites with dozens of inline scripts and third-party plugins practically means rewriting the frontend. If the project is in the middle of a migration, my choice is this: first complete escaping output and the CSRF token, put CSP in Report-Only mode, and gradually tighten it. If the site is newly built, set up nonce from day one; adding it later costs several times more.

There's another limitation that's rarely mentioned: the CSRF token is meaningless on stateless APIs that work with Bearer tokens. There, if you keep the token in localStorage, one XSS is enough to lose everything. That's why on APIs, an HttpOnly cookie along with a CSRF token is the better choice, even if it's harder to manage.

A Practical Checklist for Tonight

  • Send all session cookies with HttpOnly; Secure; SameSite=Lax.
  • Protect every state-changing endpoint with a CSRF token, including logout.
  • Escape outputs in HTML context; use textContent for JavaScript.
  • Turn on the CSP header in Report-Only mode and watch the logs.
  • Keep the audit log separate and untouched so you can reconstruct after an incident; What Is an Audit Log and How Do We Keep It Untouched? explains exactly this.

If you work on WordPress, a large portion of these holes come from plugins and the REST API. Read the two posts WordPress Security; From wp-config to Plugins That Are Themselves Holes and Securing the WordPress REST API before anything else. For the network layer, if your service is under volumetric attacks, DDoS Protection complements these efforts, but it doesn't replace escaping output. If your team is large and you exchange passwords in chat, also see Password Management for Teams; a stolen session often starts right there.

If you want to put these layers on infrastructure that carries part of the load itself instead of building from scratch, ServerNet Security Services is a reasonable starting point. But remember: no service replaces writing textContent in your code.

Frequently Asked Questions

Does SameSite=Lax replace the CSRF token?

Not completely. SameSite=Lax stops most cross-site requests, but it doesn't reject top-level GET requests and isn't reliable on older browsers. If your endpoint changes state with GET, SameSite doesn't help at all. Keep the CSRF token and treat SameSite as a second layer.

Why does an XSS attack still succeed despite a CSRF token?

Because the attacker's script executes inside your origin and has access to the same DOM the real user has. It can read the token from the form and send a valid request. The correct defense in this case is escaping output and CSP, not the token.

Which attack does an HttpOnly cookie stop?

It stops JavaScript from reading the session cookie, so it makes direct session theft via XSS harder. But it doesn't stop CSRF, because the browser still automatically attaches the cookie to the request.

What's the minimum I can do today?

Three things: make the session cookie HttpOnly; Secure; SameSite=Lax, close every state-changing endpoint with a CSRF token, and escape all user outputs. After these three, move on to CSP. The order matters; CSP on code that still doesn't escape just breaks the site.

ServerNet Support

ServerNet engineering & editorial team — specialists in infrastructure, networking and web hosting.

Security Services
Share:

Comments 0

No comments yet — be the first!

Leave a comment

Related service

Security Services

Penetration testing by OSCP-certified specialists, infrastructure hardening and 24/7 security monitoring — reports managers understand and engineers can act on.