JavaScript XSS and CSRF Basics: Protecting Web Apps

This article explains the two most common browser security problems developers run into: cross-site scripting (XSS) and cross-site request forgery (CSRF). You will learn what each attack is, how it happens in JavaScript-powered web apps, how to tell them apart, and which defenses actually work.

Quick answer: XSS happens when untrusted data is executed as script in your page, while CSRF tricks a logged-in browser into sending a request the user did not intend. Prevent XSS by treating all user-controlled content as unsafe, and prevent CSRF by requiring tokens, using SameSite cookies, and verifying requests server-side.

Difficulty: Beginner

You'll understand this better if you know: basic JavaScript syntax, how browsers render HTML, and the difference between client-side code and server-side request handling.

1. What Is XSS and CSRF?

XSS and CSRF are security bugs that affect web applications, especially ones that read user input, store comments or profiles, or use cookies for login sessions. They are related because both involve the browser, but they work in very different ways.

XSS is about execution in the page. CSRF is about forged intent behind a legitimate browser session.

2. Why XSS and CSRF Matter

These bugs are serious because browsers automatically attach powerful context: session cookies, local page state, and access to user interactions. If an attacker can exploit that trust, they may read private data, send messages, change email addresses, or perform money-moving actions.

XSS matters most when your app displays user content or uses unsafe DOM APIs. CSRF matters most when your app uses cookie-based authentication and accepts state-changing requests without verifying who initiated them.

Modern JavaScript apps often have both risks at once: frontend code renders dynamic content, and backend endpoints accept authenticated requests. Good security requires defense in depth, not one single fix.

3. Core Security Model and Key Differences

The simplest way to think about the difference is this: XSS abuses what the browser will execute, while CSRF abuses what the browser will send.

AspectXSSCSRF
Primary targetThe victim’s browser and pageThe server and user session
Attacker goalRun injected codeTrigger an authenticated request
Typical weaknessUnsafe HTML insertionMissing request validation
Common defenseEscape, sanitize, avoid unsafe DOM APIsCSRF tokens, SameSite cookies, origin checks

XSS in plain terms

XSS appears when untrusted content becomes executable script or dangerous HTML in the page. A common example is inserting user input with innerHTML instead of text-only APIs.

CSRF in plain terms

CSRF happens when a site trusts a request just because it carries a valid session cookie. If the server does not require proof that the request came from your own app, another site may be able to cause the browser to send it.

4. Step-by-Step Examples

Example 1: Unsafe DOM insertion can create XSS

Here is a simple example of how a page can become vulnerable when it places user-controlled content into the DOM as HTML.

<div id="message"></div>

const params = new URLSearchParams(window.location.search);
const name = params.get("name") || "Guest";

document.getElementById("message").innerHTML = `Welcome, ${name}!`;

This code looks harmless, but it treats the value of name as HTML. If the value contains markup, the browser may parse and execute it instead of displaying it as text.

Example 2: Safe text output prevents XSS

A safer approach is to place the value into the page as plain text.

<div id="message"></div>

const params = new URLSearchParams(window.location.search);
const name = params.get("name") || "Guest";

document.getElementById("message").textContent = `Welcome, ${name}!`;

This version displays the user input as text, which prevents the browser from interpreting it as HTML or script.

Example 3: A forged request can cause CSRF

Imagine a site that changes an email address when it receives a POST request with a logged-in session cookie.

const response = await fetch("/account/email", {
  method: "POST",
  headers: {
    "Content-Type": "application/json"
  },
  body: JSON.stringify({ email: "[email protected]" })
});

If the server accepts this kind of action only because the browser is logged in, another site may try to cause a similar request without the user meaning to do it. The protection must happen on the server, not just in the frontend.

Example 4: CSRF protection with a token

A common defense is to require a secret token that only your application can provide.

const csrfToken = document.querySelector("meta[name='csrf-token']").content;

const response = await fetch("/account/email", {
  method: "POST",
  headers: {
    "Content-Type": "application/json",
    "X-CSRF-Token": csrfToken
  },
  body: JSON.stringify({ email: "[email protected]" })
});

The server should verify that token before changing any sensitive data. Without that check, the browser’s cookie alone is not enough proof of intent.

5. Practical Use Cases

6. Common Mistakes

Mistake 1: Using innerHTML for untrusted data

Developers often use innerHTML because it is convenient, but it is unsafe for content that comes from users, URLs, or external APIs.

Problem: This code parses user input as HTML, which can create XSS if the value contains markup or scriptable elements.

<div id="output"></div>

const comment = new URLSearchParams(window.location.search).get("comment");
document.getElementById("output").innerHTML = comment;

Fix: Use textContent when you want to display text, or sanitize HTML with a trusted library when rich text is truly required.

<div id="output"></div>

const comment = new URLSearchParams(window.location.search).get("comment");
document.getElementById("output").textContent = comment || "";

The fixed version works because the browser treats the value as text instead of HTML.

Mistake 2: Trusting cookies alone for sensitive actions

For CSRF, a common mistake is assuming that a valid session cookie proves the request was intentional.

Problem: The server accepts the action without a CSRF token or origin check, so a malicious site may trigger it in the user’s browser.

// Server-side example of a risky pattern
app.post("/account/email", async (req, res) => {
  await updateEmail(req.user.id, req.body.email);
  res.send("ok");
});

Fix: Require a CSRF token or validate the request Origin and Referer headers for state-changing actions.

app.post("/account/email", async (req, res) => {
  if (!isValidCsrfToken(req)) {
    return res.status(403).send("Forbidden");
  }

  await updateEmail(req.user.id, req.body.email);
  res.send("ok");
});

The corrected version works because the server checks for proof that the request came from your own app.

Mistake 3: Thinking GET requests are always safe from CSRF

Some teams assume GET cannot be dangerous, but if GET changes state, it can still be abused.

Problem: This endpoint changes server state with a GET request, so a browser can be tricked into loading it without the user’s intent.

app.get("/account/newsletter/unsubscribe", async (req, res) => {
  await unsubscribeUser(req.user.id);
  res.send("unsubscribed");
});

Fix: Use POST, PUT, PATCH, or DELETE for state changes and still protect them with CSRF defenses.

app.post("/account/newsletter/unsubscribe", async (req, res) => {
  if (!isValidCsrfToken(req)) {
    return res.status(403).send("Forbidden");
  }

  await unsubscribeUser(req.user.id);
  res.send("unsubscribed");
});

The fixed version works because state changes move to an intentional request pattern that is easier to protect.

7. Best Practices

Practice 1: Prefer text-only DOM APIs for untrusted data

Use textContent, createTextNode, or other text-safe APIs whenever the content does not need HTML formatting.

const name = "Ava";
const node = document.createTextNode(name);
document.getElementById("profile").appendChild(node);

This reduces XSS risk because the browser never interprets the value as markup.

Practice 2: Separate trusted HTML from user content

If your app must render rich text, keep the trusted template and the untrusted content separate. Sanitize the content before insertion and be strict about what tags and attributes are allowed.

function renderSafeSnippet(container, html) {
  // Replace this with a trusted sanitizer configured for your app.
  const safeHtml = sanitizeHtml(html);
  container.innerHTML = safeHtml;
}

This practice matters because some applications truly need formatted content, but they still need strong limits on what can enter the DOM.

Practice 3: Treat every state-changing request as sensitive

Use a CSRF token on requests that modify data, and verify that token on the server for each request.

async function saveSettings(settings) {
  const token = document.querySelector("meta[name='csrf-token']").content;

  return fetch("/settings", {
    method: "PATCH",
    headers: {
      "Content-Type": "application/json",
      "X-CSRF-Token": token
    },
    body: JSON.stringify(settings)
  });
}

This approach is effective because it binds the request to your application’s own page context.

8. Limitations and Edge Cases

Warning: Never use security assumptions from a local demo as proof that production is safe. Browser cookie rules, proxy layers, and authentication behavior can differ significantly between environments.

9. Practical Mini Project

Let’s put the basics together in a tiny profile update flow. The example shows safe output handling for XSS and a CSRF token for the update request.

<div id="profile"><//div>
<form id="settingsForm">
  <input id="displayName" name="displayName" type="text">
  <button type="submit">Save</button>
</form>

<meta name="csrf-token" content="token-from-server">

const profile = document.getElementById("profile");
const form = document.getElementById("settingsForm");

const currentName = "Guest";
profile.textContent = `Signed in as ${currentName}`;

form.addEventListener("submit", async (event) => {
  event.preventDefault();

  const token = document.querySelector("meta[name='csrf-token']").content;
  const displayName = document.getElementById("displayName").value;

  await fetch("/api/profile", {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      "X-CSRF-Token": token
    },
    body: JSON.stringify({ displayName })
  });
});

This mini project uses text-safe output for the displayed name and a token for the update request. In a real app, the server must also validate the token before accepting the change.

10. Key Points

11. Practice Exercise

Expected output: The preview should display the exact text the user typed, and the update request should include a token value in the header.

Hint: Use textContent for the preview and fetch with a custom header for the request.

const preview = document.getElementById("preview");
const input = document.getElementById("comment");

input.addEventListener("input", () => {
  preview.textContent = input.value;
});

async function saveComment() {
  const token = document.querySelector("meta[name='csrf-token']").content;

  return fetch("/comments", {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      "X-CSRF-Token": token
    },
    body: JSON.{ text: input.value })
  });
}

12. Final Summary

XSS and CSRF are different problems, but both rely on a browser trusting something it should not. XSS turns untrusted content into executable code; CSRF turns a logged-in browser into an unintended request sender. If you remember that distinction, the defenses become easier to reason about.

For XSS, the safest default is to avoid HTML insertion for untrusted data and use text-safe DOM APIs instead. For CSRF, protect sensitive requests with server-side verification, CSRF tokens, and appropriate cookie settings. Real-world apps usually need both sets of defenses.

As a next step, review any code that renders user content or performs authenticated state changes, then replace risky patterns with safe DOM APIs and request validation. A short security audit of one feature is often the fastest way to learn both attacks in practice.