Guide
8 min read

SQL injection and XSS: how they work and how to stop them

SQL injection and cross-site scripting (XSS) have been in the OWASP Top 10 for decades. Both exploit the same root cause: trusting unvalidated input. This guide shows you exactly how each attack works and the specific defenses that stop them.

HTML Entities Encoder

Encode special characters to safe HTML entities

Open tool

SQL injection: how it works

SQL injection happens when untrusted input is concatenated directly into a SQL query. The database cannot distinguish between the query structure and the injected data, so it executes whatever the attacker provides.

A vulnerable login check might look like this:

// VULNERABLE: never do this
const query = "SELECT * FROM users WHERE username = '" + username + "'";

// Attacker input: ' OR '1'='1
// Resulting query:
// SELECT * FROM users WHERE username = '' OR '1'='1'
// Returns every row in the table.

// Attacker input: '; DROP TABLE users; --
// Resulting query:
// SELECT * FROM users WHERE username = ''; DROP TABLE users; --'
// Drops the entire users table.

The attacker does not need to know any valid username or password. They just need to understand enough SQL to craft a string that changes the query's logic. The fix is not to sanitize the input more carefully : it is to use parameterized queries.

How to prevent SQL injection

The only reliable defense is parameterized queries (also called prepared statements). The database driver sends the query structure and the data separately. The database never parses the data as SQL : it is always treated as a literal value.

// SAFE: parameterized query with node-postgres (pg)
const result = await client.query(
  'SELECT * FROM users WHERE username = $1',
  [username]
);

// SAFE: parameterized query with mysql2
const [rows] = await connection.execute(
  'SELECT * FROM users WHERE username = ?',
  [username]
);

ORMs (Sequelize, Prisma, TypeORM, SQLAlchemy, ActiveRecord) parameterize queries automatically. As long as you use their standard query methods and avoid raw query helpers with string concatenation, you are protected.

Additional layers: give your app database user only the permissions it needs (SELECT, INSERT, UPDATE on specific tables : not DROP TABLE or database admin rights). Input validation is a useful safety net but is not a substitute for parameterization.

Cross-site scripting (XSS): three types

XSS lets an attacker inject a script that runs in another user's browser, in the context of your domain. That script can read cookies, steal tokens from localStorage, modify the page, make requests as the victim, or redirect to a phishing site.

Reflected XSS

The payload is in the request (usually a URL parameter). The server reflects it back in the HTML response, and it executes in the victim's browser. Typically delivered via a crafted link sent by the attacker. Affects only users who click the link.

Stored XSS

The payload is saved to the database (e.g. in a comment, username, or profile field). Every user who views the affected page executes the script. More dangerous because it fires automatically with no interaction beyond visiting the page.

DOM-based XSS

The payload never hits the server. Client-side JavaScript reads from an attacker-controlled source (e.g. location.hash, document.referrer) and writes it to the DOM unsafely (e.g. innerHTML = location.hash). The server sees and logs nothing unusual.

How to prevent XSS

  • Output encode before inserting into HTML. Replace < with &lt;, > with &gt;, & with &amp;, " with &quot;, and ' with &#x27; before inserting any untrusted string into HTML. Most frameworks (React, Vue, Angular) do this automatically when you use their template bindings. The HTML Entities Encoder below can demonstrate this.
  • Never use innerHTML with untrusted data. innerHTML parses the string as HTML and executes any scripts it finds. Use textContent (sets plain text, never parsed as HTML) or your framework's data binding instead.
  • Set a Content Security Policy header. CSP tells the browser which script sources are allowed. A strict policy blocks inline scripts entirely, so even if an attacker injects a <script> tag, the browser refuses to run it. Start with Content-Security-Policy: default-src 'self' in report-only mode to catch violations before enforcing.
  • Store auth tokens in HttpOnly cookies. JavaScript cannot read HttpOnly cookies. Even if an XSS payload runs on your page, it cannot steal session tokens or JWTs stored in HttpOnly cookies. Tokens in localStorage are fully readable by any script on the page.

Content Security Policy primer

A Content Security Policy is an HTTP response header that tells the browser what it is allowed to load and execute. A strict policy is one of the most effective XSS mitigations because it limits the damage even if an injection occurs.

DirectiveWhat it controls
default-src 'self'Fallback for all resource types. 'self' means only from the same origin.
script-src 'nonce-xyz'Only scripts with the matching nonce attribute execute. Injected scripts have no nonce and are blocked.
Content-Security-Policy-Report-OnlyReports violations to a report-uri without blocking anything. Use this to test a new policy before enforcing it.

The XSS and JWT storage connection

If you store a JWT in localStorage, any XSS payload that runs on your page can read it with localStorage.getItem('token') and exfiltrate it to an attacker-controlled server.

Storing JWTs in HttpOnly cookies prevents this: the browser sends the cookie automatically on every request, but JavaScript cannot read it. Combine with Secure and SameSite=Strict for full protection.

Frequently asked questions

Is input validation enough to stop SQL injection?

No. Input validation (rejecting unexpected characters) is a useful defense-in-depth layer, but it is not sufficient on its own because every application has slightly different valid inputs and allowlists are hard to get right. The only reliable defense is parameterized queries (prepared statements). Use validation in addition to parameterization, never instead of it.

Can a modern framework protect me from XSS automatically?

Mostly yes, but only when you use it correctly. React, Vue, and Angular HTML-encode all values inserted into the DOM via their template syntax. The risk remains in "escape hatches": React's dangerouslySetInnerHTML, Vue's v-html directive, and Angular's bypassSecurityTrustHtml. Treat these as a red flag in code review.

What is second-order SQL injection?

First-order SQL injection fires immediately when the malicious input is used in a query. Second-order (or stored) SQL injection happens when untrusted data is safely stored in the database, but then retrieved and inserted unsafely into a different query later. The defense is the same: parameterize every query, regardless of whether the data came from a user directly or from your own database.

What is the difference between stored and reflected XSS?

Reflected XSS is transient: the payload is in the request and the response, but never persisted. Each victim must click a crafted link. Stored XSS is persistent: the payload is saved (in a comment, profile, etc.) and fires for every user who views the content. Stored XSS has a larger blast radius because it requires no attacker interaction after the initial injection.

Does HTTPS prevent XSS?

No. HTTPS encrypts the connection between the browser and the server, which prevents network-level attackers from injecting content in transit. But XSS is an application-layer vulnerability: the malicious script is served by your own server as part of a legitimate HTTPS response. HTTPS does nothing to prevent it.

Encode HTML entities instantly

Convert unsafe characters like < > & into their safe HTML entity equivalents.

Open HTML Entities Encoder