XSS in URL parameters

URL parameters are one of the most common places where XSS starts. They feel harmless because they arrive as plain text:

https://example.com/search?q=shoes

Then somebody reads q, drops it into the page, and now you have script execution.

The vulnerable pattern is usually boring:

const params = new URLSearchParams(window.location.search);
const q = params.get('q');
document.getElementById('search-label').innerHTML = `Results for: ${q}`;

If q is:

<img src=x onerror=alert(1)>

you just handed the browser executable HTML.

This guide is the practical version: where URL parameter XSS shows up, what unsafe code looks like, and what to replace it with.

The core rule

Treat every value from:

  • window.location.search
  • window.location.hash
  • window.location.href
  • route params
  • query params from server requests

as untrusted input.

Then apply the boring but correct rule:

  • Validate input where possible
  • Encode or escape for the output context
  • Prefer safe DOM APIs over HTML injection
  • Use CSP as backup, not as the primary fix

Common vulnerable patterns

1. Writing query params with innerHTML

Bad:

const params = new URLSearchParams(location.search);
const name = params.get('name') || 'guest';

document.querySelector('#welcome').innerHTML = `Welcome, ${name}`;

Safe:

const params = new URLSearchParams(location.search);
const name = params.get('name') || 'guest';

document.querySelector('#welcome').textContent = `Welcome, ${name}`;

If you only need text, textContent is the right answer almost every time.

2. Building HTML strings from URL params

Bad:

const params = new URLSearchParams(location.search);
const category = params.get('category');

const html = `
  <div class="badge">
    Category: ${category}
  </div>
`;

document.getElementById('filters').innerHTML = html;

Safe:

const params = new URLSearchParams(location.search);
const category = params.get('category') || '';

const badge = document.createElement('div');
badge.className = 'badge';
badge.textContent = `Category: ${category}`;

document.getElementById('filters').appendChild(badge);

If you find yourself assembling HTML with template strings and untrusted data, stop and switch to DOM node creation.

3. Passing URL params into dangerous sinks

These are the APIs I immediately distrust when query params are nearby:

  • innerHTML
  • outerHTML
  • insertAdjacentHTML
  • document.write
  • eval
  • setTimeout(string)
  • setInterval(string)

Bad:

const params = new URLSearchParams(location.search);
const message = params.get('message');

setTimeout(`showToast("${message}")`, 100);

Safe:

const params = new URLSearchParams(location.search);
const message = params.get('message') || '';

setTimeout(() => showToast(message), 100);

If a parameter ends up in code, not just markup, the impact gets ugly fast.

Context matters

XSS prevention is not just “escape everything.” The correct defense depends on where the value lands.

HTML text context

Safe approach: use textContent.

titleEl.textContent = params.get('title') || '';

Server-side example in Node/Express with template rendering:

app.get('/search', (req, res) => {
  const q = req.query.q || '';
  res.render('search', { q });
});

Template:

<h1>Results for {{ q }}</h1>

This is safe only if your template engine escapes HTML by default. Most modern ones do, but I always verify. Don’t assume.

HTML attribute context

Attribute injection is another classic mess.

Bad:

const redirect = params.get('next');
link.innerHTML = `<a href="${redirect}">Continue</a>`;

This is vulnerable to breaking out of the attribute and injecting more HTML.

Safer:

const redirect = params.get('next') || '/';
const a = document.createElement('a');
a.href = redirect;
a.textContent = 'Continue';
container.replaceChildren(a);

But there’s a second issue here: even if you safely set href, a user-controlled javascript: URL is still bad.

Better:

const redirect = params.get('next') || '/';

function safeInternalPath(value) {
  if (typeof value !== 'string') return '/';
  if (!value.startsWith('/')) return '/';
  if (value.startsWith('//')) return '/';
  return value;
}

const a = document.createElement('a');
a.href = safeInternalPath(redirect);
a.textContent = 'Continue';
container.replaceChildren(a);

For URL-bearing attributes like href, src, and action, validation matters just as much as escaping.

JavaScript context

Don’t inject URL params directly into scripts.

Bad server-rendered page:

<script>
  const searchTerm = "{{ q }}";
</script>

If escaping is wrong or incomplete for JavaScript string context, you’re exposed.

Safer options:

Option 1: Put data in the DOM as text

<div id="boot" data-search="{{ q }}"></div>
<script>
  const boot = document.getElementById('boot');
  const searchTerm = boot.dataset.search || '';
</script>

Option 2: Serialize as JSON safely

<script type="application/json" id="boot-data">
  {"searchTerm": {{ qJson }}}
</script>
<script>
  const data = JSON.parse(document.getElementById('boot-data').textContent);
  console.log(data.searchTerm);
</script>

On the server:

app.get('/search', (req, res) => {
  const q = req.query.q || '';
  res.render('search', {
    qJson: JSON.stringify(q)
  });
});

I prefer JSON serialization over trying to manually “escape enough” for inline JavaScript. Manual escaping is where bugs breed.

URL context

Sometimes a parameter is used to build another URL.

Bad:

const img = params.get('img');
document.querySelector('#preview').src = img;

This can become a problem depending on what schemes and origins you allow.

Safer:

const img = params.get('img') || '';

function safeImageUrl(value) {
  try {
    const url = new URL(value, location.origin);
    if (url.origin !== location.origin) return '/images/default.png';
    if (!url.pathname.startsWith('/uploads/')) return '/images/default.png';
    return url.href;
  } catch {
    return '/images/default.png';
  }
}

document.querySelector('#preview').src = safeImageUrl(img);

The rule here is simple: if the parameter controls navigation or resource loading, whitelist what “valid” looks like.

Framework-specific examples

React

React escapes text by default.

Safe:

function SearchPage() {
  const params = new URLSearchParams(window.location.search);
  const q = params.get('q') || '';

  return <h1>Results for: {q}</h1>;
}

Dangerous:

function SearchPage() {
  const params = new URLSearchParams(window.location.search);
  const q = params.get('q') || '';

  return <div dangerouslySetInnerHTML={{ __html: q }} />;
}

If you use dangerouslySetInnerHTML, you need sanitization first. And no, “we only use it for trusted content” usually turns into famous last words.

Vue

Safe:

<template>
  <h1>Results for: {{ q }}</h1>
</template>

<script setup>
const params = new URLSearchParams(window.location.search);
const q = params.get('q') || '';
</script>

Dangerous:

<template>
  <div v-html="q"></div>
</template>

<script setup>
const params = new URLSearchParams(window.location.search);
const q = params.get('q') || '';
</script>

v-html has the same smell as innerHTML. Use it only with sanitized content.

Server-side validation patterns

Sometimes the best fix is refusing unexpected input.

If sort should only be one of a few values:

const allowedSort = new Set(['price', 'name', 'rating']);
const sort = allowedSort.has(req.query.sort) ? req.query.sort : 'name';

If page should be numeric:

const page = Number.parseInt(req.query.page, 10);
const safePage = Number.isInteger(page) && page > 0 ? page : 1;

If tab should be a short slug:

const tab = /^[a-z0-9-]{1,20}$/.test(req.query.tab || '')
  ? req.query.tab
  : 'overview';

Validation won’t replace output encoding, but it shrinks the attack surface a lot.

CSP as backup

A good Content Security Policy can make some XSS payloads fail even when somebody ships unsafe rendering code.

A baseline CSP might look like:

Content-Security-Policy:
  default-src 'self';
  script-src 'self' 'nonce-rAnd0m123';
  object-src 'none';
  base-uri 'self';
  frame-ancestors 'none';

For implementation details and rollout strategy, see https://csp-guide.com.

I like CSP because it catches mistakes. I do not trust it as the only defense. If your page is still injecting attacker HTML, CSP is just the seatbelt after you drove into the wall.

Copy-paste safe utility patterns

Read and render text safely

function getQueryParam(name, fallback = '') {
  const params = new URLSearchParams(window.location.search);
  return params.get(name) ?? fallback;
}

function renderText(selector, value, prefix = '') {
  const el = document.querySelector(selector);
  if (!el) return;
  el.textContent = `${prefix}${value}`;
}

const q = getQueryParam('q');
renderText('#search-label', q, 'Results for: ');

Safe redirect target from query params

function getSafeNextParam() {
  const params = new URLSearchParams(window.location.search);
  const next = params.get('next') || '/dashboard';

  if (!next.startsWith('/')) return '/dashboard';
  if (next.startsWith('//')) return '/dashboard';

  return next;
}

document.querySelector('#continue').addEventListener('click', () => {
  window.location.assign(getSafeNextParam());
});

Safe numeric parameter

function getSafeIntParam(name, fallback = 1, min = 1, max = 100) {
  const params = new URLSearchParams(window.location.search);
  const value = Number.parseInt(params.get(name), 10);

  if (!Number.isInteger(value)) return fallback;
  if (value < min || value > max) return fallback;
  return value;
}

const page = getSafeIntParam('page', 1, 1, 999);

Quick review checklist

When I review code that touches URL params, I check these first:

  • Does the value end up in innerHTML, outerHTML, or insertAdjacentHTML?
  • Does the value control href, src, action, or redirects?
  • Does the value get inserted into inline JavaScript?
  • Are route params and hash fragments treated as untrusted too?
  • Does the template engine escape by default in this context?
  • Are there allowlists for enums, numeric fields, and internal paths?
  • Is CSP present as a backup layer?

If you fix only one thing today, replace unsafe HTML insertion with textContent or framework-native escaped rendering. That one change kills a huge percentage of URL parameter XSS bugs.