JavaScript/Web Security Basics

Understand the most common client-side web security risks — especially XSS — and learn the defensive habits (safe DOM APIs, input validation, output encoding, understanding CORS and same-origin policy) that prevent them.

Because JavaScript runs directly in a user's browser and can read and rewrite the page, a single careless line of code can let an attacker run their own script inside your site. This lesson covers the core client-side risks every JavaScript developer needs to recognize, using safe, non-exploitative examples to illustrate each one.

Cross-Site Scripting (XSS)

XSS happens when an attacker manages to get their own JavaScript to run inside your page, usually by injecting it through some piece of user-controlled data (a comment, a username, a URL parameter) that your code later inserts into the page without sanitizing it. Once that script runs, it executes with all the same privileges as your own code — it can read cookies, make requests as the logged-in user, or alter the page's content.

There are three common variants: stored XSS (malicious input is saved on the server and served back to other users, e.g. in a comment field), reflected XSS (malicious input comes from the current request, e.g. a query parameter echoed back into the page), and DOM-based XSS (the vulnerability lives entirely in client-side code that unsafely reads from something like location.hash or location.search and writes it into the DOM).

Unsafe innerHTML and safer alternatives

element.innerHTML = someString parses someString as HTML and inserts it, which means any <script> tags, event handler attributes (onerror=...), or other executable markup inside it will run. This is safe only when someString is a fixed value you fully control — it is never safe to assign untrusted or user-supplied text through innerHTML.

The safe alternative for inserting plain text is element.textContent = someString, which always treats the value as literal text, never as markup — it is rendered on the page exactly as written, with no risk of it being interpreted as HTML or executing anything.

Input validation and output encoding

Input validation means checking that data entering your system (a form field, a URL parameter, an API payload) matches the shape you expect — the right type, length, and format — and rejecting it otherwise. It reduces the attack surface but is not itself a defense against XSS, because valid-looking input can still contain dangerous characters.

Output encoding (also called escaping) is what actually prevents injected markup from executing: it converts characters that have special meaning in HTML — <, >, &, ", ' — into their harmless entity equivalents (<, >, etc.) at the moment data is inserted into the page, so a string like <script> is displayed as literal text instead of being parsed as a tag.

CSRF, CORS, and the same-origin policy

Cross-Site Request Forgery (CSRF) tricks a logged-in user's browser into sending an unwanted request to a site they're authenticated on (e.g. a hidden auto-submitting form on a malicious page that posts to your bank's transfer endpoint), relying on the browser automatically attaching stored cookies. Defenses (CSRF tokens, SameSite cookies) live mostly on the server side, but it's important to understand the threat.

The same-origin policy is the browser's default rule that a script from one origin (protocol + host + port) cannot read the response of a request made to a different origin, which is what stops a malicious page from silently reading your bank balance via a background request. CORS (Cross-Origin Resource Sharing) is the mechanism a server uses to deliberately loosen that restriction — via response headers like Access-Control-Allow-Origin — to allow specific other origins to read its responses.

Token and storage considerations

Where you keep an authentication token matters. localStorage and sessionStorage are plain JavaScript-accessible storage, meaning any script running on the page (including an injected XSS script) can read them — storing a sensitive auth token there means an XSS bug becomes a full account-takeover bug. An httpOnly cookie, by contrast, is set by the server and is never exposed to JavaScript at all, which is why it is generally the safer place for session tokens.

No storage choice protects against everything on its own — httpOnly cookies resist XSS token theft but need CSRF protections, since the browser sends them automatically with requests. Security is layered, not a single silver-bullet setting.

Never trust client-side validation alone

Client-side validation (checking a form field's format in JavaScript before submitting) exists purely to give users fast, friendly feedback — it runs entirely inside a browser the attacker fully controls, and can be disabled, bypassed, or skipped entirely by sending a crafted request directly to your server (with a tool as simple as the browser's own DevTools console, or curl). Every rule that actually matters for security or data integrity must be enforced again on the server, which is the only environment the attacker doesn't control.

Safe vs unsafe DOM insertion

textContent is the default-safe choice whenever you are inserting data that originated from a user, a URL, or any external source. If you truly need to render sanitized HTML from user content (e.g. a markdown preview), use a well-tested sanitization library rather than writing your own escaping logic.

// UNSAFE: treats untrustedInput as HTML markup, can execute injected scripts
element.innerHTML = untrustedInput;

// SAFE: always treated as literal text, never parsed as markup
element.textContent = untrustedInput;

// SAFE alternative when you DO need to build elements dynamically:
const p = document.createElement("p");
p.textContent = untrustedInput;
element.appendChild(p);

Code Examples

The commented-out innerHTML line shows, without actually running it, how a broken <img> tag with an onerror handler is a classic minimal XSS payload — the browser tries to load 'x' as an image, fails, and runs the onerror script. Using textContent instead displays the exact same string as harmless, visible text. Expected result: <img src=x onerror="alert(1)"> (displayed literally on the page as text, not run as a script) commentBox.textContent = userComment; renders the string as plain text — the tag is shown as characters, never executed.

This demonstrates the principle of output encoding by hand: every character with special HTML meaning is converted to a harmless entity before the string is ever inserted as markup, neutralizing any tags it contains. Expected result: <b>bold</b> & "quotes" In real applications, prefer a maintained templating engine or framework's built-in escaping over hand-rolled functions like this one — it's shown here purely to illustrate the concept.

Each .replace() call escapes one dangerous character class, in an order chosen so & is escaped first (otherwise it would double-escape the entities created by the later replacements).

The comment makes explicit what many beginners miss: validateAgeClientSide only improves the user experience by catching obvious mistakes early. It provides zero real security, because nothing stops an attacker from sending a POST directly to /api/profile with an out-of-range or malformed age, skipping this function entirely.

// an attacker can call this endpoint directly, bypassing this function entirely — client checks are for UX, server checks are for security.

Common mistake: Believing that because the client-side check exists, the server can skip re-validating the same data — this leaves the API wide open to anyone bypassing the browser UI.

const userComment = '<img src=x onerror="alert(1)">';
const commentBox = document.querySelector("#comment");

// UNSAFE — do not do this with real user input:
// commentBox.innerHTML = userComment;

// SAFE:
commentBox.textContent = userComment;
console.log(commentBox.textContent);
function escapeHtml(str) {
  return str
    .replace(/&/g, "&amp;")
    .replace(/</g, "&lt;")
    .replace(/>/g, "&gt;")
    .replace(/"/g, "&quot;")
    .replace(/'/g, "&#039;");
}

const untrusted = '<b>bold</b> & "quotes"';
console.log(escapeHtml(untrusted));
// Client-side check: fast feedback only, NOT a security boundary
function validateAgeClientSide(input) {
  const age = Number(input);
  return Number.isInteger(age) && age >= 0 && age <= 120;
}

async function submitAge(rawInput) {
  if (!validateAgeClientSide(rawInput)) {
    console.log("Please enter a valid age before submitting.");
    return;
  }
  // The request still goes to the server, which MUST re-validate independently —
  // an attacker can call this endpoint directly, bypassing this function entirely.
  await fetch("/api/profile", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ age: Number(rawInput) })
  });
}

Important Rules

Never assign untrusted or user-supplied strings to innerHTML — use textContent, or a vetted sanitization library if HTML rendering is genuinely required.

Input validation limits what shape data can take; output encoding is what actually stops injected markup from executing — you generally need both.

The same-origin policy is the browser's default protection; CORS headers are how a server deliberately opts specific other origins into reading its responses.

Never store sensitive authentication tokens in localStorage/sessionStorage if you can avoid it — both are fully readable by any script running on the page, including an injected one.

Client-side validation is a UX convenience only — every security- or data-integrity-relevant rule must be enforced again on the server.

Where this shows up

Comment sections, user profile fields, and search results pages are classic XSS targets because they display user-controlled text back to other visitors. CORS configuration is something every team hits the moment a front end on one domain needs to call an API on another. And the 'never trust the client' principle is the reason every serious API re-validates and re-authorizes every request, no matter how well-behaved the official front end is.

resultsDiv.innerHTML = "Results for: " + searchQuery; // searchQuery came from the URL resultsDiv.textContent = "Results for: " + searchQuery;

If searchQuery comes from a URL parameter or any user-controlled source, using innerHTML lets an attacker craft a URL whose query string is actual HTML/script markup, resulting in DOM-based XSS the moment the page renders it.

if (clientSideValidatePassword(pw)) { saveToDatabase(pw); } // server trusts the client check

// Server independently re-validates password rules before saveToDatabase(pw), regardless of what the client sent.

Client-side checks can be bypassed entirely by calling the API directly, so any validation that protects data integrity or security must be repeated, independently, on the server.

[Multiple Choice] Which of these is the safest way to insert a string that came from user input into the DOM? A. element.innerHTML = userInput; B. element.outerHTML = userInput; C. element.textContent = userInput; D. document.write(userInput);

C. element.textContent = userInput; — element.textContent always treats the assigned value as literal text and never parses it as HTML, so it cannot execute injected script tags or event-handler attributes.

[True / False] If a form field has JavaScript validation in the browser, the server can safely skip validating that same field again. A. True B. False

B. False — False. Client-side validation only runs inside a browser the user (or an attacker) fully controls and can be bypassed by sending requests directly to the server, so the server must always re-validate independently.

You are building a 'Welcome back, {name}' banner where {name} comes from a user profile that could contain arbitrary text (potentially including HTML). Write the DOM code to safely display it without risking XSS.

const banner = document.querySelector("#welcome-banner"); const userName = getUserProfile().name; // may contain arbitrary text banner.textContent = Welcome back, ${userName}; // Safe: textContent never interprets the interpolated value as HTML, // even if userName itself contains characters like < or >.

Build the banner from two separate text pieces using textContent, or create the element and set textContent directly — avoid concatenating the name into an HTML string.